diff --git a/plugins/SongDownloader/package.json b/plugins/SongDownloader/package.json index 290c9f54..bed9c77d 100644 --- a/plugins/SongDownloader/package.json +++ b/plugins/SongDownloader/package.json @@ -9,6 +9,7 @@ }, "main": "./src/index.ts", "dependencies": { + "node-taglib-sharp": "^6.0.3", "sanitize-filename": "^1.6.3" } } \ No newline at end of file diff --git a/plugins/SongDownloader/src/index.ts b/plugins/SongDownloader/src/index.ts index 05a1645e..ea55c872 100644 --- a/plugins/SongDownloader/src/index.ts +++ b/plugins/SongDownloader/src/index.ts @@ -3,6 +3,7 @@ import { ContextMenu, safeInterval, StyleTag } from "@luna/lib"; import { getDownloadFolder, getDownloadPath, getFileName } from "./helpers"; import { settings } from "./Settings"; +import { tagM4a } from "./tagM4a.native"; import styles from "file://downloadButton.css?minify"; @@ -31,7 +32,7 @@ ContextMenu.onMediaItem(unloads, async ({ mediaCollection, contextMenu }) => { } downloadButton.text = `Loading tags...`; - const { tags } = await mediaItem.flacTags(); + const { tags, coverUrl } = await mediaItem.flacTags(); downloadButton.text = `Fetching filename...`; const fileName = await getFileName(mediaItem, settings.downloadQuality); @@ -56,7 +57,16 @@ ContextMenu.onMediaItem(unloads, async ({ mediaCollection, contextMenu }) => { }, 50, ); - await mediaItem.download(path, settings.downloadQuality).catch(trace.msg.err.withContext(`Failed to download ${tags.title}`)); + await mediaItem + .download(path, settings.downloadQuality) + .then(async () => { + // FLAC is tagged in-flight by the FlacStreamTagger, DASH (m4a) streams are written untagged + if ((await mediaItem.fileExtension(settings.downloadQuality)) === "m4a") { + downloadButton.text = `Writing tags...`; + await tagM4a(path, tags, coverUrl); + } + }) + .catch(trace.msg.err.withContext(`Failed to download ${tags.title}`)); clearInterval(); } downloadButton.text = defaultText; diff --git a/plugins/SongDownloader/src/tagM4a.native.ts b/plugins/SongDownloader/src/tagM4a.native.ts new file mode 100644 index 00000000..63484393 --- /dev/null +++ b/plugins/SongDownloader/src/tagM4a.native.ts @@ -0,0 +1,122 @@ +import { ByteVector, File as TagFile, Picture, PictureType } from "node-taglib-sharp"; +import sanitize from "sanitize-filename"; + +import { join, parse } from "path"; + +// FlacTags from @luna/lib isn't exported, use a structural type instead +type TagMap = Record; + +const first = (value: string | string[] | undefined | null): string | undefined => { + if (value === undefined || value === null) return undefined; + return Array.isArray(value) ? value[0] : value; +}; +const asArray = (value: string | string[] | undefined | null): string[] => { + if (value === undefined || value === null) return []; + return (Array.isArray(value) ? value : [value]).filter((entry) => entry !== undefined && entry !== null && entry !== ""); +}; +const asInt = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +}; +const yearFrom = (tags: TagMap): number | undefined => { + const year = asInt(first(tags.year)); + if (year && year >= 1 && year <= 9999) return year; + const date = first(tags.date); + if (date && date.length >= 4) { + const dateYear = asInt(date.slice(0, 4)); + if (dateYear && dateYear >= 1 && dateYear <= 9999) return dateYear; + } + return undefined; +}; + +/** + * Writes metadata to a downloaded m4a file. + * FLAC downloads are tagged in-flight by the FlacStreamTagger, but DASH (m4a) streams are written to disk untagged. + * Best-effort: a field that fails to write is skipped instead of aborting the rest. + */ +export const tagM4a = async (path: string | string[], tags: TagMap, coverUrl?: string): Promise => { + // Resolve to the same path download() wrote to (it joins & sanitizes the basename) + if (Array.isArray(path)) path = join(...path); + const parsedPath = parse(path); + path = join(parsedPath.dir, sanitize(parsedPath.base)); + + // Fetch the cover before opening the file so a network failure can't corrupt it + let cover: Picture | undefined; + if (coverUrl) { + try { + const res = await fetch(coverUrl); + if (res.ok) { + cover = Picture.fromData(ByteVector.fromByteArray(Buffer.from(await res.arrayBuffer()))); + cover.type = PictureType.FrontCover; + cover.mimeType = res.headers.get("content-type") ?? "image/jpeg"; + } + } catch {} + } + + let file: TagFile | undefined; + try { + file = TagFile.createFromPath(path); + const tag = file.tag; + + const trySet = (set: () => void) => { + try { + set(); + } catch {} + }; + + const title = first(tags.title); + if (title) trySet(() => (tag.title = title)); + + const performers = asArray(tags.artist); + if (performers.length) trySet(() => (tag.performers = performers)); + + const albumArtists = asArray(tags.albumArtist); + if (albumArtists.length) trySet(() => (tag.albumArtists = albumArtists)); + + const album = first(tags.album); + if (album) trySet(() => (tag.album = album)); + + const year = yearFrom(tags); + if (year) trySet(() => (tag.year = year)); + + const copyright = first(tags.copyright); + if (copyright) trySet(() => (tag.copyright = copyright)); + + const comment = first(tags.comment); + if (comment) trySet(() => (tag.comment = comment)); + + const genres = asArray(tags.genres); + if (genres.length) trySet(() => (tag.genres = genres)); + + const trackNumber = asInt(first(tags.trackNumber)); + if (trackNumber) trySet(() => (tag.track = trackNumber)); + + const totalTracks = asInt(first(tags.totalTracks)); + if (totalTracks) trySet(() => (tag.trackCount = totalTracks)); + + const discNumber = asInt(first(tags.discNumber)); + if (discNumber) trySet(() => (tag.disc = discNumber)); + + const bpm = asInt(first(tags.bpm)); + if (bpm) trySet(() => (tag.beatsPerMinute = bpm)); + + const lyrics = first(tags.lyrics); + if (lyrics) trySet(() => (tag.lyrics = lyrics)); + + const isrc = first(tags.isrc); + if (isrc) trySet(() => (tag.isrc = isrc)); + + const musicBrainzTrackId = first(tags.musicbrainz_trackid); + if (musicBrainzTrackId) trySet(() => (tag.musicBrainzTrackId = musicBrainzTrackId)); + + const musicBrainzAlbumId = first(tags.musicbrainz_albumid); + if (musicBrainzAlbumId) trySet(() => (tag.musicBrainzReleaseId = musicBrainzAlbumId)); + + if (cover) trySet(() => (tag.pictures = [cover!])); + + file.save(); + } finally { + file?.dispose(); + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dfb419..aab44ff8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: plugins/SongDownloader: dependencies: + node-taglib-sharp: + specifier: ^6.0.3 + version: 6.0.3 sanitize-filename: specifier: ^1.6.3 version: 1.6.3 @@ -805,6 +808,10 @@ packages: image-q@4.0.0: resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + invert-kv@3.0.1: + resolution: {integrity: sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==} + engines: {node: '>=8'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -827,6 +834,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lcid@3.1.1: + resolution: {integrity: sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg==} + engines: {node: '>=8'} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -892,6 +903,10 @@ packages: encoding: optional: true + node-taglib-sharp@6.0.3: + resolution: {integrity: sha512-dT5G3wbPtwCnG1j3Rrts7LlyX5r5Xo0A2G/omt7akrcbSm6iPGrNgjdwG/KlBKbiJjUj4umr7qak9jDZOBvFJg==} + engines: {node: '>=12.16.1'} + node-vibrant@4.0.3: resolution: {integrity: sha512-kzoIuJK90BH/k65Avt077JCX4Nhqz1LNc8cIOm2rnYEvFdJIYd8b3SQwU1MTpzcHtr8z8jxkl1qdaCfbP3olFg==} @@ -920,6 +935,10 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + os-locale@6.0.2: + resolution: {integrity: sha512-qIb8bzRqaN/vVqEYZ7lTAg6PonskO7xOmM7OClD28F6eFa4s5XGe4bGpHUHMoCHbNNuR0pDYFeSLiW5bnjWXIA==} + engines: {node: '>=12.20'} + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -1189,6 +1208,11 @@ packages: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -1993,6 +2017,8 @@ snapshots: dependencies: '@types/node': 16.9.1 + invert-kv@3.0.1: {} + is-fullwidth-code-point@3.0.0: {} isomorphic-fetch@3.0.0: @@ -2017,6 +2043,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + lcid@3.1.1: + dependencies: + invert-kv: 3.0.1 + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -2069,6 +2099,12 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-taglib-sharp@6.0.3: + dependencies: + iconv-lite: 0.6.3 + os-locale: 6.0.2 + uuid: 8.3.2 + node-vibrant@4.0.3: dependencies: '@types/node': 18.19.130 @@ -2097,6 +2133,10 @@ snapshots: opener@1.5.2: {} + os-locale@6.0.2: + dependencies: + lcid: 3.1.1 + p-cancelable@2.1.1: {} package-json-from-dist@1.0.1: {} @@ -2359,6 +2399,8 @@ snapshots: uuid@13.0.0: {} + uuid@8.3.2: {} + webidl-conversions@3.0.1: {} whatwg-encoding@2.0.0: