feat(0.9): romaji sort-tags only, robust dupe detection, Mittelweg no-duration, autofill all fields

- Japanese tracks stored with original names in folder/filename/tags; romaji only in sort tags
- findExistingTrack: case-insensitive, searches all album folders under artist, strips release suffix
- --max-filesize 30M + 2x duration cap on all yt-dlp calls
- iTunes collectionArtistName captured as album_artist; fixes compilation album splitting
- album_artist tag uses mainArtist (not rawArtist) so all tracks in one album are consistent
- No-duration cascade: steps 5-8 (title-only/no-filter) removed — fail cleanly like with-duration
- Autofill iTunes: fills title, artist, albumArtist, discNum (was album/genre/year/track/total only)
- Autofill MusicBrainz: fills albumArtist, discNum (was missing both)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-21 13:26:11 +02:00
1 parent 8be3a515e6
commit fa4c954ab9
6 files changed
+148 -98

No files matched your search

+28 -32
View File
@@ -50,6 +50,31 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
--- ---
## [0.9] — 2026-06-21
### Changed
- **Backend** — Japanese tracks now stored with original Japanese names in folders, filenames, and all main tags (title/artist/album/album\_artist); Romaji goes only into sort tags (TITLESORT / ARTISTSORT / ALBUMARTISTSORT / ALBUMSORT) for sorting and search in Navidrome. Previously, romaji was applied to filenames and tags too, causing name drift, duplicate misses, and wrong iTunes resolutions (e.g. "Hisaishi Yuzuru" instead of "Joe Hisaishi")
- **Backend** — Metadata editor: files and album/artist folders are renamed using the original name typed in the form (may be Japanese), not romaji; sort tags still receive the romaji conversion
- **Backend** — `album_artist` tag in downloaded tracks now consistently uses `mainArtist` (the iTunes-resolved album artist) rather than the raw per-track artist. This prevents Navidrome from splitting one album into multiple when track artists vary (e.g. compilations)
- **Backend (download)** — No-duration cascade now ends after step 4 (same Mittelweg philosophy as the with-duration branch): no title-only or filter-free fallback grabs that pull wrong covers (observed: Yorushika 車窓 → Piano Echoes Shasou)
- **Backend (download)** — `--max-filesize 30M` added to all yt-dlp calls; Mittelweg/no-duration steps also cap at 2× expected duration — a 28-min video can no longer be saved as a ~52 MB mp3
### Fixed
- **Backend (download)** — Duplicate detection now matches case-insensitively (DJ OKAWARI vs DJ Okawari) and across all album folders under the artist folder, comparing normalized title without track prefix or release/variant suffix — prevents re-downloading songs whose filename drifted between runs
- **Backend (download)** — iTunes `collectionArtistName` (album artist) is now captured and passed through the download pipeline as the `album_artist` tag; fixes compilation albums showing "Various Artists" for some tracks and the individual track artist for others
- **Metadata editor (autofill)** — iTunes button now fills all available fields: title (`trackName`), artist (`artistName`), album-artist (`collectionArtistName`), album, genre, year, track #, disc #, total tracks, cover
- **Metadata editor (autofill)** — MusicBrainz button now fills album-artist (from release artist-credit) and disc # in addition to the existing fields
### Investigated (Point 4 — album splitting)
Primary causes identified:
1. Missing `album_artist` tag / inconsistent value across tracks — fixed above (mainArtist + collectionArtistName)
2. `tagAlbumArtist` was using `rawArtist` (per-track Spotify name) instead of `mainArtist` — fixed
3. Album name comes from iTunes `collectionName` — consistent within a collection, no fix needed
4. Year from iTunes `releaseDate` — album-level date, consistent within a collection
5. MBIDs not set — still missing; not the primary splitting cause
---
## [0.8] — 2026-06-19 ## [0.8] — 2026-06-19
### Removed ### Removed
@@ -224,35 +249,6 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
- **Web + Android** — yt-dlp backend update / maintenance - **Web + Android** — yt-dlp backend update / maintenance
### Known Bugs ### Known Bugs
- **Backend (download)** — Duplicate detection is filename-only and exact: a track re-downloads - **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded.
when the cleaned filename changes (e.g. `… - recognize - Single.mp3` vs `… - recognize.mp3`), - **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
leaving two copies. Should match on normalized artist+title (ignore release suffix / variant / - **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`).
track prefix), not the raw filename.
- **Backend (download)** — No size/length safety cap: a wrong long video can be saved as a huge
file (observed a 52 MB mp3 ≈ 28 min vs the correct 4 MB ~3 min). Needs a `--max-filesize` /
duration cap as defense-in-depth.
- **Backend (metadata/sorting)** — iTunes `collectionArtistName` (album artist) is not captured,
so `album_artist` can be wrong on compilations / various-artists releases, splitting albums and
mis-sorting them in Navidrome.
- **Metadata editor (autofill)** — Both autofill buttons fill only a partial subset of the form:
iTunes sets album/genre/year/track/total/cover (no title, artist, album-artist, disc);
MusicBrainz sets title/artist/album/genre/year/track/total (no album-artist, disc). They should
fill **every** field they can resolve (title, artist, album-artist, album, genre, year, track #,
disc #, total tracks, cover). This needs both the backend endpoints to return those fields
(e.g. iTunes `collectionArtistName`, `discNumber`, `trackName`) and the frontend to populate the
inputs. Artists are stored `;`-separated (feat./ft./&/comma normalized to `;` at download, so the
field never contains feat./ft.) and both lookups already search with the first artist — but
multi-artist tracks still mostly fail because the match is strict and gives up when iTunes/MB
credit a different/combined primary artist; needs a fallback (title-only, or try the other listed
artists).
- **Backend (download)** — Re-downloading a playlist re-fetches some already-present tracks: the
duplicate check matches the exact computed path only (same album folder, exact track-number
prefix, exact resolved artist/title). Any drift between runs — different iTunes/romaji name, a
different track number, a different album folder, or a `- Piano Version`/`(var)` variant suffix
— makes the file look new. The check is also case-sensitive (filename-existence on a
case-sensitive Linux FS), so `DJ OKAWARI` vs `DJ Okawari` misses too. Should match by
normalized, case-insensitive first-artist + title across the artist's folders, not the exact path.
- **Backend (download)** — The no-duration branch (tracks where iTunes returns no duration) has
**no Mittelweg protection**: it still falls through to title-only / no-filter grabs and pulls
wrong covers by other uploaders (observed: `Yorushika – 車窓` saved as `Piano Echoes – Shasou`).
The channel-required relaxation should apply to the no-duration branch too.
+14 -5
View File
@@ -43,16 +43,22 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed). - Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the - **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. The cleaned title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
title (`cleanTitle`/`searchTitle`) drives both the filename and the embedded title tag. are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
for Japanese tracks. Romaji goes **only** into sort tags (TITLESORT / ARTISTSORT / etc.).
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter - **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s), param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
title keyword, artist channel, and variant exclusions (remix/cover/sped/…). title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
- **"Mittelweg" rule:** when the duration is known but nothing matches the duration window, - **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
relax only the duration constraint while **still requiring the artist channel**. If nothing relax only the duration constraint while **still requiring the artist channel**. If nothing
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
Direktlink im Importer nutzen". Never fall back to a blind first-result grab (it pulled Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
same-titled videos by other uploaders). pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
all album folders under the matching artist folder; compares normalized artist+title ignoring
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by - **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer** doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
@@ -60,6 +66,9 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
- **File resolution for edit/delete:** Navidrome's stored path may use a different mount root - **File resolution for edit/delete:** Navidrome's stored path may use a different mount root
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan. (tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
## UI / references ## UI / references
+47 -8
View File
@@ -48,13 +48,50 @@ async function downloadArtwork(url, dir) {
const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav']; const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
function findExistingTrack(dir, trackPrefix, safeTitle, safeArtist) { // Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
// Check new format "{prefix}{artist} - {title}.ext" first, then old format "{prefix}{title}.ext" const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const names = safeArtist
? [`${trackPrefix}${safeArtist} - ${safeTitle}`, `${trackPrefix}${safeTitle}`] // Robust duplicate detection: search case-insensitively across all album folders
: [`${trackPrefix}${safeTitle}`]; // under the artist folder matching firstArtist, compare normalized title ignoring
return AUDIO_EXTS.flatMap(ext => names.map(n => path.join(dir, `${n}.${ext}`))) // release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
.find(f => fs.existsSync(f)) || null; // "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
const norm = s => (s || '').toLowerCase()
.replace(/[\[((][^\]))]*[\]))]/g, '')
.replace(/[,.'!?]/g, '')
.replace(/\s+/g, ' ')
.trim();
const normArtist = norm(firstArtist);
const normTitle = norm(cleanTitle);
// Accept both the raw cleanTitle and version without release suffix (e.g. "recognize" matches "recognize - Single")
const titleVariants = new Set([normTitle, norm(cleanTitle.replace(_RELEASE_SUFFIX_RE, ''))]);
try {
for (const ad of fs.readdirSync(musicRoot, { withFileTypes: true })) {
if (!ad.isDirectory() || norm(ad.name) !== normArtist) continue;
const artistPath = path.join(musicRoot, ad.name);
try {
for (const abd of fs.readdirSync(artistPath, { withFileTypes: true })) {
if (!abd.isDirectory()) continue;
const albumPath = path.join(artistPath, abd.name);
try {
for (const f of fs.readdirSync(albumPath)) {
const ext = path.extname(f).slice(1).toLowerCase();
if (!AUDIO_EXTS.includes(ext)) continue;
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, '');
// Stem may be "Artist - Title" or just "Title"
const di = stem.indexOf(' - ');
const rawTitle = di >= 0 ? stem.slice(di + 3) : stem;
const normStemTitle = norm(rawTitle.replace(_RELEASE_SUFFIX_RE, ''));
if (titleVariants.has(normStemTitle)) return path.join(albumPath, f);
}
} catch {}
}
} catch {}
}
} catch {}
return null;
} }
// Fetch plain + synced lyrics from LRCLIB // Fetch plain + synced lyrics from LRCLIB
@@ -140,7 +177,9 @@ async function lookupItunesTrackMeta(artist, title) {
const rawGenre = (usMatch || match).primaryGenreName; const rawGenre = (usMatch || match).primaryGenreName;
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined; const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
return { return {
album: match.collectionName || '', album: match.collectionName || '',
albumArtist: match.collectionArtistName || undefined,
trackName: match.trackName || undefined,
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined, year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
trackNum: match.trackNumber || undefined, trackNum: match.trackNumber || undefined,
totalTracks: match.trackCount || undefined, totalTracks: match.trackCount || undefined,
+27 -36
View File
@@ -84,25 +84,20 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i; const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const cleanTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title; const cleanTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
// Folder / filename: romaji of the resolved artist (iTunes name if available — already in English, // Folder / filename: use original names (Japanese or whatever was passed in).
// otherwise kuromoji). rawArtist (original Japanese) is only used for tags + YTM search. // Romaji goes ONLY into sort tags (TITLESORT, ARTISTSORT, etc.) via embedMetadata.
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist; const safeArtist = sanitize((mainArtist || '').split(/[,;&]/)[0].trim());
const artistRomaji = romajiForStorage(artist) || artist; const safeAlbum = sanitize(album || 'Singles');
const titleRomaji = romajiForStorage(cleanTitle) || cleanTitle; const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
const albumRomaji = album ? (romajiForStorage(album) || album) : null;
const safeArtist = sanitize(mainArtistRomaji.split(/[,;&]/)[0].trim());
const safeAlbum = sanitize(albumRomaji || 'Singles');
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
const safeTitle = sanitize(titleRomaji); const safeTitle = sanitize(cleanTitle);
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
// Filename format: "{01 - }Artist1, Artist2 - Title.ext" // Filename format: "{01 - }Artist1, Artist2 - Title.ext"
// Use track artist (not albumArtist) so all credited artists appear in the filename const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
const safeArtistForFile = sanitize(artistRomaji.replace(/;/g, ',')); const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
const existing = findExistingTrack(dir, trackPrefix, safeTitle, safeArtistForFile); const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
if (existing) { if (existing) {
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`); log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
return { success: true, dir, skipped: true }; return { success: true, dir, skipped: true };
@@ -161,6 +156,8 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
'--no-playlist', '--no-playlist',
'--no-warnings', '--no-warnings',
'--newline', '--newline',
// Hard upper size limit — prevents saving a wrong 28-min video as a ~52 MB mp3
'--max-filesize', '30M',
// Use the web_music player client (falling back to default) so YouTube Music // Use the web_music player client (falling back to default) so YouTube Music
// "Art Track" / topic uploads are reachable. The android_vr client returns // "Art Track" / topic uploads are reachable. The android_vr client returns
// "This video is not available" for many YTM tracks that play fine in the browser. // "This video is not available" for many YTM tracks that play fine in the browser.
@@ -254,7 +251,15 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
}); });
const mf = (dur, variant, strict = false) => { const mf = (dur, variant, strict = false) => {
const durPart = dur ? `duration > ${dur.min} & duration < ${dur.max} & ` : ''; let durPart;
if (dur) {
durPart = `duration > ${dur.min} & duration < ${dur.max} & `;
} else if (durationMs) {
// Even when relaxing the window (Mittelweg), keep a 2× cap so a 28-min video can't slip through
durPart = `duration < ${Math.round(durationMs / 1000 * 2)} & `;
} else {
durPart = '';
}
return ['--match-filter', return ['--match-filter',
`${durPart}!is_live${titleKwPart}${strict ? artistChannelPart : ''}${variant ? variantPart : ''}`]; `${durPart}!is_live${titleKwPart}${strict ? artistChannelPart : ''}${variant ? variantPart : ''}`];
}; };
@@ -325,24 +330,9 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// 4. YT top-5 — no channel check // 4. YT top-5 — no channel check
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
} }
if (!hasAudio()) { // Mittelweg (no-duration): after step 4, do NOT fall through to title-only or
// 5. YTM all — title only // no-filter grabs — those pull wrong covers (observed: Yorushika → Piano Echoes).
log(jobId, '⚠️ Letzter gefilterter Versuch…'); // Fail cleanly, same as the with-duration branch. Use the Direktlink instead.
await tryYtm(0, mf(null, false));
}
if (!hasAudio()) {
// 6. YT top-5 — title only
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
}
if (!hasAudio()) {
// 7. YTM — first video result, no filter
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
await tryYtm(3, []);
}
if (!hasAudio()) {
// 8. YT — no filter
await spawnYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]);
}
} }
const audioFile = hasAudio() ? expectedAudio : null; const audioFile = hasAudio() ? expectedAudio : null;
@@ -370,9 +360,10 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
if (hasCover) ffArgs.push('-map', '1:v'); if (hasCover) ffArgs.push('-map', '1:v');
// Main tags: store original text (Japanese or whatever was passed in). // Main tags: store original text (Japanese or whatever was passed in).
// rawArtist = original Spotify/source artist (may be Japanese); artist = iTunes-resolved (may be English). // rawArtist = original Spotify/source artist (may be Japanese); artist = iTunes-resolved (may be English).
// Use rawArtist for the stored tag so Apple Music / other apps see the authentic original. // Use rawArtist for the per-track artist tag; mainArtist for album_artist so all tracks
// in an album get the same value (prevents Navidrome album-splitting).
const tagArtist = navidromeArtist(rawArtist || artist); const tagArtist = navidromeArtist(rawArtist || artist);
const tagAlbumArtist = (rawArtist || mainArtist).replace(/\s+&\s+/g, ', '); const tagAlbumArtist = mainArtist.replace(/\s+&\s+/g, ', ');
// Sort tags: romaji for display / sorting in apps that support them (TITLESORT, ARTISTSORT, etc.) // Sort tags: romaji for display / sorting in apps that support them (TITLESORT, ARTISTSORT, etc.)
// artist is already the iTunes-resolved English name when available — use directly (more accurate // artist is already the iTunes-resolved English name when available — use directly (more accurate
// than kuromoji). For non-iTunes tracks romajiForStorage acts as kuromoji fallback. // than kuromoji). For non-iTunes tracks romajiForStorage acts as kuromoji fallback.
+22 -13
View File
@@ -292,13 +292,16 @@ app.get('/api/itunes/lookup', async (req, res, next) => {
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false }); if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
res.json({ res.json({
found: true, found: true,
album: meta.album || undefined, title: meta.trackName || undefined,
year: meta.year || undefined,
trackNum: meta.trackNum || undefined,
totalTracks: meta.totalTracks || undefined,
genre: meta.genre || undefined,
coverUrl: meta.artworkUrl || undefined,
artist: meta.itunesArtistName || undefined, artist: meta.itunesArtistName || undefined,
albumArtist: meta.albumArtist || undefined,
album: meta.album || undefined,
year: meta.year || undefined,
trackNum: meta.trackNum || undefined,
totalTracks: meta.totalTracks || undefined,
discNum: meta.discNum || undefined,
genre: meta.genre || undefined,
coverUrl: meta.artworkUrl || undefined,
}); });
} catch (e) { } catch (e) {
console.error('[itunes/lookup]', e.message); console.error('[itunes/lookup]', e.message);
@@ -994,15 +997,20 @@ app.get('/api/musicbrainz/lookup', async (req, res) => {
const trackNum = release?.media?.[0]?.['track-offset'] != null const trackNum = release?.media?.[0]?.['track-offset'] != null
? (release.media[0]['track-offset'] + 1) : undefined; ? (release.media[0]['track-offset'] + 1) : undefined;
const totalTracks = release?.media?.[0]?.['track-count'] || undefined; const totalTracks = release?.media?.[0]?.['track-count'] || undefined;
const discNum = release?.media?.[0]?.position || undefined;
const coverUrl = release?.id ? `https://coverartarchive.org/release/${release.id}/front` : null; const coverUrl = release?.id ? `https://coverartarchive.org/release/${release.id}/front` : null;
// album-artist from release artist-credit if present, else fall back to recording artist
const mbAlbumArtist = (release?.['artist-credit'] || []).map(c => c.name || c.artist?.name).filter(Boolean).join('; ') || mbArtist;
res.json({ res.json({
found: true, found: true,
title: rec.title || title, title: rec.title || title,
artist: mbArtist || artist, artist: mbArtist || artist,
albumArtist: mbAlbumArtist || undefined,
album: release?.title || '', album: release?.title || '',
year: year ? parseInt(year, 10) : undefined, year: year ? parseInt(year, 10) : undefined,
trackNum: trackNum, trackNum: trackNum,
totalTracks: totalTracks, totalTracks: totalTracks,
discNum: discNum,
genre: genre, genre: genre,
coverUrl: coverUrl, coverUrl: coverUrl,
mbid: rec.id, mbid: rec.id,
@@ -1253,9 +1261,10 @@ app.post('/api/edit-metadata', async (req, res) => {
let currentFile = filePath; let currentFile = filePath;
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext // 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
// Use original names (may be Japanese); romaji goes only into sort tags.
if (title) { if (title) {
const safeTitle = sanitize(romajiTitle || title); const safeTitle = sanitize(title);
const fileArtist = (romajiArtist || ndSong?.artist || '').replace(/;/g, ','); const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ',');
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : ''; const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`; const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
@@ -1269,8 +1278,8 @@ app.post('/api/edit-metadata', async (req, res) => {
// 2. Move to new artist/album folder when albumArtist or album changed // 2. Move to new artist/album folder when albumArtist or album changed
const oldAlbumDir = path.dirname(currentFile); const oldAlbumDir = path.dirname(currentFile);
const oldArtistDir = path.dirname(oldAlbumDir); const oldArtistDir = path.dirname(oldAlbumDir);
const newAlbumName = romajiAlbum ? sanitize(romajiAlbum) : path.basename(oldAlbumDir); const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir);
const newArtistName = (albumArtistSort || romajiAlbumArtist) ? sanitize((albumArtistSort || romajiAlbumArtist).split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir); const newArtistName = normalAlbumArtist ? sanitize(normalAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir);
const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName); const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName);
if (newDir !== oldAlbumDir) { if (newDir !== oldAlbumDir) {
@@ -1291,9 +1300,9 @@ app.post('/api/edit-metadata', async (req, res) => {
} catch {} } catch {}
} }
} catch (e) { console.error('[edit-metadata] move:', e.message); } } catch (e) { console.error('[edit-metadata] move:', e.message); }
} else if (romajiAlbum && path.basename(oldAlbumDir) !== sanitize(romajiAlbum)) { } else if (album && path.basename(oldAlbumDir) !== sanitize(album)) {
// Only album name changed (no artist move) — rename album folder in place // Only album name changed (no artist move) — rename album folder in place
const renamedDir = path.join(oldArtistDir, sanitize(romajiAlbum)); const renamedDir = path.join(oldArtistDir, sanitize(album));
if (!fs.existsSync(renamedDir)) { if (!fs.existsSync(renamedDir)) {
try { fs.renameSync(oldAlbumDir, renamedDir); } try { fs.renameSync(oldAlbumDir, renamedDir); }
catch (e) { console.error('[edit-metadata] folder rename:', e.message); } catch (e) { console.error('[edit-metadata] folder rename:', e.message); }
+10 -4
View File
@@ -179,11 +179,13 @@ export async function autofillMusicBrainz() {
if (!d.found) { status.textContent = '❌ Kein Treffer auf MusicBrainz'; return; } if (!d.found) { status.textContent = '❌ Kein Treffer auf MusicBrainz'; return; }
if (d.title) document.getElementById('ie-title').value = d.title; if (d.title) document.getElementById('ie-title').value = d.title;
if (d.artist) document.getElementById('ie-artist').value = d.artist; if (d.artist) document.getElementById('ie-artist').value = d.artist;
if (d.albumArtist) document.getElementById('ie-albumartist').value = d.albumArtist;
if (d.album) document.getElementById('ie-album').value = d.album; if (d.album) document.getElementById('ie-album').value = d.album;
if (d.genre) document.getElementById('ie-genre').value = d.genre; if (d.genre) document.getElementById('ie-genre').value = d.genre;
if (d.year) document.getElementById('ie-year').value = d.year; if (d.year) document.getElementById('ie-year').value = d.year;
if (d.trackNum) document.getElementById('ie-track').value = d.trackNum; if (d.trackNum) document.getElementById('ie-track').value = d.trackNum;
if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks; if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks;
if (d.discNum) document.getElementById('ie-disc').value = d.discNum;
if (d.coverUrl) { if (d.coverUrl) {
status.textContent = '✅ Metadaten gefunden — Cover wird geladen…'; status.textContent = '✅ Metadaten gefunden — Cover wird geladen…';
S._infoArtData = { url: d.coverUrl }; S._infoArtData = { url: d.coverUrl };
@@ -210,11 +212,15 @@ export async function autofillItunes() {
const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const d = await r.json(); const d = await r.json();
if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; } if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; }
if (d.album) document.getElementById('ie-album').value = d.album; if (d.title) document.getElementById('ie-title').value = d.title;
if (d.genre) document.getElementById('ie-genre').value = d.genre; if (d.artist) document.getElementById('ie-artist').value = d.artist;
if (d.year) document.getElementById('ie-year').value = d.year; if (d.albumArtist) document.getElementById('ie-albumartist').value = d.albumArtist;
if (d.trackNum) document.getElementById('ie-track').value = d.trackNum; if (d.album) document.getElementById('ie-album').value = d.album;
if (d.genre) document.getElementById('ie-genre').value = d.genre;
if (d.year) document.getElementById('ie-year').value = d.year;
if (d.trackNum) document.getElementById('ie-track').value = d.trackNum;
if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks; if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks;
if (d.discNum) document.getElementById('ie-disc').value = d.discNum;
if (d.coverUrl) { if (d.coverUrl) {
S._infoArtData = { url: d.coverUrl }; S._infoArtData = { url: d.coverUrl };
document.getElementById('info-edit-art-preview').innerHTML = document.getElementById('info-edit-art-preview').innerHTML =