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:
1 parent
8be3a515e6
commit
fa4c954ab9
6 files changed
+148
-98
No files matched your search
+47
-8
@@ -48,13 +48,50 @@ async function downloadArtwork(url, dir) {
|
||||
|
||||
const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
||||
|
||||
function findExistingTrack(dir, trackPrefix, safeTitle, safeArtist) {
|
||||
// Check new format "{prefix}{artist} - {title}.ext" first, then old format "{prefix}{title}.ext"
|
||||
const names = safeArtist
|
||||
? [`${trackPrefix}${safeArtist} - ${safeTitle}`, `${trackPrefix}${safeTitle}`]
|
||||
: [`${trackPrefix}${safeTitle}`];
|
||||
return AUDIO_EXTS.flatMap(ext => names.map(n => path.join(dir, `${n}.${ext}`)))
|
||||
.find(f => fs.existsSync(f)) || null;
|
||||
// Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
|
||||
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;
|
||||
|
||||
// Robust duplicate detection: search case-insensitively across all album folders
|
||||
// under the artist folder matching firstArtist, compare normalized title ignoring
|
||||
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
||||
// "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
|
||||
@@ -140,7 +177,9 @@ async function lookupItunesTrackMeta(artist, title) {
|
||||
const rawGenre = (usMatch || match).primaryGenreName;
|
||||
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
|
||||
return {
|
||||
album: match.collectionName || '',
|
||||
album: match.collectionName || '',
|
||||
albumArtist: match.collectionArtistName || undefined,
|
||||
trackName: match.trackName || undefined,
|
||||
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
|
||||
trackNum: match.trackNumber || undefined,
|
||||
totalTracks: match.trackCount || undefined,
|
||||
|
||||
+27
-36
@@ -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 cleanTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
|
||||
|
||||
// Folder / filename: romaji of the resolved artist (iTunes name if available — already in English,
|
||||
// otherwise kuromoji). rawArtist (original Japanese) is only used for tags + YTM search.
|
||||
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
||||
const artistRomaji = romajiForStorage(artist) || artist;
|
||||
const titleRomaji = romajiForStorage(cleanTitle) || cleanTitle;
|
||||
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);
|
||||
// Folder / filename: use original names (Japanese or whatever was passed in).
|
||||
// Romaji goes ONLY into sort tags (TITLESORT, ARTISTSORT, etc.) via embedMetadata.
|
||||
const safeArtist = sanitize((mainArtist || '').split(/[,;&]/)[0].trim());
|
||||
const safeAlbum = sanitize(album || 'Singles');
|
||||
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const safeTitle = sanitize(titleRomaji);
|
||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
const safeTitle = sanitize(cleanTitle);
|
||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
|
||||
// Use track artist (not albumArtist) so all credited artists appear in the filename
|
||||
const safeArtistForFile = sanitize(artistRomaji.replace(/;/g, ','));
|
||||
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||||
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
|
||||
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||||
|
||||
const existing = findExistingTrack(dir, trackPrefix, safeTitle, safeArtistForFile);
|
||||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
|
||||
if (existing) {
|
||||
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
||||
return { success: true, dir, skipped: true };
|
||||
@@ -161,6 +156,8 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--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
|
||||
// "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.
|
||||
@@ -254,7 +251,15 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
});
|
||||
|
||||
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',
|
||||
`${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
|
||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 5. YTM all — title only
|
||||
log(jobId, '⚠️ Letzter gefilterter Versuch…');
|
||||
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]);
|
||||
}
|
||||
// Mittelweg (no-duration): after step 4, do NOT fall through to title-only or
|
||||
// no-filter grabs — those pull wrong covers (observed: Yorushika → Piano Echoes).
|
||||
// Fail cleanly, same as the with-duration branch. Use the Direktlink instead.
|
||||
}
|
||||
|
||||
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');
|
||||
// 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).
|
||||
// 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 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.)
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user