Files
NovaSonic/src/ui/downloads.js
T
HoneyandClaude Opus 4.8 1e206e8df3 fix(web): 'Unknown Artist' download from artist page + rename itunes*→deezer* identifiers
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>
2026-08-01 00:31:09 +02:00

288 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}