import: records named by their own tags, and covers resampled where the pixels are
Two threads from one session, both of them the same shape: something was being decided from the wrong source. THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers, years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches nothing anywhere; "Mirrored" matches instantly. Across the reference library 501 of 515 records carry a tag album and 315 disagree with their folder, and no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The record's own claim now leads and the folder is the fallback, in one place (src/lib/import-title.js) that apply and the picker both read — apply already resolved it this way, and only the picker did not. The verdict judge had the same split: it compared candidates against the folder too, so an exact answer scored as mere containment and a clean match wore a warning. Identification also stops searching when searching cannot work. A folder named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so after Deezer and MusicBrainz come back empty the artist's own catalogue is ranked by title similarity. Scoring rather than stripping is the point: 1989, 1984 and 2001 are real albums, and any cleaner aggressive enough to rescue Paul's Boutique would have destroyed them. Words split on whitespace with punctuation folded inside them, so "Paul's" stays one token; pressing vocabulary is ignored for scoring only; coverage is measured from both sides. 15 assertions pin the cases that would break it. Cached verdicts now carry the engine that produced them. A cached match is good forever, a cached "nothing found" only as good as the thing that looked — and nothing ever re-asked. Negatives from an older engine, or from a query that has since changed, are dropped and asked again. Soulseek was asked the same wrong question, with the artist said twice: "battles battles - mirrored - 2007". The server matches on ALL terms, so every leftover token narrows the search — that copy was only found because the peer happened to have 2007 in its path. The ladder now leads with the record's name and keeps the folder as the narrower rung behind it. AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate above hasFreeSlot, and queue depth — captured on every candidate — was never read at all. A peer with no free slot and 277 people ahead beat a free peer with a queue of two, because its files reported a bitrate and the others reported none, and `?? 0` scores "didn't say" as the worst possible answer. Bitrate is now ignored between two fully-lossless copies, where it describes the material rather than the quality. And nothing ever gave up. Track 1 is requested alone so it is not queued behind eleven of our own files, the rest follow once the peer proves it will serve us, and a copy that makes no progress for 45s is abandoned for the next ranked one — cancelling the transfers it leaves, which used to trickle on forever. A refusal falls through to the next peer instead of ending the record. "Try another peer" is reachable while one is loading, which it never was. RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already filed by comparing the destination's size to its source — but apply itself rewrites that file, embedding the chosen cover, so it is 582 KB larger from the moment it is written and the sizes can never match again. Every run laid down another " (n)" copy with its own library row: three runs left three of everything, 2.16 GB. A file at the destination that this run did not write is now simply an earlier import; within one run a repeated name is still a collision, which is what `kept` is for. ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read art from whichever folder the audio came from, which for a kept found copy is the pen — and a downloaded album often carries no picture, so three records imported with no cover while their own files held a 1500px one. It also only ever looked at tags, never at the Folder.jpg the review had just offered as "yours". COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed 2000px masters to paint 132px tiles — a 15x single-step reduction that reads as aliasing on photographic covers, and carries megabytes of base64 to draw a thumbnail. Artist portraits already had this exact fix; album covers never got it. getArt is now bounded by default and getArtMaster is the deliberate act, because ~60 places paint a cover and only two want real pixels: with the polarity the other way round, every surface anyone forgets is silently wrong. The fullscreen viewer also framed non-square art inside a square element, so its rounded corners and shadow wrapped 96px of nothing; it takes the artwork's own ratio now, bounded on both axes. Smaller, from the same passes: the preview play button is a real transport rather than a restart, and leaving a record silences its own preview without touching music started elsewhere; bucket chips navigate with per-lens memory instead of overwriting the saved resume position; Next counts from the record's real place in a queue that re-sorts under it, which was skipping one record for every one reviewed under a filter; a cover URL with nothing behind it is shown as the empty answer it is rather than a broken image in a pickable frame; write_tags returns early when there is nothing to change, instead of rewriting the whole file to write values it already had; and the import window has a Start over, which says what it costs before it costs it.
This commit is contained in:
1 parent
e7d0971de2
commit
c4ff6269a3
14 files changed
+1326
-138
No files matched your search
@@ -13,12 +13,20 @@
|
||||
import { fade } from "svelte/transition";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { getArt, playLocalFile } from "../lib/state.svelte.js";
|
||||
import { onDestroy } from "svelte";
|
||||
import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js";
|
||||
import { evidence as matchEvidence, normx } from "../lib/import-evidence.js";
|
||||
import { IDENTIFY_ENGINE } from "../lib/import-title.js";
|
||||
|
||||
let {
|
||||
album: a, // derived album from ImportWindow (rel, title, …)
|
||||
artist, // the resolved artist (plan logic, not folder-blind)
|
||||
albumName, // the resolved album NAME — tags first, folder as
|
||||
// fallback. This is what the record IS: the
|
||||
// heading, the verdict, what the providers are
|
||||
// asked, and what the player announces. `a.title`
|
||||
// is the folder on disk and survives as a hover.
|
||||
|
||||
sourceRoot,
|
||||
index, total, // where the queue cursor is
|
||||
found, // rel -> { state, result } identify cache ($state, shared)
|
||||
@@ -31,6 +39,8 @@
|
||||
audition, // the found-copy state for this record (window-owned)
|
||||
live, // in-flight transfer facts + stall verdict, or null
|
||||
onFindCopy, // () => void — search + start the audition download
|
||||
onTryAnother, // () => void — abandon this peer for the next
|
||||
// ranked copy, without a fresh search
|
||||
accentStyle, // this record's --accent pair, cast by the window
|
||||
} = $props();
|
||||
|
||||
@@ -90,11 +100,15 @@
|
||||
found[rel] = { state: "loading", result: null };
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api.importIdentify(artist, a.title);
|
||||
found[rel] = { state: "done", result: r };
|
||||
const ask = albumName ?? a.title;
|
||||
const r = await api.importIdentify(artist, ask);
|
||||
// Stamped with the engine that produced it: a cached "nothing
|
||||
// found" is only trustworthy while the thing that looked has
|
||||
// not learned anything new. See IDENTIFY_ENGINE.
|
||||
found[rel] = { state: "done", result: r, engine: IDENTIFY_ENGINE };
|
||||
if (r.candidate?.cover) {
|
||||
const rep = await api.importCoverReport(
|
||||
artist, r.used ?? a.title, r.candidate.cover);
|
||||
artist, r.used ?? ask, r.candidate.cover);
|
||||
reports[rel] = rep;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -124,12 +138,14 @@
|
||||
() => {});
|
||||
});
|
||||
|
||||
// Local art renders through the same data-URL path every cover uses.
|
||||
// The MASTER, not the tile-sized default: this card exists to judge one
|
||||
// cover against another, and the loupe magnifies it 3x. A resampled copy
|
||||
// would hide exactly the difference being judged.
|
||||
let yoursSrc = $state(null);
|
||||
$effect(() => {
|
||||
const p = yours?.path;
|
||||
yoursSrc = null;
|
||||
if (p) getArt(p).then((u) => { if (yours?.path === p) yoursSrc = u; });
|
||||
if (p) getArtMaster(p).then((u) => { if (yours?.path === p) yoursSrc = u; });
|
||||
});
|
||||
|
||||
// ---- provider covers, as the mock's tiles ---------------------------
|
||||
@@ -182,21 +198,40 @@
|
||||
// for which of two very different situations the panel is in: the report
|
||||
// is still coming, or it came back with nothing. Neither is "no service
|
||||
// has this record" — the identify already proved one does.
|
||||
/** Candidate cover URLs the webview could not load, by url.
|
||||
*
|
||||
* A provider can name a cover it does not have: MusicBrainz identifies a
|
||||
* release group and the cover URL is built from its MBID, but the Cover
|
||||
* Art Archive answers 404 when nothing has been uploaded for it. Only the
|
||||
* <img> knows — the report says "size unknown", which is also what a
|
||||
* measurement that merely timed out says. */
|
||||
let deadArt = $state({});
|
||||
|
||||
/** Every candidate turned out to be a URL with nothing behind it — which
|
||||
* is a different sentence from "the sizes could not be measured": the
|
||||
* service was reached and simply has no picture. */
|
||||
const allDead = $derived(covers.length > 0 && covers.every((c) => deadArt[c.url]));
|
||||
|
||||
const unmeasured = $derived(covers.length === 1 && covers[0].px == null);
|
||||
const measuring = $derived(unmeasured && !report);
|
||||
const measureFailed = $derived(unmeasured && !!report);
|
||||
const measureFailed = $derived(unmeasured && !!report && !allDead);
|
||||
/** A match with no cover at all — a real answer, and not the same as
|
||||
* nobody having heard of the record. */
|
||||
const matchHasNoArt = $derived(
|
||||
idn?.state === "done" && !!candidate && !candidate.cover && !covers.length);
|
||||
|
||||
// ---- evidence (shared with the window's outcome buckets) -------------
|
||||
const ev = $derived(matchEvidence(candidate, artist, a.title, a.tracks.length));
|
||||
// Judged against the record's own NAME, the same string the provider was
|
||||
// asked about — not the folder. Comparing a candidate to the folder made
|
||||
// an exact hit look doubtful: "Mirrored" against
|
||||
// "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean
|
||||
// match wore a "!" on its title row.
|
||||
const ev = $derived(matchEvidence(candidate, artist, albumName ?? a.title, a.tracks.length));
|
||||
|
||||
const verdictLine = $derived.by(() => {
|
||||
const c = candidate;
|
||||
const v = {
|
||||
none: ["warn", "Nothing on the provider matches this folder. It may be a bootleg, a session, or named differently."],
|
||||
none: ["warn", "Nothing on the provider matches this record. It may be a bootleg, a session, or named differently."],
|
||||
bad: ["bad", "<b>This does not look like the same record.</b> Applying it would replace your files with a different album."],
|
||||
warn: ["warn", "<b>Close, but not identical.</b> Check the tracklist and listen before applying — it may be a different edition."],
|
||||
ok: ["ok", "Artist, title and track count all agree. Listen to be certain."],
|
||||
@@ -403,25 +438,53 @@
|
||||
const yoursPath = $derived(
|
||||
a.tracks[0] ? `${sourceRoot}\\${a.rel}\\${a.tracks[0].name}` : null);
|
||||
const resolvedAudio = $derived(choice?.audio ?? "yours");
|
||||
|
||||
// ---- preview transport ----------------------------------------------
|
||||
// A preview plays through the MAIN player (playLocalFile replaces the
|
||||
// queue), so "is this one playing?" is a question about the player's
|
||||
// current local path — not about anything this card owns.
|
||||
const nowPath = $derived(player.track?.source === "local" ? player.track.path : null);
|
||||
const isLive = (p) => !!p && nowPath === p;
|
||||
/** Sounding right now (buffering counts — the button must offer the stop
|
||||
* before the audio arrives, or a slow start is unstoppable). */
|
||||
const isSounding = (p) => isLive(p) && (player.status === "playing" || player.status === "buffering");
|
||||
|
||||
/** One button, both jobs: start this preview, or pause/resume it if it is
|
||||
* already the thing playing. Previously every press re-issued
|
||||
* playLocalFile, which restarted the track — there was no way to stop it. */
|
||||
function togglePreview(path, start) {
|
||||
if (!path) return;
|
||||
if (isLive(path)) togglePlayPause();
|
||||
else start();
|
||||
}
|
||||
function playYours() {
|
||||
if (!yoursPath) return;
|
||||
playLocalFile(
|
||||
yoursPath,
|
||||
a.tracks[0].name.replace(/\.[a-z0-9]+$/i, ""),
|
||||
`${artist} — ${a.title} · yours`,
|
||||
`${artist} — ${albumName ?? a.title} · yours`,
|
||||
yours?.path ?? null,
|
||||
);
|
||||
}
|
||||
function playTheirs() {
|
||||
if (!audition?.track1Path) return;
|
||||
playLocalFile(
|
||||
audition.track1Path,
|
||||
(audition.probe?.title ?? "Track 1"),
|
||||
`${artist} — ${a.title} · found copy`,
|
||||
`${artist} — ${albumName ?? a.title} · found copy`,
|
||||
yours?.path ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
// Leaving the record silences ITS preview. The card is re-keyed per record,
|
||||
// so this fires on every move as well as on closing the window.
|
||||
//
|
||||
// Deliberately narrow: only when what is sounding is one of THIS record's
|
||||
// two files. Music the user started before opening the importer is theirs,
|
||||
// and walking through records must not silence it.
|
||||
onDestroy(() => {
|
||||
const mine = [yoursPath, audition?.track1Path].filter(Boolean);
|
||||
if (mine.includes(nowPath) && player.status === "playing") togglePlayPause();
|
||||
});
|
||||
|
||||
// Real peaks for both lanes, drawn against the JOINT maximum so a
|
||||
// quieter master honestly looks smaller — the shapes are comparable
|
||||
// because the scale is shared (his ask: match the waveforms).
|
||||
@@ -499,7 +562,11 @@
|
||||
<span class="im-out" data-out="later"
|
||||
title="You skipped this one — still in the run, still undecided">skipped for now</span>
|
||||
{/if}</div>
|
||||
<h3>{a.title}</h3>
|
||||
<!-- The RECORD, not the folder it happens to sit in. The folder is the
|
||||
ripper's naming ("[WARP CD 156] Battles - Mirrored - 2007"); the
|
||||
heading says what the record is, and the folder stays one hover
|
||||
away for when you need to know which directory this is. -->
|
||||
<h3 title={a.title === (albumName ?? a.title) ? undefined : `folder: ${a.title}`}>{albumName ?? a.title}</h3>
|
||||
<small>{artist} · {a.tracks.length} tracks · {fmtMB(a.bytes)} · {ext} {a.avgBitrate ?? "—"}k</small>
|
||||
</div>
|
||||
<div class="im-hero-side">
|
||||
@@ -517,6 +584,7 @@
|
||||
<em>{#if !idn || idn.state === "loading"}looking the record up…
|
||||
{:else if idn.state === "error"}the lookup failed
|
||||
{:else if measuring}found it — measuring what each service has…
|
||||
{:else if allDead}found it, but no service has a cover for it
|
||||
{:else if measureFailed}found it, but the sizes could not be measured
|
||||
{:else if matchHasNoArt}the matching release carries no artwork
|
||||
{:else if !covers.length}no service has this record
|
||||
@@ -547,23 +615,42 @@
|
||||
<div class="im-art-pick">{resolvedArt === "yours" ? "✓ using this" : "use this"}</div>
|
||||
</div>
|
||||
{#each covers as c, n (c.url)}
|
||||
{@const dead = !!deadArt[c.url]}
|
||||
{@const smaller = yours && c.px && c.px < yours.w}
|
||||
{@const wrongTitle = c.title && norm2(c.title) !== norm2(a.title)}
|
||||
<!-- A candidate whose picture will not load is shown as the empty
|
||||
answer it is, and cannot be chosen. The Cover Art Archive
|
||||
answers 404 for a release group with no art on file, and the
|
||||
URL is built from the MBID without knowing that — so the tile
|
||||
used to be a broken-image icon inside a frame that still said
|
||||
"use this", and using it silently fell back at apply time. -->
|
||||
<div class="im-art {smaller ? 'worse' : 'better'}"
|
||||
class:picked={resolvedArt === c.provider}
|
||||
role="button" tabindex="0" title="Use {c.provider}'s artwork"
|
||||
onclick={() => onChoose({ art: c.provider })}
|
||||
onkeydown={(e) => e.key === "Enter" && onChoose({ art: c.provider })}>
|
||||
<div class="im-art-box zoom"
|
||||
onclick={(e) => { e.stopPropagation(); openFs(n + 1); }}
|
||||
class:dead
|
||||
role={dead ? "presentation" : "button"} tabindex={dead ? -1 : 0}
|
||||
title={dead
|
||||
? `${c.provider} has no cover on file for this release`
|
||||
: `Use ${c.provider}'s artwork`}
|
||||
onclick={() => !dead && onChoose({ art: c.provider })}
|
||||
onkeydown={(e) => !dead && e.key === "Enter" && onChoose({ art: c.provider })}>
|
||||
<div class="im-art-box" class:zoom={!dead}
|
||||
onclick={(e) => { if (!dead) { e.stopPropagation(); openFs(n + 1); } }}
|
||||
role="presentation">
|
||||
<img src={c.url} alt="" /></div>
|
||||
<div class="im-art-lab"><span class="im-art-res" class:im-dim={!c.px}>{c.px
|
||||
? `${c.px} × ${c.realH ?? c.px}`
|
||||
: measuring ? "measuring…" : "size unknown"}</span>
|
||||
<i>{c.provider}{smaller ? " · smaller than yours" : ""}</i></div>
|
||||
{#if dead}
|
||||
<div class="im-art-none">nothing on file here —<br />{c.provider} has no cover for this release</div>
|
||||
{:else}
|
||||
<img src={c.url} alt="" onerror={() => (deadArt[c.url] = true)} />
|
||||
{/if}</div>
|
||||
<div class="im-art-lab"><span class="im-art-res" class:im-dim={!c.px || dead}>{dead
|
||||
? "no cover"
|
||||
: c.px
|
||||
? `${c.px} × ${c.realH ?? c.px}`
|
||||
: measuring ? "measuring…" : "size unknown"}</span>
|
||||
<i>{c.provider}{smaller && !dead ? " · smaller than yours" : ""}</i></div>
|
||||
{#if wrongTitle}<div class="im-art-lab"><i class="im-amber">“{c.title}”</i></div>{/if}
|
||||
<div class="im-art-pick">{resolvedArt === c.provider ? "✓ using this" : "use this"}</div>
|
||||
<div class="im-art-pick">{dead
|
||||
? "nothing to use"
|
||||
: resolvedArt === c.provider ? "✓ using this" : "use this"}</div>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Only once the lookup has actually SETTLED with nothing. While it
|
||||
@@ -611,7 +698,7 @@
|
||||
? "the transfer went quiet"
|
||||
: "found a copy — fetching track 1 first, so the comparison can start"}
|
||||
{:else if audition.status === "complete"}the whole copy is cached — keeping it is instant
|
||||
{:else}{audition.arrived ?? 1} of {audition.ids?.length ?? "…"} tracks cached · the rest keep arriving
|
||||
{:else}{audition.arrived ?? 1} of {audition.expected ?? audition.ids?.length ?? "…"} tracks cached · the rest keep arriving
|
||||
{/if}</em>
|
||||
<span class="im-card-hint">the lit version is what gets imported</span></div>
|
||||
|
||||
@@ -619,9 +706,10 @@
|
||||
<div class="im-ver" class:picked={true} role="button" tabindex="0"
|
||||
onclick={() => onChoose({ audio: "yours" })}
|
||||
onkeydown={(e) => e.key === "Enter" && onChoose({ audio: "yours" })}>
|
||||
<button class="im-play" disabled={!yoursPath} title="Play your copy"
|
||||
onclick={(e) => { e.stopPropagation(); playYours(); }}>
|
||||
<Icon name="play" size={13} />
|
||||
<button class="im-play" disabled={!yoursPath}
|
||||
title={isSounding(yoursPath) ? "Pause" : "Play your copy"}
|
||||
onclick={(e) => { e.stopPropagation(); togglePreview(yoursPath, playYours); }}>
|
||||
<Icon name={isSounding(yoursPath) ? "pause" : "play"} size={13} />
|
||||
</button>
|
||||
<div class="im-ablab"><b>Yours</b><i>{ext} {a.avgBitrate ?? "—"}k · {a.tracks.length} tracks · {fmtMB(a.bytes)}</i></div>
|
||||
<div class="im-wave">{#each (peaksYours ?? waveFloor().map((b) => 0)) as v, i (i)}<i style:height={peaksYours ? waveScale(v) : "2px"} style:--fill="0" style:--i={i}></i>{/each}</div>
|
||||
@@ -652,9 +740,10 @@
|
||||
title="Keep your own files"
|
||||
onclick={() => onChoose({ audio: "yours" })}
|
||||
onkeydown={(e) => e.key === "Enter" && onChoose({ audio: "yours" })}>
|
||||
<button class="im-play" disabled={!yoursPath} title="Play your copy"
|
||||
onclick={(e) => { e.stopPropagation(); playYours(); }}>
|
||||
<Icon name="play" size={13} />
|
||||
<button class="im-play" disabled={!yoursPath}
|
||||
title={isSounding(yoursPath) ? "Pause" : "Play your copy"}
|
||||
onclick={(e) => { e.stopPropagation(); togglePreview(yoursPath, playYours); }}>
|
||||
<Icon name={isSounding(yoursPath) ? "pause" : "play"} size={13} />
|
||||
</button>
|
||||
<div class="im-ablab"><b>Yours</b><i>{ext} {a.avgBitrate ?? "—"}k · {a.tracks.length} tracks · {fmtMB(a.bytes)}</i></div>
|
||||
<div class="im-wave">{#each (peaksYours ?? []) as v, i (i)}<i style:height={waveScale(v)} style:--fill="0" style:--i={i}></i>{/each}</div>
|
||||
@@ -672,12 +761,20 @@
|
||||
onclick={() => theirsReady && onChoose({ audio: "theirs" })}
|
||||
onkeydown={(e) => e.key === "Enter" && theirsReady && onChoose({ audio: "theirs" })}>
|
||||
<button class="im-play" disabled={!theirsReady}
|
||||
title={theirsReady ? "Play the found copy" : "Still arriving"}
|
||||
onclick={(e) => { e.stopPropagation(); playTheirs(); }}>
|
||||
<Icon name="play" size={13} />
|
||||
title={!theirsReady
|
||||
? "Still arriving"
|
||||
: isSounding(au.track1Path) ? "Pause" : "Play the found copy"}
|
||||
onclick={(e) => { e.stopPropagation(); togglePreview(au.track1Path, playTheirs); }}>
|
||||
<Icon name={isSounding(au.track1Path) ? "pause" : "play"} size={13} />
|
||||
</button>
|
||||
<div class="im-ablab">
|
||||
<b>{au.candidate.folderLeaf}</b>
|
||||
<!-- "Found copy", to match "Yours" on the lane above. This
|
||||
used to lead with the PEER's folder name ("[WARP 156CD]
|
||||
Battles …"), which is the same rip-descriptor noise the
|
||||
heading carried — and in a 150px label it truncated to
|
||||
nothing useful anyway. Which peer and which folder is what
|
||||
the sub-line and the hover are for. -->
|
||||
<div class="im-ablab" title={`${au.candidate.folderLeaf} · ${au.candidate.username}`}>
|
||||
<b>Found copy</b>
|
||||
<i>{au.probe
|
||||
? `${(au.candidate.files[0]?.name ?? "").split(".").pop().toUpperCase()} ${au.probe.bitrate ?? "—"}k${au.probe.bitDepth ? ` · ${au.probe.bitDepth}-bit` : ""} · measured`
|
||||
: theirsReady
|
||||
@@ -707,16 +804,18 @@
|
||||
nothing used to say so — the pill changed and that was all. -->
|
||||
{#if !theirsReady && live?.stalled}
|
||||
<b class="im-amber">Nothing has arrived for {Math.round(live.quietMs / 1000)}s.</b>
|
||||
The peer may have gone offline or dropped the queue. You can wait, search
|
||||
again, or keep your own copy — nothing is lost either way.
|
||||
The peer may have gone offline or dropped the queue. You can wait, try
|
||||
another peer, or keep your own copy — nothing is lost either way.
|
||||
{:else if !theirsReady}
|
||||
Track 1 comes first on purpose: the comparison can start before the rest of
|
||||
the album is down.
|
||||
Track 1 is asked for on its own, so it is not queued behind the other
|
||||
{au.candidate.trackCount - 1} files of its own album.{#if au.switchedFrom}
|
||||
· Moved on from <b>{au.switchedFrom}</b>{au.reason ? ` — ${au.reason}` : ""}.
|
||||
{/if}
|
||||
{:else if au.probe?.seconds && a.tracks[0]?.seconds}
|
||||
{@const d = Math.abs(a.tracks[0].seconds - au.probe.seconds)}
|
||||
{#if d <= 2}
|
||||
<b class="im-ready">Ready to compare.</b>
|
||||
Track 1 is the same length in both ({mmss(au.probe.seconds)}) — the strongest signal it is the same recording. The waveforms share one scale, so shape and loudness compare honestly.{#if au.status !== "complete"} The other {Math.max(0, (au.ids?.length ?? 0) - (au.arrived ?? 0))} tracks are still coming; keeping this version waits for them.{/if}
|
||||
Track 1 is the same length in both ({mmss(au.probe.seconds)}) — the strongest signal it is the same recording. The waveforms share one scale, so shape and loudness compare honestly.{#if au.status !== "complete"} The other {Math.max(0, (au.expected ?? au.ids?.length ?? 0) - (au.arrived ?? 0))} tracks are still coming; keeping this version waits for them.{/if}
|
||||
{:else}
|
||||
<b class="im-amber">Track 1 differs by {d}s</b> — different recording, different edit, or a different record. Listen before keeping it.
|
||||
{/if}
|
||||
@@ -726,6 +825,22 @@
|
||||
The whole album downloads into the cache — if you keep it, nothing is fetched twice; if you keep yours, the sweep reclaims it in a week.
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Reachable WHILE a peer is loading. There was no way out of a bad
|
||||
peer once one was chosen: the only "Find a better copy" button
|
||||
lives in the no-audition state, so a queue of 277 could only be
|
||||
waited out. Alternates are already ranked and held, so switching
|
||||
costs no new search. -->
|
||||
{#if !theirsReady}
|
||||
{@const alts = au.alts?.length ?? 0}
|
||||
<div class="im-ab-find">
|
||||
<button class="pill ghost" onclick={alts ? onTryAnother : onFindCopy}>
|
||||
{alts ? "Try another peer" : "Search again"}
|
||||
</button>
|
||||
<small>{alts
|
||||
? `${alts} more ${alts === 1 ? "copy" : "copies"} ranked behind this one${au.candidate.queue ? ` — this peer has ${au.candidate.queue} ahead of you` : ""}. Switching cancels this download; nothing already cached is lost.`
|
||||
: "No ranked alternates left for this copy — this asks the network again."}</small>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -916,7 +1031,7 @@
|
||||
vs {right.provider} {right.px} × {right.realH ?? right.px}
|
||||
</div>
|
||||
<div class="im-fs-bot">
|
||||
{#each covers as c, n (c.url)}
|
||||
{#each covers.filter((c) => !deadArt[c.url]) as c, n (c.url)}
|
||||
<div class="im-fs-pick" role="button" tabindex="0"
|
||||
aria-selected={fsPick === n + 1}
|
||||
onclick={() => (fsPick = n + 1)}
|
||||
@@ -1028,6 +1143,12 @@
|
||||
.im-art-res { font-variant-numeric: tabular-nums; }
|
||||
.im-art.better .im-art-res { color: var(--accent-hover); }
|
||||
.im-art.worse .im-art-res { color: #e9b949; }
|
||||
/* Not an option, so it does not look like one: no pointer, no hover lift,
|
||||
and the size line stays muted rather than reading as a measurement. */
|
||||
.im-art.dead { cursor: default; }
|
||||
.im-art.dead:hover { background: none; }
|
||||
.im-art.dead .im-art-res,
|
||||
.im-art.dead .im-art-pick { color: var(--text-muted); }
|
||||
.im-art-none { display: grid; place-items: center; height: 100%; color: var(--text-muted); font-size: 12px; text-align: center; padding: 12px; }
|
||||
|
||||
.im-ev { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
|
||||
Reference in new issue
Block a user