Fix (the reported bug):
- Deezer's /artist/{id}/albums omits the artist object, so discography album cards had an
empty artist name → the download modal fell back to the literal "Unknown Artist", which
poisoned the yt-dlp search ("Unknown Artist <album>") and its artist/channel filter, failing
with "Nicht eindeutig gefunden".
- deezerLookupArtistAlbums now backfills the known artist name (we're on that artist's page).
- openDlModal now defaults album cards to an ALBUM download (was always Track), so a one-click
download no longer grabs an album as a single track named after the album.
Rename (requested cleanup, dependency-checked so nothing breaks):
- All frontend itunes* identifiers → deezer* (functions, the ItunesItem-shape field keys
itunesData/itunesArtistId/itunesFanCount/itunesId, the itunes-album-detail route + DOM ids,
the badge-itunes CSS class). The backend reads none of these fields, so the boundary is safe.
- Verified: build passes; route/DOM-id/CSS producer↔consumer all consistent; only the factual
"Apple's iTunes /search 403" comments keep the historical name. SW ns-v62.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
13 KiB
JavaScript
288 lines
13 KiB
JavaScript
import { S } from '../state.js';
|
||
import { esc, toast } from '../utils.js';
|
||
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex, deezerLookupAlbum } from '../api.js';
|
||
import { _addNotif } from './notify.js';
|
||
|
||
// ─── DOWNLOAD MODAL ───
|
||
export function openDlModal(track) {
|
||
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
|
||
// Album cards pass isAlbum → default the modal to an album download, not a single track named
|
||
// after the album (a one-click download of an album card used to grab it as one "track").
|
||
const _defType = track.isAlbum ? 'album' : 'track';
|
||
S.downloadTarget = track; S.dlType = _defType;
|
||
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type===_defType));
|
||
const info = document.getElementById('dl-modal-info');
|
||
const art = track.artworkUrl100 || track.artworkUrl60 || '';
|
||
info.innerHTML = `${art ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
|
||
<div class="qi-info"><div class="mti-name">${esc(track.trackName||track.collectionName||'—')}</div><div class="mti-sub">${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}</div></div>`;
|
||
document.getElementById('dl-album-sub').textContent = track.collectionName ? `"${track.collectionName}" herunterladen` : 'Album herunterladen';
|
||
document.getElementById('download-modal').classList.add('open');
|
||
}
|
||
export function closeDlModal() { document.getElementById('download-modal').classList.remove('open'); }
|
||
export function selectDlType(el, type) {
|
||
S.dlType = type;
|
||
document.querySelectorAll('.modal-opt').forEach(o => o.classList.remove('selected'));
|
||
el.classList.add('selected');
|
||
}
|
||
export async function executeDownload() {
|
||
if(!S.downloadTarget || !S.cfg.backendUrl) return;
|
||
closeDlModal();
|
||
const jobId = 'job_' + Date.now();
|
||
const ov = getRomajiOverrides();
|
||
const _artist = S.downloadTarget.artistName || 'Unknown Artist';
|
||
const _title = S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown';
|
||
const _album = S.downloadTarget.collectionName || '';
|
||
const payload = {
|
||
type: S.dlType,
|
||
artist: _artist,
|
||
title: _title,
|
||
album: _album,
|
||
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
|
||
deezerId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
|
||
collectionId: S.downloadTarget.collectionId || null,
|
||
explicit: S.downloadTarget.trackExplicitness === 'explicit',
|
||
trackNum: S.downloadTarget.trackNumber || undefined,
|
||
totalTracks: S.downloadTarget.trackCount || undefined,
|
||
discNum: S.downloadTarget.discNumber || undefined,
|
||
discTotal: S.downloadTarget.discCount || undefined,
|
||
durationMs: S.downloadTarget.durationMs || S.downloadTarget.trackTimeMillis || undefined,
|
||
...(ov[_artist] && { artistSort: ov[_artist], albumArtistSort: ov[_artist] }),
|
||
...(ov[_title] && { titleSort: ov[_title] }),
|
||
...(ov[_album] && { albumSort: ov[_album] }),
|
||
};
|
||
// Album download: fetch the album's tracks from Deezer (id in collectionId) and send them in the
|
||
// request — the backend downloads that list directly (Apple's /search is 403, so no iTunes lookup).
|
||
if (S.dlType === 'album' && payload.collectionId) {
|
||
try {
|
||
const albumTracks = await deezerLookupAlbum(payload.collectionId);
|
||
if (albumTracks.length) payload.tracks = albumTracks;
|
||
} catch {}
|
||
}
|
||
addJob(jobId, payload.title, payload.artist, payload.type, payload.album);
|
||
try {
|
||
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({...payload, jobId}),
|
||
});
|
||
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
pollJob(jobId);
|
||
} catch(e) {
|
||
updateJob(jobId, 'error', 'Verbindung zum Backend fehlgeschlagen');
|
||
toast('❌ Backend nicht erreichbar: '+e.message);
|
||
}
|
||
}
|
||
|
||
// ─── SPOTIFY DOWNLOAD ───
|
||
export async function executeSpotifyDownload({ type, title, artist, artworkUrl, tracks, createPlaylist, playlistName }) {
|
||
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
|
||
const jobId = 'job_' + Date.now();
|
||
addJob(jobId, title, artist, type);
|
||
const ov = getRomajiOverrides();
|
||
const enrichedTracks = (tracks || []).map(t => {
|
||
const a = t.artistName || '';
|
||
const n = t.trackName || '';
|
||
const al = t.collectionName || '';
|
||
return {
|
||
...t,
|
||
...(ov[a] && { artistSort: ov[a], albumArtistSort: ov[a] }),
|
||
...(ov[n] && { titleSort: ov[n] }),
|
||
...(ov[al] && { albumSort: ov[al] }),
|
||
};
|
||
});
|
||
try {
|
||
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ jobId, type, title, artist, artworkUrl, tracks: enrichedTracks }),
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
pollJob(jobId);
|
||
if (createPlaylist && playlistName && tracks?.length) {
|
||
_pollAndCreatePlaylist(jobId, tracks, playlistName);
|
||
}
|
||
} catch(e) {
|
||
updateJob(jobId, 'error', 'Backend-Verbindung fehlgeschlagen: ' + e.message);
|
||
toast('❌ Backend nicht erreichbar: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// Used by settings.js for romaji fix-sort job
|
||
export async function startArtistSortFix(kanji, romaji) {
|
||
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return;
|
||
const jobId = 'job_' + Date.now();
|
||
addJob(jobId, `Romaji-Fix: ${romaji}`, kanji, 'fix-sort');
|
||
const res = await fetch(`${S.cfg.backendUrl}/api/fix-artist-sort`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, artistKanji: kanji, artistSort: romaji, jobId }),
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
pollJob(jobId);
|
||
}
|
||
|
||
async function _pollAndCreatePlaylist(jobId, tracks, playlistName) {
|
||
let lastStatus = 'pending';
|
||
while (true) {
|
||
await new Promise(r => setTimeout(r, 2500));
|
||
const job = S.jobs[jobId];
|
||
if (job) lastStatus = job.status;
|
||
if (!job || job.status === 'done' || job.status === 'error') break;
|
||
}
|
||
if (lastStatus !== 'done') return;
|
||
|
||
try {
|
||
await api('startScan', '');
|
||
toast('🔍 Navidrome scannt Bibliothek…');
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
for (let i = 0; i < 30; i++) {
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
const st = await api('getScanStatus', '');
|
||
if (!st?.scanStatus?.scanning) break;
|
||
}
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
} catch {
|
||
await new Promise(r => setTimeout(r, 20000));
|
||
}
|
||
|
||
await _createNdPlaylist(tracks, playlistName);
|
||
}
|
||
|
||
async function _createNdPlaylist(tracks, name) {
|
||
try {
|
||
toast('🎵 Erstelle Navidrome-Playlist…');
|
||
const songIds = [];
|
||
const missing = [];
|
||
const norm = s => s?.toLowerCase().trim() || '';
|
||
const normA = s => norm(s).replace(/[^\w\s]/g, '');
|
||
const JP_RE = /[-ヿ一-鿿]/;
|
||
const hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName));
|
||
const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null;
|
||
const jpTitles = tracks.map(t => (hasJp && JP_RE.test(t.trackName)) ? t.trackName : '');
|
||
const romajiTitles = hasJp ? await romajiConvert(jpTitles).catch(() => jpTitles) : jpTitles;
|
||
const normR = s => (s || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||
for (const [idx, t] of tracks.entries()) {
|
||
if (!t.trackName) continue;
|
||
const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName;
|
||
try {
|
||
const firstArtist = t.artistName ? t.artistName.split(/[,/•]/)[0].trim() : '';
|
||
const artistQuery = firstArtist ? firstArtist + ' ' + t.trackName : t.trackName;
|
||
const r = await api('search3', '&query=' + encodeURIComponent(artistQuery) + '&songCount=20&albumCount=0&artistCount=0');
|
||
let songs = r.searchResult3?.song || [];
|
||
if (!songs.some(s => norm(s.title) === norm(t.trackName))) {
|
||
try {
|
||
const r2 = await api('search3', '&query=' + encodeURIComponent(t.trackName) + '&songCount=20&albumCount=0&artistCount=0');
|
||
songs = r2.searchResult3?.song || [];
|
||
} catch { /* keep original songs */ }
|
||
}
|
||
const firstWord = s => normA(s).split(/\s+/)[0];
|
||
const titleMatch = s => norm(s.title) === norm(t.trackName);
|
||
const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName);
|
||
let match = songs.find(s => titleMatch(s) && artistMatch(s))
|
||
|| songs.find(s => titleMatch(s));
|
||
if (!match && romajiIdx && JP_RE.test(t.trackName)) {
|
||
const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName));
|
||
if (jpEntry) match = { id: jpEntry.id };
|
||
}
|
||
if (!match && JP_RE.test(t.trackName) && romajiTitles[idx]) {
|
||
const rTitle = romajiTitles[idx];
|
||
try {
|
||
const r3 = await api('search3', '&query=' + encodeURIComponent(rTitle) + '&songCount=20&albumCount=0&artistCount=0');
|
||
const s3 = r3.searchResult3?.song || [];
|
||
match = s3.find(s => normR(s.title) === normR(rTitle)) || null;
|
||
} catch { /* ignore */ }
|
||
}
|
||
if (match) songIds.push(match.id);
|
||
else missing.push(missingLabel);
|
||
} catch { missing.push(missingLabel); }
|
||
}
|
||
if (!songIds.length) {
|
||
toast('⚠ Keine Tracks in Navidrome gefunden — zuerst Library scannen, dann Playlist manuell anlegen');
|
||
_addNotif({ id: 'pl_' + Date.now(), title: 'Playlist: ' + name, artist: '', status: 'error', message: 'Keine Tracks in Navidrome gefunden — Library scan nötig', missing, timestamp: Date.now(), read: false });
|
||
return;
|
||
}
|
||
const songParams = songIds.map(id => '&songId=' + encodeURIComponent(id)).join('');
|
||
const plRes = await api('createPlaylist', '&name=' + encodeURIComponent(name) + songParams);
|
||
const plId = plRes.playlist?.id || null;
|
||
const msg = songIds.length + '/' + tracks.length + ' Tracks hinzugefügt' + (missing.length ? ', ' + missing.length + ' nicht gefunden' : '');
|
||
toast('✅ Playlist "' + name + '" mit ' + songIds.length + '/' + tracks.length + ' Tracks erstellt');
|
||
_addNotif({ id: 'pl_' + Date.now(), title: 'Playlist: ' + name, artist: '', status: 'done', message: msg, missing, timestamp: Date.now(), read: false, navType: 'playlist', navId: plId, navName: name });
|
||
} catch(e) {
|
||
toast('Playlist-Erstellung fehlgeschlagen: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ─── JOBS ───
|
||
function addJob(id, title, artist, type, album) {
|
||
S.jobs[id] = {title, artist, type: type || '', album: album || '', progress:0, status:'pending'};
|
||
renderJobs();
|
||
}
|
||
function updateJob(id, status, msg, progress) {
|
||
if(!S.jobs[id]) return;
|
||
S.jobs[id] = {...S.jobs[id], status, msg:msg||'', progress: progress ?? S.jobs[id].progress};
|
||
renderJobs();
|
||
if(status==='done') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 5000);
|
||
if(status==='error') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 8000);
|
||
}
|
||
function renderJobs() {
|
||
const panel = document.getElementById('jobs-panel');
|
||
const activeIds = new Set(Object.keys(S.jobs));
|
||
|
||
panel.querySelectorAll('[data-job-id]').forEach(el => {
|
||
if (!activeIds.has(el.dataset.jobId)) el.remove();
|
||
});
|
||
|
||
Object.entries(S.jobs).forEach(([id, j]) => {
|
||
let c = panel.querySelector(`[data-job-id="${id}"]`);
|
||
if (!c) {
|
||
c = document.createElement('div');
|
||
c.className = 'job-card';
|
||
c.dataset.jobId = id;
|
||
panel.appendChild(c);
|
||
}
|
||
const isDone = j.status === 'done', isErr = j.status === 'error';
|
||
const statusText = isErr ? 'Fehler' : isDone ? '✓ Fertig! Bibliothek wird gescannt…' : j.msg || 'Wird heruntergeladen…';
|
||
c.innerHTML = `<div class="job-header">
|
||
<div class="job-title">${esc(j.title)}</div>
|
||
<div class="job-status">${esc(j.artist)}</div>
|
||
</div>
|
||
<div class="job-bar"><div class="job-fill ${isDone?'done':isErr?'error':''}" style="width:${isDone?100:isErr?100:j.progress}%"></div></div>
|
||
<div class="job-sub">${statusText}</div>`;
|
||
});
|
||
}
|
||
async function pollJob(jobId) {
|
||
if(!S.jobs[jobId]) return;
|
||
try {
|
||
const res = await fetch(`${S.cfg.backendUrl}/api/status/${jobId}`);
|
||
const d = await res.json();
|
||
updateJob(jobId, d.status, d.message, d.progress);
|
||
if(d.status !== 'done' && d.status !== 'error') {
|
||
setTimeout(() => pollJob(jobId), 1500);
|
||
} else {
|
||
const job = S.jobs[jobId];
|
||
const title = job?.title || 'Download';
|
||
_addNotif({
|
||
id: jobId,
|
||
title,
|
||
artist: job?.artist || '',
|
||
status: d.status,
|
||
message: d.message || (d.status === 'done' ? 'Fertig!' : 'Fehler'),
|
||
failed: _parseFailedTracks(d.log),
|
||
timestamp: Date.now(),
|
||
read: false,
|
||
navType: job?.type || '',
|
||
navArtist: job?.artist || '',
|
||
navAlbum: job?.album || '',
|
||
navTitle: title,
|
||
});
|
||
if (d.status === 'done') {
|
||
toast(d.skipped ? `⏭ "${title}" bereits auf dem Server` : `✅ ${title} gespeichert!`);
|
||
if (!d.skipped) invalidateRomajiIndex();
|
||
}
|
||
}
|
||
} catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); }
|
||
}
|
||
|
||
function _parseFailedTracks(log) {
|
||
const re = /Fehler bei "([^"]+)"/;
|
||
return (log || []).map(line => re.exec(line)?.[1]).filter(Boolean);
|
||
}
|