From 5b727a103ecc810e09921c6f2b12dea4044b14a3 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:23:49 -0500 Subject: [PATCH] feat(downloads): allow writing multiple artists to metadata This will write each artist separately to the metadata rather than as a single concatenated string. This allows for better library searching if the player supports it. If multiple artists are written to an m4a file, iTunes will only show the first artist. --- index.html | 14 ++++++++++++++ js/ModernSettings.ts | 4 ++++ js/metadata.js | 18 ++++++++++++++---- js/settings.js | 9 +++++++++ js/taglib.types.ts | 3 ++- js/taglib.worker.ts | 14 +++++++++++--- js/utils.js | 14 ++++++++++++-- 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index f08edb7..776d6f4 100644 --- a/index.html +++ b/index.html @@ -4885,6 +4885,20 @@ +
+
+
+ Write Artists Separately + Write artists separately to metadata. Requires player support. +
+ +
+
diff --git a/js/ModernSettings.ts b/js/ModernSettings.ts index de90cdc..c114181 100644 --- a/js/ModernSettings.ts +++ b/js/ModernSettings.ts @@ -261,6 +261,7 @@ export const modernSettings = new ModernSettings() transformer: String, }, }) + .addProperty('writeArtistsSeparately', false) .finalize() as ModernSettings & { /** The last used directory handle for bulk downloads */ bulkDownloadFolder: FileSystemDirectoryHandle | null; @@ -286,4 +287,7 @@ export const modernSettings = new ModernSettings() /** Filename template for downloads */ filenameTemplate: string; + + /** Whether to write multiple artists to downloaded files */ + writeArtistsSeparately: boolean; }; diff --git a/js/metadata.js b/js/metadata.js index 57fe1c8..b826ffd 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -1,7 +1,15 @@ -import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js'; +import { + getCoverBlob, + getTrackTitle, + getFullArtistString, + getMimeType, + getTrackCoverId, + getFullArtistArray, +} from './utils.js'; import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts'; import { LyricsManager } from './lyrics.js'; import { Mp4Stik } from './taglib.types.ts'; +import { modernSettings } from './ModernSettings.js'; /** * @typedef {import('./container-classes.ts').Track} Track @@ -35,13 +43,15 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet /** * @type {TagLibMetadata} */ - const data = {}; + const data = { + writeArtistsSeparately: modernSettings.writeArtistsSeparately, + }; try { data.title = getTrackTitle(track); - data.artist = getFullArtistString(track); + data.artist = getFullArtistArray(track); data.albumTitle = track.album?.title; - data.albumArtist = track.album?.artist?.name || track.artist?.name; + data.albumArtist = track.album?.artist?.name || getFullArtistString(track) || ''; data.trackNumber = track.trackNumber; data.discNumber = track.volumeNumber ?? track.discNumber; data.totalTracks = track.album?.numberOfTracksOnDisc ?? track.album?.numberOfTracks; diff --git a/js/settings.js b/js/settings.js index faa1df0..19951e6 100644 --- a/js/settings.js +++ b/js/settings.js @@ -4568,6 +4568,15 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); } + // Write multiple artists toggle + const writeArtistsSeparatelyToggle = document.getElementById('write-artists-separately-toggle'); + if (writeArtistsSeparatelyToggle) { + writeArtistsSeparatelyToggle.checked = modernSettings.writeArtistsSeparately; + writeArtistsSeparatelyToggle.addEventListener('change', (e) => { + modernSettings.writeArtistsSeparately = e.target.checked; + }); + } + // Download Lyrics Toggle const downloadLyricsToggle = document.getElementById('download-lyrics-toggle'); if (downloadLyricsToggle) { diff --git a/js/taglib.types.ts b/js/taglib.types.ts index 187d29b..45b3b72 100644 --- a/js/taglib.types.ts +++ b/js/taglib.types.ts @@ -16,7 +16,8 @@ export interface TagLibWorkerResponse { export interface TagLibMetadata { title?: string; - artist?: string; + artist?: string | string[]; + writeArtistsSeparately?: boolean; albumTitle?: string; albumArtist?: string; trackNumber?: number; diff --git a/js/taglib.worker.ts b/js/taglib.worker.ts index fb91b10..c06c495 100644 --- a/js/taglib.worker.ts +++ b/js/taglib.worker.ts @@ -29,8 +29,8 @@ import { FileSystemFileHandleStream } from '!/@dantheman827/taglib-ts/src/toolki import { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js'; import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js'; import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js'; -import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js'; import { OggVorbisFile } from '!/@dantheman827/taglib-ts/src/ogg/vorbis/vorbisFile.js'; +import { WavFile } from '!/@dantheman827/taglib-ts/src/riff/wav/wavFile'; export const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; @@ -41,6 +41,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise< filename, title, artist, + writeArtistsSeparately = false, albumTitle, albumArtist, trackNumber, @@ -74,17 +75,24 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise< } const underlying = ref.file(); + const isFlac = underlying instanceof FlacFile; const isMp4 = underlying instanceof Mp4File; const isMpeg = underlying instanceof MpegFile; + const isOgg = underlying instanceof OggVorbisFile; + const isWav = underlying instanceof WavFile; + const needsCombinedTrackDisc = isMp4 || isMpeg; + const artistArray = Array.isArray(artist) ? artist : artist ? [artist] : []; + const supportsMultiValuedArtist = writeArtistsSeparately && (isFlac || isOgg || isMp4); + doTimed('Tagging file', () => { const props = ref.properties(); if (title) props.replace('TITLE', [title]); - if (artist) props.replace('ARTIST', [artist]); + if (artistArray.length) props.replace('ARTIST', supportsMultiValuedArtist ? artistArray : [artistArray.join('; ')]); if (albumTitle) props.replace('ALBUM', [albumTitle]); - if (albumArtist || artist) props.replace('ALBUMARTIST', [albumArtist || artist!]); + if (albumArtist || artistArray.length) props.replace('ALBUMARTIST', albumArtist ? [albumArtist] : [artistArray.join('; ')]); if (trackNumber) { const trackStr = diff --git a/js/utils.js b/js/utils.js index 6c78b17..bd4be2e 100644 --- a/js/utils.js +++ b/js/utils.js @@ -616,10 +616,10 @@ export const getShareUrl = (path) => { }; /** - * Builds a full artist string by combining the track's listed artists + * Builds a full artist array by combining the track's listed artists * with any featured artists parsed from the title (feat./with). */ -export function getFullArtistString(track) { +export function getFullArtistArray(track) { const knownArtists = Array.isArray(track.artists) && track.artists.length > 0 ? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean) @@ -646,6 +646,16 @@ export function getFullArtistString(track) { } } + return knownArtists; +} + +/** + * Builds a full artist string by combining the track's listed artists + * with any featured artists parsed from the title (feat./with). + */ +export function getFullArtistString(track) { + const knownArtists = getFullArtistArray(track); + return knownArtists.join('; ') || null; }