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:
1 parent
30eb6fe025
commit
50414dae5c
16 files changed
+4157
-154
No files matched your search
+213
-24
@@ -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} · {playlistBulk.organized} saved{/if}
|
||||
{#if playlistBulk.queued} · {playlistBulk.queued} downloading{/if}
|
||||
{#if playlistBulk.missed} · {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);
|
||||
|
||||
Reference in new issue
Block a user