playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises

This commit is contained in:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+213 -24
View File
@@ -16,8 +16,15 @@
toggleShuffle,
goToView,
getArt,
cachedStreamArt,
playlistBulk,
runPlaylistBulk,
cancelPlaylistBulk,
dismissPlaylistBulk,
} from "../lib/state.svelte.js";
import { untrack } from "svelte";
import * as api from "../lib/api.js";
import { findCatalogTrack } from "../lib/catalogMatch.js";
import Icon from "../components/Icon.svelte";
const playlist = $derived(
@@ -29,6 +36,7 @@
let query = $state("");
let menuOpen = $state(false);
let confirmDelete = $state(false);
let exportOpen = $state(false);
let renaming = $state(false);
let nameInput = $state("");
let editingDesc = $state(false);
@@ -115,7 +123,6 @@
// the import ladder's own evidence class — supplies the cover and the
// track id, persisted on the row. Sequential, one search per row, each
// row attempted once per session; no match simply keeps the glyph.
const normId = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const artAttempted = new Set();
let backfillBusy = false;
$effect(() => {
@@ -124,6 +131,7 @@
(i) =>
i.kind === "stream" &&
!i.art_url &&
!i.art_path &&
i.artist &&
i.title &&
!artAttempted.has(i.id),
@@ -138,17 +146,37 @@
if (ui.playlistId !== plId) return;
if (artAttempted.has(row.id)) continue;
artAttempted.add(row.id);
// The file BEFORE the catalog: a row already streamed once
// holds the peer's own copy, and underground rows — the ones
// with no catalog cover to find, which is why they are in
// this walk at all — are exactly the ones whose files carry
// artwork nothing else has.
const embedded = await cachedStreamArt(row.track_id, row.artist, row.title);
if (ui.playlistId !== plId) return;
if (embedded) {
try {
await api.playlistRelinkItem(plId, row.id, { artPath: embedded });
const mine = items.find((i) => i.id === row.id);
if (mine) mine.art_path = embedded;
} catch {
// The row vanished mid-walk — nothing to persist onto.
}
continue;
}
// The SAME lookup the import review uses — one definition of
// "this row is that record", so a row that matched on import
// matches here too.
let hit = null;
try {
const res = await api.catalogSearch(`${row.artist} ${row.title}`);
const same = (t) =>
normId(t.artist) === normId(row.artist) &&
normId(t.title) === normId(row.title);
const hits = (res?.tracks ?? []).filter(same);
hit =
hits.find((t) => row.album && normId(t.album) === normId(row.album)) ??
hits[0] ??
null;
hit = await findCatalogTrack(
(query) => api.catalogSearchTracks(query),
{
title: row.title,
artist: row.artist,
duration_secs: row.duration_secs ?? null,
},
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
);
} catch {
// Offline or throttled — let the next page visit retry.
artAttempted.delete(row.id);
@@ -174,6 +202,28 @@
}
}
// A row the walk above could not help: its file was not cached when the
// page opened, so nothing local existed to read. The player extracts the
// cover the moment it streams one — if that row is one of ours, keep it,
// rather than making the row blank again on the next visit.
$effect(() => {
const q = player.queue[player.queueIndex];
const found = q?.art;
const rowId = q?.plItemId;
if (!found || rowId == null || !playlist) return;
const mine = items.find((i) => i.id === rowId);
if (!mine || mine.art_path === found) return;
const plId = playlist.id;
// Writing back into `items` is what this effect READS; untrack keeps
// the assignment from re-entering it, and the equality guard above
// makes a second pass a no-op either way.
untrack(() => {
mine.art_path = found;
artAttempted.add(rowId);
api.playlistRelinkItem(plId, rowId, { artPath: found }).catch(() => {});
});
});
const norm = (s) => (s ?? "").toLowerCase();
const visible = $derived.by(() => {
const q = norm(query.trim());
@@ -340,8 +390,23 @@
delete playlistStore.covers[playlist.id];
await refreshPlaylists();
}
// The two collection actions — stream rows with full identity are the
// material; the runner dedupes namesakes and narrates via playlistBulk.
const bulkableCount = $derived(
items.filter((i) => i.kind === "stream" && i.artist && i.title).length,
);
function addCachedToLibrary() {
menuOpen = false;
void runPlaylistBulk(playlist.id, items, "cached");
}
function downloadAllTracks() {
menuOpen = false;
void runPlaylistBulk(playlist.id, items, "download");
}
async function exportAs(format) {
menuOpen = false;
exportOpen = false;
try {
await api.playlistExport(playlist.id, format);
} catch (e) {
@@ -670,6 +735,7 @@
onclick={(e) => {
e.stopPropagation();
confirmDelete = false;
exportOpen = false;
menuOpen = !menuOpen;
}}
>
@@ -698,24 +764,51 @@
<button class="mi" onclick={removeUnresolved}>
<Icon name="x" size={15} /> Remove unresolved entries
</button>
<div class="rule"></div>
{/if}
<button class="mi" onclick={() => exportAs("m3u8")}>
<Icon name="export" size={15} /> Export M3U8
<div class="rule"></div>
<button
class="mi"
onclick={addCachedToLibrary}
disabled={playlistBulk.active || bulkableCount === 0}
title="File every already-streamed track into the library — nothing is downloaded"
>
<Icon name="library" size={15} /> Add to library
</button>
<button class="mi" onclick={() => exportAs("xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs("pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs("csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs("json")}>
<Icon name="export" size={15} /> Export backup (JSON)
<button
class="mi"
onclick={downloadAllTracks}
disabled={playlistBulk.active || bulkableCount === 0}
title="Find and download every track that isn't in the library yet"
>
<Icon name="download" size={15} /> Download all tracks
</button>
<div class="rule"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sub-wrap"
onmouseenter={() => (exportOpen = true)}
onmouseleave={() => (exportOpen = false)}
>
<button
class="mi"
onclick={() => (exportOpen = !exportOpen)}
aria-haspopup="menu"
aria-expanded={exportOpen}
>
<Icon name="export" size={15} /> Export
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
</button>
{#if exportOpen}
<div class="menu sub" role="menu">
<button class="mi" onclick={() => exportAs("m3u8")}>M3U8</button>
<button class="mi" onclick={() => exportAs("xspf")}>XSPF</button>
<button class="mi" onclick={() => exportAs("pls")}>PLS</button>
<button class="mi" onclick={() => exportAs("csv")}>CSV</button>
<button class="mi" onclick={() => exportAs("json")}>Backup (JSON)</button>
</div>
{/if}
</div>
<div class="rule"></div>
<button class="mi danger" onclick={del}>
<Icon name="trash" size={15} />
{confirmDelete ? "Really delete?" : "Delete playlist"}
@@ -737,6 +830,31 @@
{/if}
</div>
</div>
{#if playlistBulk.playlistId === playlist.id && (playlistBulk.active || playlistBulk.summary)}
<div class="bulkline">
{#if playlistBulk.active}
<span class="ring"></span>
<span>
{playlistBulk.mode === "cached"
? "Adding streamed tracks to the library"
: "Downloading tracks"}
— {playlistBulk.done}/{playlistBulk.total}
{#if playlistBulk.organized}&thinsp;·&thinsp;{playlistBulk.organized} saved{/if}
{#if playlistBulk.queued}&thinsp;·&thinsp;{playlistBulk.queued} downloading{/if}
{#if playlistBulk.missed}&thinsp;·&thinsp;{playlistBulk.missed} missed{/if}
</span>
<button class="bulkx" title="Stop" onclick={cancelPlaylistBulk}>
<Icon name="x" size={13} />
</button>
{:else}
<Icon name="check" size={13} />
<span>{playlistBulk.summary}</span>
<button class="bulkx" title="Dismiss" onclick={dismissPlaylistBulk}>
<Icon name="x" size={13} />
</button>
{/if}
</div>
{/if}
</div>
</div>
</header>
@@ -1236,6 +1354,77 @@
.mi.danger:hover {
color: #f15e6c;
}
.sub-wrap {
position: relative;
}
.sub-wrap > .mi {
width: 100%;
}
.sub-arrow {
margin-left: auto;
display: inline-flex;
color: var(--text-tertiary);
}
/* The flyout: the same glass card, berthed beside its row. The bridge
(::before) covers the 6px gap so the pointer can cross without the
mouseleave closing it. */
.menu.sub {
position: absolute;
left: calc(100% + 4px);
top: -6px;
min-width: 130px;
}
.menu.sub::before {
content: "";
position: absolute;
left: -8px;
top: 0;
bottom: 0;
width: 8px;
}
.mi:disabled {
opacity: 0.45;
pointer-events: none;
}
.bulkline {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 7px 12px;
border-radius: 999px;
width: fit-content;
max-width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
}
.bulkline .ring {
width: 12px;
height: 12px;
flex: none;
border-radius: 50%;
border: 2px solid var(--border);
border-top-color: var(--accent);
animation: bulkspin 0.9s linear infinite;
}
@keyframes bulkspin {
to {
transform: rotate(360deg);
}
}
.bulkline .bulkx {
display: inline-flex;
color: var(--text-tertiary);
padding: 2px;
border-radius: 50%;
}
.bulkline .bulkx:hover {
color: var(--text-primary);
background: var(--bg-highlight);
}
.menu .rule {
height: 1px;
background: var(--border);