Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05c15df93 | ||
|
|
da32d2911a | ||
|
|
39d47b78d4 | ||
|
|
eee98d193f |
No files matched your search
@@ -67,6 +67,23 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald
|
||||||
|
irgendwo ein Kanal-Eintrag (`browse/UC…`) auftauchte, samt der bereits gesammelten Song-URLs. An
|
||||||
|
acht echten Suchen gemessen passierte das in **sieben** Fällen: Schritt 1 der Kaskade — die Stufe
|
||||||
|
mit den offiziellen Art Tracks — lieferte nie etwas, faktisch lief jeder Download über den
|
||||||
|
YouTube-Fallback. Das erklärt eine ganze Serie falscher Treffer (u. a. „Easy" als Mixtape- statt
|
||||||
|
Albumversion). Kanal- und Album-Einträge werden jetzt übersprungen statt abzubrechen; über 20 000
|
||||||
|
Zufallsausgaben geprüft ist die neue Auswahl eine Obermenge der alten, kann also nur Kandidaten
|
||||||
|
hinzufügen.
|
||||||
|
- **Zurück im Album führte in die Albenliste** — statt dorthin, wo man das Album angeklickt hatte.
|
||||||
|
Der Knopf navigierte vorwärts, legte dabei einen History-Eintrag an und brachte so auch den
|
||||||
|
Browser-Zurück-Knopf durcheinander. Jetzt geht er einen Schritt im Verlauf zurück wie jede andere
|
||||||
|
Detailseite.
|
||||||
|
- **Metadaten bearbeiten zerlegte die Hörstatistik** — Top-Titel gruppierten nach `song_id`, und beim
|
||||||
|
Bearbeiten umbenannte Dateien bekommen von Navidrome eine neue ID: der Titel erschien doppelt mit
|
||||||
|
geteilten Zählern, Künstler ebenso bei geänderter Schreibweise. Gruppiert wird jetzt über einen
|
||||||
|
normalisierten Schlüssel (Titel + Künstler), Anzeigenamen kommen aus dem jüngsten Play. Playlists
|
||||||
|
waren nie betroffen, die werden beim Bearbeiten ohnehin auf die neue ID übertragen.
|
||||||
- **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's
|
- **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's
|
||||||
"700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required
|
"700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required
|
||||||
only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step
|
only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ On the deploy host the music lives at `/opt/navidrome/music`.
|
|||||||
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
|
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
|
||||||
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
|
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
|
||||||
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
|
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
|
||||||
|
- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls`
|
||||||
|
used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned
|
||||||
|
`[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired
|
||||||
|
in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost
|
||||||
|
every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE
|
||||||
|
line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips
|
||||||
|
`browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old
|
||||||
|
behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any.
|
||||||
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
|
- **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.
|
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
|
||||||
- **Leading aesthetic characters:** artists often style their name with leading punctuation
|
- **Leading aesthetic characters:** artists often style their name with leading punctuation
|
||||||
|
|||||||
+29
-19
@@ -55,6 +55,28 @@ function buildTitleKeywords(title) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen.
|
||||||
|
// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und
|
||||||
|
// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind
|
||||||
|
// Listeneintraege und werden uebersprungen.
|
||||||
|
//
|
||||||
|
// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE
|
||||||
|
// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand
|
||||||
|
// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf
|
||||||
|
// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier
|
||||||
|
// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile.
|
||||||
|
function pickYtmVideoUrls(stdout) {
|
||||||
|
const urls = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const line of (stdout || '').split('\n')) {
|
||||||
|
const u = line.trim();
|
||||||
|
if (!u) continue;
|
||||||
|
if (u.includes('watch?v=')) urls.push(u);
|
||||||
|
else if (u.includes('/browse/')) skipped++;
|
||||||
|
}
|
||||||
|
return { urls, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
function buildVariantFilter(title) {
|
function buildVariantFilter(title) {
|
||||||
const t = (title || '').toLowerCase();
|
const t = (title || '').toLowerCase();
|
||||||
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
|
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
|
||||||
@@ -231,14 +253,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
||||||
// result and treating its uploads as the search result set.
|
// result and treating its uploads as the search result set.
|
||||||
//
|
//
|
||||||
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
|
|
||||||
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
|
|
||||||
// return [] so the caller falls through to the ytsearch fallback.
|
|
||||||
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
|
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
|
||||||
// page processing, not just channel expansion, and would wrongly return [] always.
|
// page processing, not just channel expansion, and would wrongly return [] always.
|
||||||
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
|
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
|
||||||
const urls = [];
|
let out = '';
|
||||||
let channelExpanded = false;
|
|
||||||
const proc = spawn(CONFIG.ytdlpBin, [
|
const proc = spawn(CONFIG.ytdlpBin, [
|
||||||
ytmUrl,
|
ytmUrl,
|
||||||
'--flat-playlist',
|
'--flat-playlist',
|
||||||
@@ -250,21 +268,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
// native Songs shelf (where the actual artist track lives).
|
// native Songs shelf (where the actual artist track lives).
|
||||||
'--extractor-args', 'youtube:player_client=web_music,default',
|
'--extractor-args', 'youtube:player_client=web_music,default',
|
||||||
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
||||||
proc.stdout.on('data', d => {
|
proc.stdout.on('data', d => { out += d.toString(); });
|
||||||
const text = d.toString();
|
|
||||||
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
|
|
||||||
if (!channelExpanded) {
|
|
||||||
text.split('\n').forEach(line => {
|
|
||||||
const u = line.trim();
|
|
||||||
if (u.includes('watch?v=')) urls.push(u);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
proc.on('error', () => resolve([]));
|
proc.on('error', () => resolve([]));
|
||||||
proc.on('close', () => {
|
proc.on('close', () => {
|
||||||
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`);
|
const { urls, skipped } = pickYtmVideoUrls(out);
|
||||||
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`);
|
if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`);
|
||||||
resolve(channelExpanded ? [] : urls);
|
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`);
|
||||||
|
resolve(urls);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -453,4 +463,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
|
|||||||
return { success: true, dir };
|
return { success: true, dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter };
|
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };
|
||||||
@@ -76,3 +76,34 @@ test('"live" inside another word does not disable the rule', () => {
|
|||||||
assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false);
|
assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false);
|
||||||
assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true);
|
assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { pickYtmVideoUrls } = require('../lib/ytdlp.js');
|
||||||
|
|
||||||
|
// Echte Ausgabe der YTM-Suche "DAT ADAM UFO": Songs, ein Album (MPREb_) und der
|
||||||
|
// Kuenstlerkanal (UC…) auf Platz 9.
|
||||||
|
const YTM_OUT = [
|
||||||
|
'https://music.youtube.com/watch?v=ZG4XhPn3Yb4',
|
||||||
|
'https://music.youtube.com/watch?v=2KVU6aikZF0',
|
||||||
|
'https://music.youtube.com/browse/MPREb_IhxJPixC1QT',
|
||||||
|
'https://music.youtube.com/watch?v=phLOTtJaqZk',
|
||||||
|
'https://music.youtube.com/watch?v=p935nH6aj8Y',
|
||||||
|
'https://music.youtube.com/watch?v=Pb2YcDBm5fA',
|
||||||
|
'https://music.youtube.com/browse/MPREb_S7MMKVMduma',
|
||||||
|
'https://music.youtube.com/watch?v=mNfsX2RbZ2M',
|
||||||
|
'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA',
|
||||||
|
'https://music.youtube.com/watch?v=O034JUlf1J4',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
test('a channel hit no longer discards the whole result list', () => {
|
||||||
|
const { urls, skipped } = pickYtmVideoUrls(YTM_OUT);
|
||||||
|
assert.equal(urls.length, 7);
|
||||||
|
assert.equal(skipped, 3); // 2 Alben + 1 Kanal
|
||||||
|
assert.match(urls[0], /ZG4XhPn3Yb4/); // der offizielle Art Track
|
||||||
|
assert.ok(!urls.some(u => u.includes('/browse/')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing but list entries yields no candidates', () => {
|
||||||
|
const only = 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA';
|
||||||
|
assert.deepEqual(pickYtmVideoUrls(only).urls, []);
|
||||||
|
assert.deepEqual(pickYtmVideoUrls('').urls, []);
|
||||||
|
});
|
||||||
+55
-16
@@ -172,6 +172,14 @@ app.get('/scrobble/history', async (req, res) => {
|
|||||||
// ?tz= seconds east of UTC for local-day bucketing (default 0)
|
// ?tz= seconds east of UTC for local-day bucketing (default 0)
|
||||||
// ?bucket= day | month | year — granularity of the time series (default day)
|
// ?bucket= day | month | year — granularity of the time series (default day)
|
||||||
// ?limit= top-list size (default 20, max 100)
|
// ?limit= top-list size (default 20, max 100)
|
||||||
|
// Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache
|
||||||
|
// Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald
|
||||||
|
// jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik).
|
||||||
|
// SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu
|
||||||
|
// vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor).
|
||||||
|
const normKey = (col) =>
|
||||||
|
`lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`;
|
||||||
|
|
||||||
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
|
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
|
||||||
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
|
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
|
||||||
|
|
||||||
@@ -202,31 +210,62 @@ app.get('/scrobble/stats', async (req, res) => {
|
|||||||
// navigator so you can't step before logging began.
|
// navigator so you can't step before logging began.
|
||||||
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
|
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
|
||||||
|
|
||||||
|
// Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte
|
||||||
|
// Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen.
|
||||||
const topArtists = db.prepare(`
|
const topArtists = db.prepare(`
|
||||||
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
WITH neueste AS (
|
||||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at)
|
||||||
GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit
|
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
|
||||||
|
), zaehler AS (
|
||||||
|
SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
||||||
|
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
|
||||||
|
)
|
||||||
|
SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds
|
||||||
|
FROM neueste n JOIN zaehler z ON z.k = n.k
|
||||||
|
ORDER BY z.plays DESC, z.seconds DESC LIMIT :limit
|
||||||
`).all(winL);
|
`).all(winL);
|
||||||
|
|
||||||
const topAlbums = db.prepare(`
|
const topAlbums = db.prepare(`
|
||||||
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist,
|
WITH neueste AS (
|
||||||
MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2,
|
||||||
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> ''
|
album, album_id, artist, cover_art, MAX(played_at)
|
||||||
GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit
|
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
|
||||||
|
), zaehler AS (
|
||||||
|
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
|
||||||
|
)
|
||||||
|
SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist,
|
||||||
|
n.cover_art AS coverArt, z.plays AS plays
|
||||||
|
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
|
||||||
|
ORDER BY z.plays DESC LIMIT :limit
|
||||||
`).all(winL);
|
`).all(winL);
|
||||||
|
|
||||||
|
// Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt
|
||||||
|
// Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist
|
||||||
|
// deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem
|
||||||
|
// JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten
|
||||||
|
// blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums.
|
||||||
const topTracks = db.prepare(`
|
const topTracks = db.prepare(`
|
||||||
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album,
|
WITH neueste AS (
|
||||||
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt,
|
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2,
|
||||||
song_id AS songId, COUNT(*) AS plays
|
title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at)
|
||||||
FROM plays WHERE ${WHERE}
|
FROM plays WHERE ${WHERE}
|
||||||
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
|
GROUP BY k1, k2
|
||||||
|
), zaehler AS (
|
||||||
|
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE}
|
||||||
|
GROUP BY k1, k2
|
||||||
|
)
|
||||||
|
SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId,
|
||||||
|
n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays
|
||||||
|
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
|
||||||
|
ORDER BY z.plays DESC LIMIT :limit
|
||||||
`).all(winL);
|
`).all(winL);
|
||||||
|
|
||||||
const topGenres = db.prepare(`
|
const topGenres = db.prepare(`
|
||||||
SELECT MAX(genre) AS name, COUNT(*) AS plays
|
SELECT MAX(genre) AS name, COUNT(*) AS plays
|
||||||
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
|
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
|
||||||
GROUP BY lower(genre) ORDER BY plays DESC LIMIT :limit
|
GROUP BY ${normKey('genre')} ORDER BY plays DESC LIMIT :limit
|
||||||
`).all(winL);
|
`).all(winL);
|
||||||
|
|
||||||
const series = db.prepare(`
|
const series = db.prepare(`
|
||||||
@@ -280,14 +319,14 @@ app.get('/scrobble/compare', async (req, res) => {
|
|||||||
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
|
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
|
||||||
|
|
||||||
const artistsOf = u => db.prepare(`
|
const artistsOf = u => db.prepare(`
|
||||||
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
|
SELECT ${normKey('artist')} AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
|
||||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
||||||
GROUP BY lower(artist)
|
GROUP BY ${normKey('artist')}
|
||||||
`).all({ user: u, from, to });
|
`).all({ user: u, from, to });
|
||||||
const tracksOf = u => db.prepare(`
|
const tracksOf = u => db.prepare(`
|
||||||
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
|
SELECT MAX(song_id) AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
|
||||||
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
||||||
FROM plays WHERE ${WHERE} GROUP BY song_id
|
FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')}
|
||||||
`).all({ user: u, from, to });
|
`).all({ user: u, from, to });
|
||||||
const totalsOf = u => db.prepare(
|
const totalsOf = u => db.prepare(
|
||||||
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
|
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
|
||||||
|
|||||||
+6
-1
@@ -572,8 +572,13 @@ export async function loadPartialAlbumDetail({ nd, deezer }) {
|
|||||||
// ─── ALBUM DETAIL ───
|
// ─── ALBUM DETAIL ───
|
||||||
export async function loadAlbumDetail(album) {
|
export async function loadAlbumDetail(album) {
|
||||||
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
|
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
|
||||||
|
// Zurück heißt zurück: einen Eintrag im Verlauf zurückgehen, wie auf jeder anderen Detailseite.
|
||||||
|
// Vorher navigierte dieser Knopf VORWÄRTS auf die Albenliste (bzw. den Artist) — das landete nicht
|
||||||
|
// dort, wo man das Album angeklickt hatte, und schob zusätzlich einen neuen History-Eintrag auf den
|
||||||
|
// Stapel, sodass der Browser-Zurück-Knopf sich durch diesen Müll arbeiten musste statt nach Home.
|
||||||
|
// Nur wenn es keinen Verlauf gibt (Direkteinstieg auf ein Album), bleibt die Albenliste das Ziel.
|
||||||
document.getElementById('album-back-btn').onclick = () =>
|
document.getElementById('album-back-btn').onclick = () =>
|
||||||
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums');
|
S._navCursor > 0 ? _navGoBack() : navigate('albums');
|
||||||
document.getElementById('album-detail-name').textContent = dispAlbumName(album);
|
document.getElementById('album-detail-name').textContent = dispAlbumName(album);
|
||||||
const artistEl = document.getElementById('album-detail-artist');
|
const artistEl = document.getElementById('album-detail-artist');
|
||||||
artistEl.innerHTML = parseArtists(album)
|
artistEl.innerHTML = parseArtists(album)
|
||||||
|
|||||||
Reference in new issue
Block a user