From b600fcf0b627f0f911bc7d096a275fc1b943da2d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 3 Jun 2026 21:51:44 +0200 Subject: [PATCH] refine music tracks add a music track type --- package-lock.json | 4 +- package.json | 2 +- src/components/Note/MusicTrackNote.tsx | 37 +- src/components/PostEditor/PostContent.tsx | 325 +++++++++++++++++- .../PostEditor/PostTextarea/Preview.tsx | 61 +++- .../PostEditor/PostTextarea/index.tsx | 14 + src/lib/draft-event.ts | 69 ++++ 7 files changed, 502 insertions(+), 10 deletions(-) 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')} + /> +
+
+
+ + setMusicTrackAlbum(e.target.value)} + placeholder={t('Album (optional)')} + /> +
+
+ + setMusicTrackDuration(e.target.value)} + placeholder="245" + /> +
+
+ + setMusicTrackFormat(e.target.value)} + placeholder="mp3" + /> +
+
+ + setMusicTrackLanguage(e.target.value)} + placeholder="de" + /> +
+
+
+ + 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(