diff --git a/package-lock.json b/package-lock.json
index 87c50f48..4330416e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "imwald",
- "version": "23.18.3",
+ "version": "23.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
- "version": "23.18.3",
+ "version": "23.19.0",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
diff --git a/package.json b/package.json
index 9ff0241b..9d92141c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "imwald",
- "version": "23.18.3",
+ "version": "23.19.0",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",
diff --git a/src/components/Note/MusicTrackNote.tsx b/src/components/Note/MusicTrackNote.tsx
index 7587a993..7c6ad569 100644
--- a/src/components/Note/MusicTrackNote.tsx
+++ b/src/components/Note/MusicTrackNote.tsx
@@ -11,8 +11,28 @@ import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
+import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import MediaPlayer from '../MediaPlayer'
+/** Tags already shown on the music card — omit from caption markdown so they are not rendered twice. */
+const MUSIC_TRACK_CAPTION_OMIT_TAGS = new Set([
+ 'd',
+ 'title',
+ 'artist',
+ 'url',
+ 'image',
+ 'video',
+ 'album',
+ 'duration',
+ 'format',
+ 'language',
+ 'track_number',
+ 'released',
+ 'explicit',
+ 'alt',
+ 'genre'
+])
+
export default function MusicTrackNote({
event,
className,
@@ -37,6 +57,11 @@ export default function MusicTrackNote({
() => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined),
[track]
)
+ const captionEvent = useMemo(() => {
+ if (!caption) return null
+ const tags = event.tags.filter(([name]) => !MUSIC_TRACK_CAPTION_OMIT_TAGS.has(name))
+ return { ...event, content: caption, tags } as Event
+ }, [event, caption])
if (!track) {
return (
@@ -86,8 +111,16 @@ export default function MusicTrackNote({
) : null}
- {caption ? (
-
{caption}
+ {captionEvent ? (
+
+
+
) : null}
)
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx
index 16127bab..4c16c33f 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/PostContent.tsx
@@ -35,6 +35,7 @@ import {
createCitationExternalDraftEvent,
createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent,
+ createMusicTrackDraftEvent,
collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto,
stripImwaldAttributionTags
@@ -330,6 +331,17 @@ export default function PostContent({
const [isWikiArticle, setIsWikiArticle] = useState(false)
const [isNostrSpecification, setIsNostrSpecification] = useState(false)
const [isPublicationContent, setIsPublicationContent] = useState(false)
+ const [isMusicTrack, setIsMusicTrack] = useState(false)
+ const [musicTrackDTag, setMusicTrackDTag] = useState('')
+ const [musicTrackTitle, setMusicTrackTitle] = useState('')
+ const [musicTrackArtist, setMusicTrackArtist] = useState('')
+ const [musicTrackAudioUrl, setMusicTrackAudioUrl] = useState('')
+ const [musicTrackImageUrl, setMusicTrackImageUrl] = useState('')
+ const [musicTrackAlbum, setMusicTrackAlbum] = useState('')
+ const [musicTrackDuration, setMusicTrackDuration] = useState('')
+ const [musicTrackFormat, setMusicTrackFormat] = useState('')
+ const [musicTrackLanguage, setMusicTrackLanguage] = useState('')
+ const [musicTrackGenres, setMusicTrackGenres] = useState('')
const [nostrSpecAffectedKindRows, setNostrSpecAffectedKindRows] = useState(
() => [newNostrSpecAffectedKindRow()]
)
@@ -387,6 +399,7 @@ export default function PostContent({
const [mediaNoteUploadPending, setMediaNoteUploadPending] = useState(false)
/** Stable auto d-tag when the field is left empty; `{ slug, value }` resets when article subtype changes. */
const articleDTagFallbackRef = useRef<{ slug: string; value: string } | null>(null)
+ const musicTrackDTagFallbackRef = useRef(null)
useEffect(() => {
if (articleDTag.trim()) {
@@ -590,6 +603,8 @@ export default function PostContent({
return ExtendedKind.NOSTR_SPECIFICATION
} else if (isPublicationContent) {
return ExtendedKind.PUBLICATION_CONTENT
+ } else if (isMusicTrack) {
+ return ExtendedKind.MUSIC_TRACK
} else if (isCitationInternal) {
return ExtendedKind.CITATION_INTERNAL
} else if (isCitationExternal) {
@@ -615,6 +630,7 @@ export default function PostContent({
isWikiArticle,
isNostrSpecification,
isPublicationContent,
+ isMusicTrack,
isCitationInternal,
isCitationExternal,
isCitationHardcopy,
@@ -654,6 +670,7 @@ export default function PostContent({
(!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) &&
(!isCitationHardcopy || !!citationAccessedOn.trim()) &&
(!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) &&
+ (!isMusicTrack || (!!musicTrackTitle.trim() && !!musicTrackAudioUrl.trim())) &&
relayCapBlockInfo === null
)
}, [
@@ -686,7 +703,10 @@ export default function PostContent({
threadIsReadingGroup,
threadReadingAuthor,
threadReadingSubject,
- relayCapBlockInfo
+ relayCapBlockInfo,
+ isMusicTrack,
+ musicTrackTitle,
+ musicTrackAudioUrl
])
const getDeterminedKindRef = useRef(getDeterminedKind)
@@ -805,6 +825,38 @@ export default function PostContent({
nostrSpecAffectedKindRows
])
+ const musicTrackPreviewMetadata = useMemo(() => {
+ if (!isMusicTrack) return undefined
+ const genres = musicTrackGenres.trim()
+ ? musicTrackGenres.split(/[,\s]+/).filter((s) => s.trim())
+ : []
+ const durationRaw = Number.parseInt(musicTrackDuration.trim(), 10)
+ return {
+ dTag: musicTrackDTag.trim() || undefined,
+ title: musicTrackTitle.trim() || undefined,
+ audioUrl: musicTrackAudioUrl.trim() || undefined,
+ artist: musicTrackArtist.trim() || undefined,
+ imageUrl: musicTrackImageUrl.trim() || undefined,
+ album: musicTrackAlbum.trim() || undefined,
+ durationSec: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : undefined,
+ format: musicTrackFormat.trim() || undefined,
+ language: musicTrackLanguage.trim() || undefined,
+ genres: genres.length > 0 ? genres : undefined
+ }
+ }, [
+ isMusicTrack,
+ musicTrackDTag,
+ musicTrackTitle,
+ musicTrackAudioUrl,
+ musicTrackArtist,
+ musicTrackImageUrl,
+ musicTrackAlbum,
+ musicTrackDuration,
+ musicTrackFormat,
+ musicTrackLanguage,
+ musicTrackGenres
+ ])
+
const mergedExtraPreviewTags = useMemo((): string[][] | undefined => {
const contextual =
isDiscussionThread && !parentEvent
@@ -944,6 +996,37 @@ export default function PostContent({
? articleSubject.split(/[,\s]+/).filter(s => s.trim())
: []
+ if (isMusicTrack) {
+ const trimmedDTag = musicTrackDTag.trim()
+ let effectiveDTag = trimmedDTag
+ if (!effectiveDTag) {
+ if (!musicTrackDTagFallbackRef.current) {
+ musicTrackDTagFallbackRef.current = `music-track-${Math.floor(Date.now() / 1000)}`
+ }
+ effectiveDTag = musicTrackDTagFallbackRef.current
+ } else {
+ musicTrackDTagFallbackRef.current = null
+ }
+ const genres = musicTrackGenres.trim()
+ ? musicTrackGenres.split(/[,\s]+/).filter((s) => s.trim())
+ : []
+ const durationRaw = Number.parseInt(musicTrackDuration.trim(), 10)
+ return await createMusicTrackDraftEvent(cleanedText, mentions, {
+ dTag: effectiveDTag,
+ title: musicTrackTitle.trim(),
+ audioUrl: musicTrackAudioUrl.trim(),
+ artist: musicTrackArtist.trim() || undefined,
+ imageUrl: musicTrackImageUrl.trim() || undefined,
+ album: musicTrackAlbum.trim() || undefined,
+ durationSec: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : undefined,
+ format: musicTrackFormat.trim() || undefined,
+ language: musicTrackLanguage.trim() || undefined,
+ genres,
+ addClientTag,
+ isNsfw
+ })
+ }
+
// Articles
const isArticleDraft =
isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent
@@ -1160,6 +1243,17 @@ export default function PostContent({
isWikiArticle,
isNostrSpecification,
isPublicationContent,
+ isMusicTrack,
+ musicTrackDTag,
+ musicTrackTitle,
+ musicTrackArtist,
+ musicTrackAudioUrl,
+ musicTrackImageUrl,
+ musicTrackAlbum,
+ musicTrackDuration,
+ musicTrackFormat,
+ musicTrackLanguage,
+ musicTrackGenres,
isCitationInternal,
isCitationExternal,
isCitationHardcopy,
@@ -1502,6 +1596,7 @@ export default function PostContent({
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
+ setIsMusicTrack(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
@@ -1526,6 +1621,7 @@ export default function PostContent({
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
+ setIsMusicTrack(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
@@ -1553,6 +1649,7 @@ export default function PostContent({
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
+ setIsMusicTrack(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
@@ -1616,7 +1713,7 @@ export default function PostContent({
}
const canUseMediaKindFromUrlButton = useMemo(() => {
- if (parentEvent || isDiscussionThread || isPublicMessage) return false
+ if (parentEvent || isDiscussionThread || isPublicMessage || isMusicTrack) return false
if (mediaNoteKind !== null && mediaUrl) return false
if (mediaImetaTags.length > 0) return true
if (mediaUrl) return true
@@ -1627,6 +1724,7 @@ export default function PostContent({
parentEvent,
isDiscussionThread,
isPublicMessage,
+ isMusicTrack,
mediaNoteKind,
mediaUrl,
mediaImetaTags,
@@ -1635,7 +1733,7 @@ export default function PostContent({
/** When the editor already contains a media URL (e.g. after drop/paste) but kind stayed 1. */
const handleUseMediaNoteKindFromUrl = () => {
- if (parentEvent || isDiscussionThread || isPublicMessage) return
+ if (parentEvent || isDiscussionThread || isPublicMessage || isMusicTrack) return
if (mediaNoteKind !== null && mediaUrl) {
toast.info(t('Already publishing as a media note'))
return
@@ -1658,6 +1756,7 @@ export default function PostContent({
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
+ setIsMusicTrack(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
@@ -1691,6 +1790,7 @@ export default function PostContent({
!isWikiArticle &&
!isNostrSpecification &&
!isPublicationContent &&
+ !isMusicTrack &&
!isCitationInternal &&
!isCitationExternal &&
!isCitationHardcopy &&
@@ -1706,6 +1806,7 @@ export default function PostContent({
isWikiArticle,
isNostrSpecification,
isPublicationContent,
+ isMusicTrack,
isCitationInternal,
isCitationExternal,
isCitationHardcopy,
@@ -1715,6 +1816,31 @@ export default function PostContent({
]
)
+ const handleMusicTrackToggle = () => {
+ if (parentEvent) return
+ setIsMusicTrack((prev) => !prev)
+ if (!isMusicTrack) {
+ setIsPoll(false)
+ setIsPublicMessage(false)
+ setIsHighlight(false)
+ setIsLongFormArticle(false)
+ setIsWikiArticle(false)
+ setIsNostrSpecification(false)
+ setIsPublicationContent(false)
+ setIsMusicTrack(false)
+ setIsCitationInternal(false)
+ setIsCitationExternal(false)
+ setIsCitationHardcopy(false)
+ setIsCitationPrompt(false)
+ setIsDiscussionThread(false)
+ setMediaNoteKind(null)
+ setMediaUrl('')
+ setMediaImetaTags([])
+ composerImetaTagsRef.current = []
+ musicTrackDTagFallbackRef.current = null
+ }
+ }
+
const handleHighlightToggle = () => {
if (parentEvent) return
@@ -1727,6 +1853,7 @@ export default function PostContent({
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
+ setIsMusicTrack(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
@@ -1746,6 +1873,11 @@ export default function PostContent({
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
+ setIsLongFormArticle(false)
+ setIsWikiArticle(false)
+ setIsNostrSpecification(false)
+ setIsPublicationContent(false)
+ setIsMusicTrack(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
@@ -2148,6 +2280,30 @@ export default function PostContent({
}
return // Don't set media note kind for non-audio in replies/PMs
}
+ } else if (isMusicTrack) {
+ const fileType = uploadingFile.type
+ const fileName = uploadingFile.name.toLowerCase()
+ const isAudioMime =
+ fileType.startsWith('audio/') ||
+ fileType === 'audio/mp4' ||
+ fileType === 'audio/x-m4a' ||
+ fileType === 'audio/m4a'
+ const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg)$/i.test(fileName)
+ const isAudio = isAudioMime || isAudioExt || /\.m4a$/i.test(fileName)
+ const isImage =
+ fileType.startsWith('image/') || imageUrlLooksLikeHttpImage(url)
+
+ if (isAudio) {
+ setMusicTrackAudioUrl(url)
+ const ext = fileName.split('.').pop()
+ if (ext && !musicTrackFormat.trim()) {
+ setMusicTrackFormat(ext)
+ }
+ } else if (isImage) {
+ setMusicTrackImageUrl(url)
+ } else if (!urlAlreadyInEditor) {
+ appendUploadedUrlToComposer(url, imageUrlLooksLikeHttpImage(url))
+ }
} else {
// For new posts, check if file is ambiguous (could be audio or video)
if (isAmbiguousMediaFile(uploadingFile)) {
@@ -2172,7 +2328,7 @@ export default function PostContent({
// Don't throw - just log the error so the upload doesn't fail completely
}
- if (!mediaNoteUploaderIntentRef.current) {
+ if (!mediaNoteUploaderIntentRef.current && !isMusicTrack) {
clearNonMediaNoteComposerModes()
}
@@ -2223,6 +2379,7 @@ export default function PostContent({
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
setIsDiscussionThread(false)
+ setIsMusicTrack(false)
// Clear article metadata when switching off article mode
if (type === null) {
@@ -2263,6 +2420,7 @@ export default function PostContent({
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
+ setIsMusicTrack(false)
setIsDiscussionThread(false)
// Set default accessedOn if not already set
@@ -2294,6 +2452,18 @@ export default function PostContent({
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
+ setIsMusicTrack(false)
+ setMusicTrackDTag('')
+ setMusicTrackTitle('')
+ setMusicTrackArtist('')
+ setMusicTrackAudioUrl('')
+ setMusicTrackImageUrl('')
+ setMusicTrackAlbum('')
+ setMusicTrackDuration('')
+ setMusicTrackFormat('')
+ setMusicTrackLanguage('')
+ setMusicTrackGenres('')
+ musicTrackDTagFallbackRef.current = null
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
@@ -2701,6 +2871,138 @@ export default function PostContent({
)}
+
+ {isMusicTrack && (
+
+
+
+
setMusicTrackDTag(e.target.value)}
+ placeholder={t('e.g., my-song-slug')}
+ />
+
{t('articleDTagDefaultHint')}
+
+
+
+ setMusicTrackTitle(e.target.value)}
+ placeholder={t('Track title')}
+ className={!musicTrackTitle.trim() ? 'border-destructive' : ''}
+ />
+
+
+
+ setMusicTrackArtist(e.target.value)}
+ placeholder={t('Artist name (optional)')}
+ />
+
+
+
+ setMusicTrackAudioUrl(e.target.value)}
+ placeholder={t('https://example.com/track.m4a')}
+ className={!musicTrackAudioUrl.trim() ? 'border-destructive' : ''}
+ />
+
+
+
+ setMusicTrackImageUrl(e.target.value)}
+ placeholder={t('https://example.com/cover.png')}
+ />
+
+
+
+
+
setMusicTrackGenres(e.target.value)}
+ placeholder={t('heimatsound, deutschland')}
+ />
+
+ {t('Comma-separated genre tags (kind 36787 also adds t=music automatically)', {
+ defaultValue: 'Comma-separated genre tags (t=music is added automatically)'
+ })}
+
+
+
+ {t('Use the editor below for lyrics or notes (Markdown).', {
+ defaultValue: 'Use the editor below for lyrics or notes (Markdown).'
+ })}
+
+
+ )}
{/* Citation metadata fields */}
{(isCitationInternal ||
@@ -3077,6 +3379,7 @@ export default function PostContent({
pollCreateData={isPoll ? pollCreateData : undefined}
extraPreviewTags={mergedExtraPreviewTags}
articleMetadata={articlePreviewMetadata}
+ musicTrackMetadata={musicTrackPreviewMetadata}
addClientTag={addClientTag}
mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl}
@@ -3086,6 +3389,7 @@ export default function PostContent({
isWikiArticle ? FileText :
isNostrSpecification ? FileText :
isPublicationContent ? Book :
+ isMusicTrack ? Music :
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote :
isHighlight ? Highlighter :
isPublicMessage ? MessageCircle :
@@ -3098,6 +3402,7 @@ export default function PostContent({
isWikiArticle ? t('Wiki Article (AsciiDoc)') :
isNostrSpecification ? t('Nostr Specification') :
isPublicationContent ? t('Publication Note') :
+ isMusicTrack ? t('Music Track', { defaultValue: 'Music Track' }) :
isCitationInternal ? t('Internal Citation') :
isCitationExternal ? t('External Citation') :
isCitationHardcopy ? t('Hardcopy Citation') :
@@ -3173,6 +3478,18 @@ export default function PostContent({
{isMediaNoteComposerMode && }
+
+
+
+ {t('Music Track', { defaultValue: 'Music Track' })}
+
+ {t('Publish audio with cover art and lyrics (kind 36787)', {
+ defaultValue: 'Publish audio with cover art and lyrics (kind 36787)'
+ })}
+
+
+ {isMusicTrack && }
+
diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx
index d82622ef..a4e74ff6 100644
--- a/src/components/PostEditor/PostTextarea/Preview.tsx
+++ b/src/components/PostEditor/PostTextarea/Preview.tsx
@@ -31,6 +31,7 @@ export default function Preview({
mediaImetaTags,
mediaUrl,
articleMetadata,
+ musicTrackMetadata,
extraPreviewTags,
addClientTag = true
}: {
@@ -50,6 +51,18 @@ export default function Preview({
/** Kind 30817: each number becomes a `k` tag. */
affectedKinds?: number[]
}
+ musicTrackMetadata?: {
+ dTag?: string
+ title?: string
+ audioUrl?: string
+ artist?: string
+ imageUrl?: string
+ album?: string
+ durationSec?: number
+ format?: string
+ language?: string
+ genres?: string[]
+ }
/** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */
extraPreviewTags?: string[][]
/** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */
@@ -168,6 +181,44 @@ export default function Preview({
tags.push(...normalizedTopics.map((topic) => ['t', topic]))
}
}
+ if (musicTrackMetadata && kind === ExtendedKind.MUSIC_TRACK) {
+ if (musicTrackMetadata.dTag) {
+ tags.push(['d', musicTrackMetadata.dTag])
+ }
+ if (musicTrackMetadata.title) {
+ tags.push(['title', musicTrackMetadata.title])
+ }
+ if (musicTrackMetadata.audioUrl) {
+ tags.push(['url', musicTrackMetadata.audioUrl])
+ }
+ tags.push(['t', 'music'])
+ if (musicTrackMetadata.artist) {
+ tags.push(['artist', musicTrackMetadata.artist])
+ }
+ if (musicTrackMetadata.imageUrl) {
+ tags.push(['image', musicTrackMetadata.imageUrl])
+ }
+ if (musicTrackMetadata.album) {
+ tags.push(['album', musicTrackMetadata.album])
+ }
+ if (musicTrackMetadata.durationSec) {
+ tags.push(['duration', String(musicTrackMetadata.durationSec)])
+ }
+ if (musicTrackMetadata.format) {
+ tags.push(['format', musicTrackMetadata.format])
+ }
+ if (musicTrackMetadata.language) {
+ tags.push(['language', musicTrackMetadata.language])
+ }
+ if (musicTrackMetadata.genres?.length) {
+ for (const g of musicTrackMetadata.genres) {
+ const topic = normalizeTopic(g.trim())
+ if (topic && topic !== 'music') {
+ tags.push(['t', topic])
+ }
+ }
+ }
+ }
if (extraPreviewTags?.length) {
tags.push(...extraPreviewTags)
}
@@ -176,7 +227,7 @@ export default function Preview({
stripped.push(buildClientTag())
}
return stripped
- }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind, extraPreviewTags, addClientTag])
+ }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag])
const fakeEvent = useMemo(() => {
// For voice comments, include the media URL in content if not already there
@@ -277,6 +328,14 @@ export default function Preview({
)
}
+ if (kind === ExtendedKind.MUSIC_TRACK) {
+ return withClientBadge(
+
+
+
+ )
+ }
+
return withClientBadge(
diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx
index 62603e4e..56d64089 100644
--- a/src/components/PostEditor/PostTextarea/index.tsx
+++ b/src/components/PostEditor/PostTextarea/index.tsx
@@ -78,6 +78,18 @@ const PostTextarea = forwardRef<
topics?: string[]
affectedKinds?: number[]
}
+ musicTrackMetadata?: {
+ dTag?: string
+ title?: string
+ audioUrl?: string
+ artist?: string
+ imageUrl?: string
+ album?: string
+ durationSec?: number
+ format?: string
+ language?: string
+ genres?: string[]
+ }
extraPreviewTags?: string[][]
addClientTag?: boolean
}
@@ -103,6 +115,7 @@ const PostTextarea = forwardRef<
mediaImetaTags,
mediaUrl,
articleMetadata,
+ musicTrackMetadata,
extraPreviewTags,
addClientTag = true
},
@@ -311,6 +324,7 @@ const PostTextarea = forwardRef<
mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl}
articleMetadata={articleMetadata}
+ musicTrackMetadata={musicTrackMetadata}
extraPreviewTags={extraPreviewTags}
addClientTag={addClientTag}
/>
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index f33b7d6e..6296a4cd 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -2020,6 +2020,75 @@ export async function createVideoDraftEvent(
})
}
+export async function createMusicTrackDraftEvent(
+ content: string,
+ mentions: string[],
+ options: {
+ dTag: string
+ title: string
+ audioUrl: string
+ artist?: string
+ imageUrl?: string
+ album?: string
+ durationSec?: number
+ format?: string
+ language?: string
+ genres?: string[]
+ addClientTag?: boolean
+ isNsfw?: boolean
+ }
+): Promise {
+ const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
+ const hashtags = extractHashtags(transformedEmojisContent)
+
+ const tags: string[][] = []
+ tags.push(buildDTag(normalizeDTag(options.dTag)))
+ tags.push(buildTitleTag(options.title))
+ tags.push(['url', options.audioUrl.trim()])
+ tags.push(['t', 'music'])
+ if (options.artist?.trim()) {
+ tags.push(['artist', options.artist.trim()])
+ }
+ if (options.imageUrl?.trim()) {
+ tags.push(['image', options.imageUrl.trim()])
+ }
+ if (options.album?.trim()) {
+ tags.push(['album', options.album.trim()])
+ }
+ if (options.durationSec != null && options.durationSec > 0) {
+ tags.push(['duration', String(Math.floor(options.durationSec))])
+ }
+ if (options.format?.trim()) {
+ tags.push(['format', options.format.trim()])
+ }
+ if (options.language?.trim()) {
+ tags.push(['language', options.language.trim()])
+ }
+ const genreTags = new Set()
+ for (const g of options.genres ?? []) {
+ const t = normalizeTopic(g.trim())
+ if (t && t !== 'music') genreTags.add(t)
+ }
+ for (const h of hashtags) {
+ if (h !== 'music') genreTags.add(h)
+ }
+ for (const g of genreTags) {
+ tags.push(['t', g])
+ }
+ tags.push(...emojiTags)
+ tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
+
+ if (options.isNsfw) {
+ tags.push(buildNsfwTag())
+ }
+
+ return setDraftEventCache({
+ kind: ExtendedKind.MUSIC_TRACK,
+ content: transformedEmojisContent,
+ tags
+ })
+}
+
// Article draft event functions
export async function createLongFormArticleDraftEvent(