Compare commits

...
Author SHA1 Message Date
bvandeusen 165c87047e Merge pull request 'Duplicates and video rips resolve themselves (M498)' (#155) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m15s
release / android (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 22:30:10 -04:00
bvandeusenandClaude Opus 5.5 d59b7fdc2a fix(test): the mix-writer test reaches oneCopyPerSong through export_test; suspect fixtures carry the verdict (M498 #5439)
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:17:17 -04:00
bvandeusenandClaude Opus 5.5 53eb954c86 feat: video rips and stray copies handle themselves (M498 #5439)
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 25s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
- The resolver also merges cross-release and mismatch groups where Lidarr
  maps exactly one copy: the others fulfil nothing, so removing them opens no
  hole (D-a rule 1). That covers a rip beside the clean copy on another
  release and a wrong-file import Lidarr holds unmapped. With two or more
  mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
  and the system mixes (tracks.source_verdict, migration 0077). It still plays
  when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
  and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.

Replacing a rip that has no clean copy is left for the operator to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:13:37 -04:00
bvandeusenandClaude Opus 5.5 5b372f61d7 feat: the same song on several releases counts as one song (M498 #5438)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m7s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m11s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m58s
release / Build signed APK (releases and dev) (push) Canceled after 5m59s
A single and the album it is on stay two files, since each fulfils its own
release in Lidarr, but they are one song to the listener.

- tracks.song_id links copies; the generated song_key (song_id, else the
  track's own id) is what they share (migration 0076).
- The resolver links each cross-release group every pass (idempotent; only
  with auto-resolve on) and, when a link is new, shares existing likes across
  the song and logs them for sync.
- A like or unlike (web and Subsonic) reaches every copy; each change is
  logged and published so clients update every heart.
- The Liked list and its count show the song once; Shuffle and the mix writer
  take one copy per song.
- A merge keeps the removed copy's song link; "Not the same song" on the
  Across releases tab dismisses the group and undoes the link.

Shared plays ("heard via another copy") are left for a later step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:07:55 -04:00
bvandeusenandClaude Opus 5.5 4ecff52f19 feat: duplicates resolve themselves where Lidarr says it is safe (M498)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.

Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
  mismatch, review), resolve_note, resolved_automatically;
  duplicate_group_members.lidarr_state (tracked, unmapped);
  fingerprint_settings.auto_resolve; notification kind
  duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
  credits, remaster notes and video-rip markers, and keeping live, demo,
  remix and instrumental. The rip markers move from api to library.

Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
  clash-free track number, no rip marker in the name, an MBID), then the
  quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.

Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
  last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
  The merge is guarded, so a mapped copy can never be removed
  (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
  twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
  that lists each song once and best covers what is on disk. It never
  picks one covering less, and is capped at 10 albums per pass.
  - Fixed point (lesson #4183): the chosen release no longer repeats.
  - The album is left alone for 24h while Lidarr rescans, so "every copy
    unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
  operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
  lidarr_unavailable when Lidarr cannot say.

Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
  automatically. Each loads as you scroll (rule 172), replacing the
  pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
  the copy Lidarr uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:49:37 -04:00
bvandeusenandClaude Opus 5.5 01e2294471 ci: probe and relax the integration Postgres over TCP, with retries (#5426)
release / web (push) Successful in 1m34s
release / govulncheck (push) Successful in 24s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 5m21s
release / android (push) Successful in 5m33s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
On first boot the postgres image runs initdb under a temporary server that
listens on the unix socket only, then stops it. The socket readiness probe
could catch that server, and the fsync relax then hit 'the database system
is shutting down', leaving the suite ~15x slower on internal/api. Only the
real server listens on TCP, so probe and relax via -h 127.0.0.1, retry the
relax, and print the settings that took effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:18:49 -04:00
bvandeusen 05bc85f2b8 Merge dev: scroll-loaded lists, Liked tabs, missing files kept out of the queue (#5416, #5417, #5418)
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m4s
release / go (push) Successful in 1m21s
release / integration (push) Successful in 3m56s
release / android (push) Successful in 4m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 20:08:04 -04:00
bvandeusenandClaude Opus 5.5 bf0bc45c4b fix(web): filter a refetched mix inside the self-heal step, not by wrapping the closure (#5418)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m42s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / integration (push) Successful in 17m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 22s
release / Verify release artifacts (tag releases only) (push) Skipped
The wrapper added a promise hop, so the self-heal test's single
microtask wait saw 'loading' instead of 'error'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:33:10 -04:00
bvandeusenandClaude Opus 5.5 8bd2f66416 fix(web): missing files show greyed in track lists and never enter the queue (#5418)
release / web (push) Failing after 1m23s
release / govulncheck (push) Successful in 25s
release / go (push) Successful in 1m35s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m34s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
TrackRef gains the unavailable flag the server already sends on album
detail and search. TrackRow greys such a row with '· File missing' and
hides like, radio, add and the menu, as playlist rows already do. Every
player-store entry point (playQueue, enqueue, play next, and a refetched
mix) drops missing files; playQueue keeps the chosen track as the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:28:36 -04:00
bvandeusenandClaude Opus 5.5 015fe1d3b2 fix(web): admin error lines use text-error-fg, not the untokened text-oxblood (#5417)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m42s
release / go (push) Successful in 1m52s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m28s
release / Verify release artifacts (tag releases only) (push) Skipped
text-oxblood is not a theme token, so Tailwind emits nothing and the scan
error on Admin → Overview and "It stopped early" on Duplicates rendered as
body text. Same fix as the album page in #5392; no uses remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:17:34 -04:00
bvandeusenandClaude Opus 5.5 0f9ddbca97 feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s
Preference 172: no "Load more" buttons. Eight remained, on Liked (three
sections), the three Search overflow pages, Genres and Years.

- ListContinuation: the one bottom-of-list block. It loads the next page
  ahead of the reader, announces "Loading more…" through aria-live, shows an
  optional end line, and on a failed page shows the error with Try again,
  dropping the sentinel so a dead endpoint isn't re-hit on every scroll.
  Used on all ten paged lists, including the four that already autoloaded.
- A failed next page no longer replaces the list. TanStack sets isError for
  it, so the page-level error branches now apply only to a failed first
  load. Genres and Years did the same with their own loader; a later
  page's failure now keeps the grid.
- Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a
  long Artists list loading as you scroll would bury the other two. It
  opens on the first tab that has likes.
- TabStrip: the in-page tab strip, now shared by Liked, Playback errors
  and Requests.
- test-utils/intersectionObserver: a stand-in so tests can scroll to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:55:33 -04:00
bvandeusen d1fe3758d5 Merge dev: Suspect sources report (#5410)
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m28s
release / govulncheck (push) Successful in 14s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m29s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 16:03:53 -04:00
bvandeusenandClaude Opus 5.5 2205b2927b test: fix the Suspect sources tests CI rejected (#5410)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m11s
release / Verify release artifacts (tag releases only) (push) Skipped
- staticcheck QF1001: the nil-vs-empty check in TestSourceMarkersFor is
  now an early continue instead of a negated conjunction.
- The page test mocks #lib/api/admin.js whole, so suspectSourcesNextOffset
  was undefined there. Its test moves to admin.suspect-sources.test.ts,
  beside admin.scan.test.ts, against the real module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:18 -04:00
bvandeusenandClaude Opus 5.5 3f0540cb7a feat: admin Suspect sources report, for files named like video rips (#5410)
release / govulncheck (push) Successful in 37s
release / go (push) Failing after 1m10s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / web (push) Failing after 1m24s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m5s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 5m18s
Humanz turned out to be YouTube rips: "(Official Video)", "Visualizer", a
reaction video filed as a song, junk disc numbers (#5401). The library holds
about 200 more files named the same way. This report lists them, grouped by
folder like Missing files, with the markers each file name carries.

- GET /api/admin/library/suspect-sources: one marker list in Go builds both
  the Postgres ~* filter and each row's labels, so they cannot drift.
  Basename only; missing files are left out.
- Markers calibrated on the live library: "live in/at" dropped (real live
  albums), "reaction" narrowed (it caught "Chain Reaction").
- Admin tab "Suspect sources", read-only, loads as you scroll; a folder split
  across pages is joined back into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:51:01 -04:00
bvandeusen 664b129947 Merge pull request 'One track-list shape across the web app, drag reorder that snaps (M494)' (#152) from dev into main
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m36s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 27s
release / integration (push) Successful in 4m20s
release / android (push) Successful in 4m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 15:08:09 -04:00
bvandeusenandClaude Opus 5.5 918db27fec feat(web): shared collection header, Play and Shuffle, and the centred page column (M494)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m51s
release / android (push) Successful in 6m53s
release / Build signed APK (releases and dev) (push) Successful in 7m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
  album and playlist. The playlist page gains Play and Shuffle; it had
  neither. The cover uses the shared Cover component, so the playlist
  header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
  Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
  The artist page's round icon button said Play but shuffled; Play now
  keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
  playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
  converter, in place of its own copy. Its copy skipped only removed
  tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
  copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
  not a colour in the theme, so it never rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 aaa6fa15d6 test(web): import history components after their mocks (M494)
HistoryRow now renders LikeButton, and both history test files imported
the component before the likes mock helper. LikeButton then loaded while
emptyLikesMock was still uninitialised, and the suites failed at import.
Every other suite imports components after its mocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 b7f50c42a9 feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393)
release / govulncheck (push) Successful in 34s
release / go (push) Successful in 1m37s
release / web (push) Failing after 1m20s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s
Every track list (album, Liked, Search, Search → Tracks, playlist,
History) now draws its rows with TrackListRow inside a TrackList box,
in place of four row components that had drifted apart:

- TrackListRow owns the look: lead column, title over "artist · album",
  like, radio, add to queue, menu, duration, then a trailing slot. It
  also owns the stripe, hover, the now-playing band and the
  unavailable state. The stripe is dropped on now-playing and selected
  rows, where Tailwind would otherwise let `odd:` win.
- TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only
  their behaviour: track number and multi-select; grip, remove and
  dead-entry states; cover and timestamp. Playlist and History rows
  gain radio, add and the menu, so every list offers the same actions.
- The radio control is Lucide's Radio icon, not the 📻 emoji.
- Enter on a control inside a row now belongs to that control. The old
  TrackRow played the track when Enter was pressed on "add to queue".
- TrackList has no fill, so the stripe shows on playlists too.

Drag reorder (#5393): neodrag kept the drop offset, so after the list
re-rendered the dragged row sat between two slots. reorderDrag, shared
by the playlist and queue rows:
- snaps the row to whole rows while dragging;
- resets it to its slot on drop;
- on playlists, gives the touch gesture to the grip only, so the list
  still scrolls by touch.
The playlist page reorders optimistically and rolls back on error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:06 -04:00
bvandeusenandClaude Opus 5.5 e16d182417 fix(web): drop the shelf-header accent rule Tailwind 4 started drawing (#5021)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m47s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The rule after each shelf title used `after:bg-accent/60`. Tailwind 3
cannot put an opacity modifier on a `var()` colour, so it emitted nothing
for that class and the rule never showed. Tailwind 4 mixes the opacity
in with color-mix, so the rule appeared after the upgrade. Removed, so
the headers look as they did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:17:15 -04:00
bvandeusen c9fa18ecb7 Merge pull request 'Notifications inbox (M489), Discover taste-arm fix, web tooling majors' (#151) from dev into main
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m8s
release / Build signed APK (releases and dev) (push) Skipped
release / go (push) Successful in 1m27s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m34s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 11:11:08 -04:00
bvandeusenandClaude Opus 5.5 ca9917d8cd chore(web): clear kit 3's alias and Vite's config-loader deprecations (#5021)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m49s
release / android (push) Successful in 5m32s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
CI run 8789 warned on both:

- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
  `#test-utils/*` subpath import, matching #lib, and its 43 import sites
  move with it.
- Vite's coming native config loader needs the extension on
  vitest.config.ts's import of vite.config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:03:38 -04:00
bvandeusenandClaude Opus 5.5 b1b10b0c77 fix(web): run Tailwind 4 through @tailwindcss/vite, not PostCSS (#5021)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 56s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
The image build on fecd030b failed in `vite build`:

  [postcss] ENOENT: no such file or directory, open '/web/tailwindcss'

With a PostCSS config present, Vite's own @import inliner resolves
`@import 'tailwindcss'` before @tailwindcss/postcss sees it, and reads it
as a relative file. svelte-check and Vitest never build CSS, so only the
image job caught it. The Vite plugin is Tailwind's documented setup for
Vite projects and handles the import itself, so postcss.config.cjs and
the direct postcss and @tailwindcss/postcss dependencies go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:55:48 -04:00
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00
bvandeusen beba5b2082 Merge remote-tracking branch 'origin/renovate/sveltejs-kit-3.x' into dev 2026-10-08 10:49:44 -04:00
bvandeusenandClaude Opus 5.5 fecd030b68 chore(deps): Tailwind 4 (#5021)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 6m54s
release / Build signed APK (releases and dev) (push) Successful in 7m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Failing after 34s
release / Verify release artifacts (tag releases only) (push) Skipped
Renovate's tailwindcss bump (PR #150) plus the migration it needs:

- Theme moves from tailwind.config.js into app.css as `@theme inline`,
  mapping the same FabledSword tokens. tailwind.config.js is gone.
- PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since
  Tailwind 4 prefixes through Lightning CSS.
- Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none
  -> outline-hidden, focus-visible:outline -> outline-solid, shadow ->
  shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare
  `rounded` stays: v4 keeps it at 0.25rem, as before.
- Three v3 preflight defaults kept in a base layer so nothing changes on
  screen: gray-200 default border colour, gray-400 placeholder text and
  the pointer cursor on buttons.
- The unused class-based dark variant is not carried over; no template
  uses `dark:`.

Clears the five high and two moderate npm audit findings that came in
through Tailwind 3 (braces, chokidar, micromatch, fast-glob,
postcss-selector-parser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:07 -04:00
Renovate Bot 0202c86338 chore(deps): update dependency tailwindcss to v4
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-10-08 14:45:03 +00:00
Renovate Bot c83933b61a chore(deps): update dependency @sveltejs/adapter-static to v4
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:00 +00:00
bvandeusen 3077ed75fe Merge pull request 'Readable accent text app-wide; Android queue drag math under test' (#144) from dev into main
release / govulncheck (push) Successful in 18s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m2s
release / android (push) Successful in 4m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 06:29:48 -04:00
bvandeusen 97e84a79eb Merge pull request 'Multi-value genres for FLAC/Ogg/MP4, readable tinted and error text, no missing-track seeds' (#143) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m41s
release / android (push) Successful in 4m44s
release / integration (push) Successful in 17m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 23:53:09 -04:00
bvandeusen a29ceaaeab Merge pull request 'Similarity cache and round-robin artist arms (#5296, #5297)' (#142) from dev into main
release / android (push) Successful in 5m1s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / go (push) Successful in 2m28s
release / web (push) Successful in 57s
release / govulncheck (push) Successful in 19s
release / Build signed APK (releases and dev) (push) Skipped
release / integration (push) Successful in 5m9s
2026-10-07 20:02:59 -04:00
bvandeusen 54b2720d76 Merge pull request 'Fold stale missing tracks into their on-disk replacement (M485)' (#141) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m2s
release / go (push) Successful in 1m20s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
release / integration (push) Successful in 3m51s
release / android (push) Successful in 4m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
2026-10-07 18:17:51 -04:00
bvandeusen fe142e3451 Merge dev: request completion fix, x/text bump, full-logo tab icon (#140)
release / govulncheck (push) Successful in 22s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m54s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 15:35:01 -04:00
bvandeusen 3bdfa94f39 Merge dev: M483 release-group ids for Lidarr (#139)
release / integration (push) Successful in 3m46s
release / android (push) Successful in 4m24s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m1s
release / go (push) Successful in 1m21s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 12:29:12 -04:00
bvandeusen 22ea27efff Merge pull request 'Lidarr: album adds that Lidarr accepts, artist monitoring that sticks' (#138) from dev into main
release / govulncheck (push) Successful in 27s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 4m24s
release / android (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
2026-10-07 11:08:18 -04:00
bvandeusen 09bd71629a Merge pull request 'M401: AcoustID recording-id lookup' (#137) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m24s
release / integration (push) Successful in 4m3s
release / android (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 09:50:11 -04:00
bvandeusen c83670d216 Merge pull request 'M464 loudness leveling (steps 4–8), APK distribution and security baseline adoptions' (#136) from dev into main
release / web (push) Successful in 1m41s
release / go (push) Successful in 2m0s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 21s
release / integration (push) Successful in 6m6s
release / android (push) Successful in 7m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-06 23:11:35 -04:00
bvandeusen 1e9aed214b Merge pull request 'M462 security hardening and M464 loudness analysis (steps 1–3)' (#135) from dev into main
release / go (push) Successful in 2m19s
release / web (push) Successful in 1m43s
release / govulncheck (push) Successful in 35s
release / Build signed APK (releases and dev) (push) Skipped
release / android (push) Successful in 6m10s
release / integration (push) Successful in 19m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m24s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-06 14:19:58 -04:00
bvandeusen 3638d1d822 Merge pull request 'M400: acoustic duplicate detection, history-preserving merge, and fingerprinting settings' (#134) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 1m32s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m46s
test-go / integration (push) Successful in 4m19s
test-web / test (push) Successful in 33s
2026-09-11 20:56:55 -04:00
bvandeusen 18618bd135 Backfill fingerprints for the existing library (M400 #3908) (#133)
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m11s
test-go / integration (push) Successful in 3m16s
2026-09-11 15:34:19 -04:00
bvandeusen d2985f3841 Acoustic fingerprints at ingest, a delete that can't lose history, and a reproducible candidate draw (#132)
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 20s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m0s
test-go / test (push) Successful in 1m20s
test-go / integration (push) Successful in 3m35s
android / Build + lint + test (push) Successful in 4m48s
2026-09-11 14:43:00 -04:00
bvandeusen f70df9f827 Recommendation relevance, the rollback unit, and a version that names what shipped (#131)
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m33s
test-go / integration (push) Successful in 4m25s
android / Build + lint + test (push) Successful in 5m33s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 23:37:57 -04:00
bvandeusen 439c8625d5 Fix the rebundle step: a non-matching grep must not kill it (#130)
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 3m1s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 10:49:40 -04:00
bvandeusen 1d67c160b2 Versioning rework, a dev channel, and the miniplayer gap (#129)
release / Build signed APK (releases and dev) (push) Skipped
android / Build + lint + test (push) Failing after 4s
release / Build + push container image (push) Failing after 5s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m24s
test-go / integration (push) Successful in 6m25s
2026-09-10 08:33:26 -04:00
bvandeusen 237380b122 New brand mark, and both clients stop fetching their fonts at runtime (#128)
test-web / test (push) Successful in 54s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m36s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-09 14:36:15 -04:00
bvandeusen 4f077736b6 Merge PR #127: Sonos queue verification, cast double-download fix, stutter instrumentation
android / Build + lint + test (push) Successful in 4m16s
release / Build signed APK (tag releases only) (push) Successful in 5m25s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Successful in 1s
2026-08-18 10:50:10 -04:00
bvandeusen 727f68950e Merge PR #126: missing-file lifecycle, UPnP stall recovery, Android browse parity, Flutter client removed
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
2026-08-17 16:28:13 -04:00
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusen 324059b2bd Merge pull request 'Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268)' (#116) from dev into main
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m30s
android / Build + lint + test (push) Successful in 5m1s
test-go / integration (push) Successful in 5m29s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 17s
2026-08-03 08:38:24 -04:00
bvandeusen 1138d75a45 Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m33s
android / Build + lint + test (push) Successful in 4m30s
2026-08-01 12:23:37 -04:00
279 changed files with 8660 additions and 3146 deletions
+34 -14
View File
@@ -239,9 +239,16 @@ jobs:
# container itself: the run: shell is dash (rule 81), where the # container itself: the run: shell is dash (rule 81), where the
# old `/dev/tcp` probe never connects and the loop silently # old `/dev/tcp` probe never connects and the loop silently
# burned its full two minutes on every run. # burned its full two minutes on every run.
#
# Over TCP (-h 127.0.0.1), not the unix socket (#5426). On first
# boot the image's entrypoint runs initdb under a temporary server
# that listens on the socket only, then stops it and starts the
# real one. A socket probe can catch the temporary server, and the
# next step then meets "the database system is shutting down".
# Only the real server listens on TCP.
ready="" ready=""
for i in $(seq 1 60); do for i in $(seq 1 60); do
if docker exec "$PG_ID" pg_isready -U minstrel -d minstrel_test -q; then ready=1; break; fi if docker exec "$PG_ID" pg_isready -h 127.0.0.1 -U minstrel -d minstrel_test -q; then ready=1; break; fi
sleep 2 sleep 2
done done
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; } test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
@@ -259,13 +266,25 @@ jobs:
# - fsync / full_page_writes are sighup GUCs and # - fsync / full_page_writes are sighup GUCs and
# synchronous_commit is user-context, so pg_reload_conf() picks # synchronous_commit is user-context, so pg_reload_conf() picks
# all three up with no restart. # all three up with no restart.
# Non-fatal: a perms surprise degrades to "slower", never red CI. # Non-fatal: a failure degrades to "slower", never red CI. But
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \ # slower is ~15x on internal/api (#5426), so retry a few times and
-c "ALTER SYSTEM SET fsync = off" \ # print what took effect.
-c "ALTER SYSTEM SET synchronous_commit = off" \ relaxed=""
-c "ALTER SYSTEM SET full_page_writes = off" \ for i in 1 2 3 4 5; do
-c "SELECT pg_reload_conf()" \ if docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test \
|| echo "WARN: durability relax failed; continuing" -c "ALTER SYSTEM SET fsync = off" \
-c "ALTER SYSTEM SET synchronous_commit = off" \
-c "ALTER SYSTEM SET full_page_writes = off" \
-c "SELECT pg_reload_conf()"; then relaxed=1; break; fi
sleep 2
done
if [ -n "$relaxed" ]; then
docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test -At \
-c "SELECT 'durability: ' || string_agg(name || '=' || setting, ' ' ORDER BY name) FROM pg_settings WHERE name IN ('fsync','synchronous_commit','full_page_writes')" \
|| true
else
echo "::warning::durability relax failed after 5 tries; the suite will run several times slower"
fi
# Apply embedded migrations to the fresh test DB, then run the # Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1: # full suite (no -short → integration tests execute). -p 1:
@@ -295,12 +314,13 @@ jobs:
- name: Install deps - name: Install deps
run: npm ci run: npm ci
# What ships to browsers: `dependencies` and the runtime they pull in # The whole tree, build and test tooling included. Until #5021 this
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind, # audited only what ships to browsers (`--omit=dev`), because vite,
# kit's dev server) is left out because none of it reaches a user, and # vitest, tailwind and kit carried advisories that needed major
# its open advisories need major-version upgrades tracked separately. # upgrades. Those upgrades landed and the full tree audits clean, so
- name: npm audit (shipped dependencies) # the tooling that builds the shipped bundle is held to the same bar.
run: npm audit --omit=dev --audit-level=moderate - name: npm audit (all dependencies)
run: npm audit --audit-level=moderate
- name: Type-check + svelte-check - name: Type-check + svelte-check
run: npm run check run: npm run check
@@ -301,6 +301,7 @@ internal fun kindLabel(kind: String): String = when (kind) {
"scan_failed" -> "Library scan failed" "scan_failed" -> "Library scan failed"
"tracks_missing" -> "Tracks gone missing" "tracks_missing" -> "Tracks gone missing"
"duplicates_found" -> "Duplicates to review" "duplicates_found" -> "Duplicates to review"
"duplicates_resolved" -> "Duplicates resolved"
"playback_errors" -> "Playback errors" "playback_errors" -> "Playback errors"
else -> kind else -> kind
} }
+13
View File
@@ -325,6 +325,19 @@ func run() error {
library.SetNotifier(notifier) library.SetNotifier(notifier)
go lidarrReconciler.Run(ctx) go lidarrReconciler.Run(ctx)
// Duplicate resolver (M498): classifies the sweep's groups and, with the
// operator's auto-resolve on, merges copies Lidarr does not map and moves a
// Lidarr release that lists songs twice. Started after SetNotifier so its
// summaries reach admins. See internal/library/duplicate_resolve.go.
resolverLidarr := func() library.LidarrLibrary {
if c := lidarrClientFn(); c != nil {
return c
}
return nil // a nil *lidarr.Client must not become a non-nil interface
}
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
cfg.Storage.DataDir, fpSettings, resolverLidarr).Run(ctx)
// Missing-file re-acquisition (milestone #290). Turns albums whose files // Missing-file re-acquisition (milestone #290). Turns albums whose files
// have been gone longer than the grace window into Lidarr requests, on an // have been gone longer than the grace window into Lidarr requests, on an
// exponential per-album backoff. Hourly tick — the shortest meaningful // exponential per-album backoff. Hourly tick — the shortest meaningful
+1 -1
View File
@@ -94,7 +94,7 @@ Keystore, so a copy of the app's files doesn't yield a usable session.
Nothing is published unless every check passes: the Go, integration, web and Nothing is published unless every check passes: the Go, integration, web and
Android test suites, `govulncheck` against the toolchain that builds the Android test suites, `govulncheck` against the toolchain that builds the
image, and `npm audit` on the packages that ship to the browser. See image, and `npm audit` on every web dependency, build tooling included. See
`.gitea/workflows/release.yml`. `.gitea/workflows/release.yml`.
## Reporting a problem ## Reporting a problem
+163 -13
View File
@@ -11,8 +11,10 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
) )
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and // duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
@@ -31,6 +33,12 @@ type duplicateMemberView struct {
AddedAt string `json:"added_at"` AddedAt string `json:"added_at"`
LikeCount int64 `json:"like_count"` LikeCount int64 `json:"like_count"`
PlayCount int64 `json:"play_count"` PlayCount int64 `json:"play_count"`
// LidarrState is Lidarr's view of the file as of the resolver's last pass
// (M498): "tracked" (mapped to a track, so it cannot be removed without
// Lidarr downloading it again), "unmapped", or null when not asked.
LidarrState *string `json:"lidarr_state"`
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
} }
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are // duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
@@ -38,13 +46,17 @@ type duplicateMemberView struct {
// (library.ProposeSurvivor) — a default the merge (#3911) lets the operator // (library.ProposeSurvivor) — a default the merge (#3911) lets the operator
// override. // override.
type duplicateGroupView struct { type duplicateGroupView struct {
ID string `json:"id"` ID string `json:"id"`
Tier string `json:"tier"` Tier string `json:"tier"`
WorstBitErrorRate *float32 `json:"worst_bit_error_rate"` WorstBitErrorRate *float32 `json:"worst_bit_error_rate"`
DetectedAt string `json:"detected_at"` DetectedAt string `json:"detected_at"`
SurvivorTrackID string `json:"survivor_track_id"` SurvivorTrackID string `json:"survivor_track_id"`
SurvivorReason string `json:"survivor_reason"` SurvivorReason string `json:"survivor_reason"`
Members []duplicateMemberView `json:"members"` // Class is the resolver's verdict (library.DuplicateClass), null until its
// first pass; ResolveNote is why it left the group for the operator.
Class *string `json:"class"`
ResolveNote *string `json:"resolve_note"`
Members []duplicateMemberView `json:"members"`
} }
// duplicateSweepView is the latest sweep. State is "never" when none has run, // duplicateSweepView is the latest sweep. State is "never" when none has run,
@@ -60,10 +72,26 @@ type duplicateSweepView struct {
ErrorMessage *string `json:"error_message"` ErrorMessage *string `json:"error_message"`
} }
// adminDuplicatesResponse is the paged report. Total counts groups. // duplicateViewCounts sizes each tab of the report.
type duplicateViewCounts struct {
Review int64 `json:"review"`
CrossRelease int64 `json:"cross_release"`
Resolved int64 `json:"resolved"`
}
// Report views (M498). cross_release groups are the same song on different
// releases, kept on purpose; everything else pending needs the operator.
const (
duplicateViewReview = "review"
duplicateViewCrossRelease = "cross_release"
)
// adminDuplicatesResponse is the paged report. Total counts groups in View.
type adminDuplicatesResponse struct { type adminDuplicatesResponse struct {
Sweep duplicateSweepView `json:"sweep"` Sweep duplicateSweepView `json:"sweep"`
Fingerprints fingerprintCoverageResp `json:"fingerprints"` Fingerprints fingerprintCoverageResp `json:"fingerprints"`
View string `json:"view"`
Counts duplicateViewCounts `json:"counts"`
Total int64 `json:"total"` Total int64 `json:"total"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
@@ -71,6 +99,7 @@ type adminDuplicatesResponse struct {
} }
// handleListDuplicates implements GET /api/admin/library/duplicates (#3912). // handleListDuplicates implements GET /api/admin/library/duplicates (#3912).
// ?view=review (the default) or cross_release picks the tab (M498).
// //
// Read-only. The sweep's state and the fingerprint backfill's progress travel // Read-only. The sweep's state and the fingerprint backfill's progress travel
// with the groups because an empty report means three different things — still // with the groups because an empty report means three different things — still
@@ -81,6 +110,14 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging") writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
return return
} }
view := r.URL.Query().Get("view")
if view == "" {
view = duplicateViewReview
}
if view != duplicateViewReview && view != duplicateViewCrossRelease {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_view")
return
}
ctx := r.Context() ctx := r.Context()
q := dbq.New(h.pool) q := dbq.New(h.pool)
@@ -101,14 +138,18 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return return
} }
total, err := q.CountPendingDuplicateGroups(ctx) counts, err := duplicateCounts(ctx, q)
if err != nil { if err != nil {
h.logger.Error("admin: count duplicate groups", "err", err) h.logger.Error("admin: count duplicate groups", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return return
} }
total := counts.Review
if view == duplicateViewCrossRelease {
total = counts.CrossRelease
}
rows, err := q.ListPendingDuplicateGroupMembers(ctx, dbq.ListPendingDuplicateGroupMembersParams{ rows, err := q.ListPendingDuplicateGroupMembers(ctx, dbq.ListPendingDuplicateGroupMembersParams{
PageLimit: int32(limit), PageOffset: int32(offset), View: view, PageLimit: int32(limit), PageOffset: int32(offset),
}) })
if err != nil { if err != nil {
h.logger.Error("admin: list duplicate groups", "err", err) h.logger.Error("admin: list duplicate groups", "err", err)
@@ -119,6 +160,8 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, adminDuplicatesResponse{ writeJSON(w, http.StatusOK, adminDuplicatesResponse{
Sweep: sweep, Sweep: sweep,
Fingerprints: cov, Fingerprints: cov,
View: view,
Counts: counts,
Total: total, Total: total,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
@@ -126,6 +169,76 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
}) })
} }
// resolvedActions are the audit actions the "Resolved automatically" tab lists.
var resolvedActions = []string{string(audit.ActionDuplicateMerge), string(audit.ActionLidarrReleaseChange)}
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
var c duplicateViewCounts
var err error
if c.Review, err = q.CountPendingDuplicateGroupsInView(ctx, duplicateViewReview); err != nil {
return c, err
}
if c.CrossRelease, err = q.CountPendingDuplicateGroupsInView(ctx, duplicateViewCrossRelease); err != nil {
return c, err
}
c.Resolved, err = q.CountAuditLogByActions(ctx, dbq.CountAuditLogByActionsParams{Actions: resolvedActions, SystemOnly: true})
return c, err
}
// resolvedActivityView is one thing the resolver did on its own: a merge
// (action duplicate_merge) or a Lidarr release change (lidarr_release_change).
// Details is the audit row's metadata, which names the files or releases.
type resolvedActivityView struct {
ID string `json:"id"`
Action string `json:"action"`
CreatedAt string `json:"created_at"`
Details json.RawMessage `json:"details"`
}
type resolvedActivityResponse struct {
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Items []resolvedActivityView `json:"items"`
}
// handleListResolvedDuplicates implements GET
// /api/admin/library/duplicates/resolved (M498): what the resolver did by
// itself, newest first, read from the audit log so it outlives the groups.
func (h *handlers) handleListResolvedDuplicates(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
return
}
q := dbq.New(h.pool)
total, err := q.CountAuditLogByActions(r.Context(), dbq.CountAuditLogByActionsParams{Actions: resolvedActions, SystemOnly: true})
if err != nil {
h.logger.Error("admin: count resolved duplicates", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
rows, err := q.ListAuditLogByActions(r.Context(), dbq.ListAuditLogByActionsParams{
Actions: resolvedActions, SystemOnly: true, PageLimit: int32(limit), PageOffset: int32(offset),
})
if err != nil {
h.logger.Error("admin: list resolved duplicates", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
items := make([]resolvedActivityView, 0, len(rows))
for _, row := range rows {
details := json.RawMessage(row.Metadata)
if len(details) == 0 {
details = json.RawMessage("{}")
}
items = append(items, resolvedActivityView{
ID: uuidToString(row.ID), Action: row.Action, CreatedAt: formatTimestamp(row.CreatedAt), Details: details,
})
}
writeJSON(w, http.StatusOK, resolvedActivityResponse{Total: total, Limit: limit, Offset: offset, Items: items})
}
func duplicateSweepViewOf(s dbq.DuplicateSweep) duplicateSweepView { func duplicateSweepViewOf(s dbq.DuplicateSweep) duplicateSweepView {
v := duplicateSweepView{ v := duplicateSweepView{
State: "running", State: "running",
@@ -158,6 +271,8 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
Tier: row.Tier, Tier: row.Tier,
WorstBitErrorRate: row.WorstBitErrorRate, WorstBitErrorRate: row.WorstBitErrorRate,
DetectedAt: formatTimestamp(row.DetectedAt), DetectedAt: formatTimestamp(row.DetectedAt),
Class: row.Class,
ResolveNote: row.ResolveNote,
}) })
candidates = append(candidates, nil) candidates = append(candidates, nil)
} }
@@ -176,9 +291,14 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
AddedAt: formatTimestamp(row.AddedAt), AddedAt: formatTimestamp(row.AddedAt),
LikeCount: row.LikeCount, LikeCount: row.LikeCount,
PlayCount: row.PlayCount, PlayCount: row.PlayCount,
LidarrState: row.LidarrState,
DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber,
}) })
candidates[n] = append(candidates[n], library.SurvivorCandidate{ candidates[n] = append(candidates[n], library.SurvivorCandidate{
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time, TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
LidarrTracked: row.LidarrState != nil && *row.LidarrState == "tracked",
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
}) })
} }
for i := range groups { for i := range groups {
@@ -211,15 +331,17 @@ func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Reques
// handleDismissDuplicateGroup implements POST // handleDismissDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The // /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The
// sweep keeps the dismissal and will not propose that set of tracks again. 404 // sweep keeps the dismissal and will not propose that set of tracks again. On a
// duplicate_group_not_pending when the group was already resolved or is gone. // cross-release group it also undoes the song link (M498 #5438): the copies
// stop sharing likes from here on. 404 duplicate_group_not_pending when the
// group was already resolved or is gone.
func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(chi.URLParam(r, "id")) id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok { if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return return
} }
n, err := dbq.New(h.pool).DismissDuplicateGroup(r.Context(), id) n, err := h.dismissDuplicateGroup(r.Context(), id)
if err != nil { if err != nil {
h.logger.Error("admin: dismiss duplicate group", "err", err) h.logger.Error("admin: dismiss duplicate group", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -232,6 +354,25 @@ func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"}) writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
} }
// dismissDuplicateGroup dismisses the group and unlinks its copies as one song,
// together.
func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (int64, error) {
tx, err := h.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
n, err := tq.DismissDuplicateGroup(ctx, id)
if err != nil || n == 0 {
return n, err
}
if err := tq.UnlinkDuplicateGroupSongs(ctx, id); err != nil {
return 0, err
}
return n, tx.Commit(ctx)
}
// mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id // mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id
// keeps the report's proposal. // keeps the report's proposal.
type mergeDuplicateRequest struct { type mergeDuplicateRequest struct {
@@ -257,6 +398,10 @@ const mergeRequestBodyLimit = 1 << 16
// Errors: // Errors:
// - 409 library_not_writable / 500 file_delete_failed when a file could not be // - 409 library_not_writable / 500 file_delete_failed when a file could not be
// removed — nothing was changed (fileRemoveAPIError) // removed — nothing was changed (fileRemoveAPIError)
// - 409 copy_tracked_by_lidarr when a copy to remove is one Lidarr maps to a
// track: removing it would make Lidarr download it again (M498)
// - 503 lidarr_unavailable when Lidarr is on but did not answer, so that
// cannot be checked
// - 404 duplicate_group_not_pending when the group was already resolved // - 404 duplicate_group_not_pending when the group was already resolved
// - 400 survivor_not_in_group, invalid_id, invalid_body // - 400 survivor_not_in_group, invalid_id, invalid_body
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
@@ -294,6 +439,11 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending") writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
case errors.Is(err, library.ErrSurvivorNotInGroup): case errors.Is(err, library.ErrSurvivorNotInGroup):
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group") writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
case errors.Is(err, library.ErrCopyTrackedByLidarr):
writeAdminJSONErr(w, http.StatusConflict, "copy_tracked_by_lidarr")
case errors.Is(err, tracks.ErrLidarrUnavailable):
h.logger.Warn("admin: merge duplicate group: lidarr unavailable", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unavailable")
default: default:
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err) h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
+26
View File
@@ -63,3 +63,29 @@ func TestFoldDuplicateGroups(t *testing.T) {
t.Errorf("group 2 survivor = (%s, %q), want the FLAC copy", g2.SurvivorTrackID, g2.SurvivorReason) t.Errorf("group 2 survivor = (%s, %q), want the FLAC copy", g2.SurvivorTrackID, g2.SurvivorReason)
} }
} }
// M498: the proposal shown beside a group follows the resolver's rule. The copy
// Lidarr tracks is proposed even when the other is lossless, because removing
// the tracked one would make Lidarr download it again.
func TestFoldDuplicateGroups_ProposesTheCopyLidarrTracks(t *testing.T) {
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
tracked, unmapped := "tracked", "unmapped"
class := "same_release"
rows := []dbq.ListPendingDuplicateGroupMembersRow{
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(30), Class: &class,
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at), LidarrState: &unmapped},
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(31), Class: &class,
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), LidarrState: &tracked},
}
got := foldDuplicateGroups(rows)
if len(got) != 1 {
t.Fatalf("folded %d groups, want 1", len(got))
}
g := got[0]
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "the copy Lidarr tracks" {
t.Errorf("survivor = (%s, %q), want the tracked mp3", g.SurvivorTrackID, g.SurvivorReason)
}
if g.Class == nil || *g.Class != "same_release" || g.Members[1].LidarrState == nil || *g.Members[1].LidarrState != "tracked" {
t.Errorf("class or lidarr state not carried: %+v", g)
}
}
@@ -18,6 +18,9 @@ type fingerprintSettingsBody struct {
AcousticMaxBitErrorRate float64 `json:"acoustic_max_bit_error_rate"` AcousticMaxBitErrorRate float64 `json:"acoustic_max_bit_error_rate"`
BackfillConcurrency int32 `json:"backfill_concurrency"` BackfillConcurrency int32 `json:"backfill_concurrency"`
SweepIntervalHours int32 `json:"sweep_interval_hours"` SweepIntervalHours int32 `json:"sweep_interval_hours"`
// AutoResolve is a pointer so a client that predates it (M498) and leaves
// it out keeps the saved value rather than switching the resolver off.
AutoResolve *bool `json:"auto_resolve"`
} }
func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSettingsBody { func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSettingsBody {
@@ -27,6 +30,7 @@ func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSetting
AcousticMaxBitErrorRate: s.AcousticMaxBitErrorRate, AcousticMaxBitErrorRate: s.AcousticMaxBitErrorRate,
BackfillConcurrency: s.BackfillConcurrency, BackfillConcurrency: s.BackfillConcurrency,
SweepIntervalHours: s.SweepIntervalHours, SweepIntervalHours: s.SweepIntervalHours,
AutoResolve: &s.AutoResolve,
} }
} }
@@ -39,6 +43,7 @@ func (h *handlers) handleGetFingerprintSettings(w http.ResponseWriter, _ *http.R
// //
// A whole-row write. A body that leaves a field out decodes it as zero, which no // A whole-row write. A body that leaves a field out decodes it as zero, which no
// field accepts, so a partial save is refused rather than zeroing what it omitted. // field accepts, so a partial save is refused rather than zeroing what it omitted.
// auto_resolve is the exception: left out, it keeps its saved value.
// The saved settings reach the scanner and both workers at once: they share the // The saved settings reach the scanner and both workers at once: they share the
// service instance. // service instance.
func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *http.Request) {
@@ -47,12 +52,17 @@ func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *htt
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON")) writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
return return
} }
autoResolve := h.fingerprintSettings.Get().AutoResolve
if req.AutoResolve != nil {
autoResolve = *req.AutoResolve
}
saved, err := h.fingerprintSettings.Set(r.Context(), library.FingerprintSettings{ saved, err := h.fingerprintSettings.Set(r.Context(), library.FingerprintSettings{
Enabled: req.Enabled, Enabled: req.Enabled,
ChromaprintLengthSec: req.ChromaprintLengthSec, ChromaprintLengthSec: req.ChromaprintLengthSec,
AcousticMaxBitErrorRate: req.AcousticMaxBitErrorRate, AcousticMaxBitErrorRate: req.AcousticMaxBitErrorRate,
BackfillConcurrency: req.BackfillConcurrency, BackfillConcurrency: req.BackfillConcurrency,
SweepIntervalHours: req.SweepIntervalHours, SweepIntervalHours: req.SweepIntervalHours,
AutoResolve: autoResolve,
}) })
if err != nil { if err != nil {
// Validation mirrors migration 0061's CHECKs and names the field. // Validation mirrors migration 0061's CHECKs and names the field.
@@ -23,7 +23,13 @@ func TestGetFingerprintSettings_ServesDefaultsWithoutAService(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
if want := fingerprintSettingsBodyOf(library.DefaultFingerprintSettings); got != want { want := fingerprintSettingsBodyOf(library.DefaultFingerprintSettings)
// auto_resolve is a pointer, so it is compared by value and then set aside.
if got.AutoResolve == nil || *got.AutoResolve != *want.AutoResolve {
t.Fatalf("auto_resolve = %v, want %v", got.AutoResolve, *want.AutoResolve)
}
got.AutoResolve, want.AutoResolve = nil, nil
if got != want {
t.Fatalf("body = %+v, want the defaults %+v", got, want) t.Fatalf("body = %+v, want the defaults %+v", got, want)
} }
} }
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// suspectTrackView is one flagged track. FilePath is the evidence: the
// markers are read from its basename, and the operator needs to see it to
// judge whether the flag is right.
type suspectTrackView struct {
TrackID string `json:"track_id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
FilePath string `json:"file_path"`
DurationSec int32 `json:"duration_sec"`
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
Markers []string `json:"markers"`
// Verdict is "suspect" while the track is held back from radio and the
// mixes, "fine" once the operator let it back, null before the resolver's
// first pass (M498 #5439). VerdictAt is when it was last set.
Verdict *string `json:"verdict"`
VerdictAt *string `json:"verdict_at"`
}
// suspectGroupView is a folder's worth of flagged tracks. As on the
// missing-files report, the folder is the unit of decision: Humanz was 62 rows
// in one folder, which is one problem to look at, not 62.
type suspectGroupView struct {
Directory string `json:"directory"`
Tracks []suspectTrackView `json:"tracks"`
}
// adminSuspectSourcesResponse is the paged envelope. Total counts tracks.
type adminSuspectSourcesResponse struct {
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Groups []suspectGroupView `json:"groups"`
}
// handleListSuspectSources implements GET /api/admin/library/suspect-sources.
//
// A marker is a reason to look, not proof: a band can title a song "Music
// Video". The duplicate resolver holds a flagged track back from radio and the
// mixes and merges it away where a clean copy exists (M498 #5439); this report
// is where the operator sees that and can say a track is fine.
func (h *handlers) handleListSuspectSources(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
return
}
q := dbq.New(h.pool)
total, err := q.CountSuspectSourceTracks(r.Context(), library.SuspectSourcePattern)
if err != nil {
h.logger.Error("admin: count suspect-source tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
rows, err := q.ListSuspectSourceTracks(r.Context(), dbq.ListSuspectSourceTracksParams{
Pattern: library.SuspectSourcePattern,
PageLimit: int32(limit),
PageOffset: int32(offset),
})
if err != nil {
h.logger.Error("admin: list suspect-source tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
writeJSON(w, http.StatusOK, adminSuspectSourcesResponse{
Total: total,
Limit: limit,
Offset: offset,
Groups: groupSuspectByDirectory(rows),
})
}
// groupSuspectByDirectory folds the directory-ordered rows into per-folder
// groups, the same run-length fold as groupMissingByDirectory. A page boundary
// can split a folder; the web page joins the halves.
func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGroupView {
groups := make([]suspectGroupView, 0, 8)
for _, row := range rows {
t := suspectTrackView{
TrackID: uuidToString(row.ID),
Title: row.Title,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
FilePath: row.FilePath,
DurationSec: row.DurationMs / 1000,
DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber,
Markers: library.SourceMarkersFor(row.FilePath),
Verdict: row.SourceVerdict,
}
if row.SourceVerdictAt.Valid {
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
t.VerdictAt = &at
}
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
continue
}
groups = append(groups, suspectGroupView{
Directory: row.Directory,
Tracks: []suspectTrackView{t},
})
}
return groups
}
type suspectVerdictRequest struct {
Verdict string `json:"verdict"`
}
// handleSetSuspectSourceVerdict implements PUT
// /api/admin/library/suspect-sources/{id}: {"verdict": "fine"} lets a flagged
// track back into radio and the mixes for good, {"verdict": "suspect"} holds
// it back again. 404 suspect_track_not_found when the track was never flagged.
func (h *handlers) handleSetSuspectSourceVerdict(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
var req suspectVerdictRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Verdict != "fine" && req.Verdict != "suspect" {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_verdict")
return
}
n, err := dbq.New(h.pool).SetTrackSourceVerdict(r.Context(), dbq.SetTrackSourceVerdictParams{Verdict: req.Verdict, ID: id})
if err != nil {
h.logger.Error("admin: set suspect-source verdict", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
if n == 0 {
writeAdminJSONErr(w, http.StatusNotFound, "suspect_track_not_found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"verdict": req.Verdict})
}
@@ -0,0 +1,69 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// The integration test runs the same alternation through Postgres's ~*, so the
// one pattern is exercised in both regex dialects it has to work in.
func TestHandleListSuspectSources_Integration(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "suspect-admin", "pw", true)
artist := seedArtist(t, pool, "Gorillaz")
humanz := seedAlbum(t, pool, artist.ID, "Humanz", 2017)
seedTrack(t, pool, humanz.ID, artist.ID, "Saturnz Barz (Official Video)", 1, 180_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Momentz (Visualizer)", 2, 200_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Andromeda [Audio]", 3, 190_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Chain Reaction", 4, 190_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Busted and Blue", 5, 190_000)
gone := seedTrack(t, pool, humanz.ID, artist.ID, "Ascension (Lyric Video)", 6, 156_000)
if _, err := pool.Exec(context.Background(),
`UPDATE tracks SET missing_since = now() WHERE id = $1`, gone.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/library/suspect-sources?limit=50", nil)
req = withUser(req, admin)
w := httptest.NewRecorder()
h.handleListSuspectSources(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var resp adminSuspectSourcesResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
// Three present files carry a marker. "Chain Reaction" and "Busted and
// Blue" carry none, and the lyric video's file is missing.
if resp.Total != 3 {
t.Errorf("total = %d, want 3", resp.Total)
}
if len(resp.Groups) != 1 {
t.Fatalf("groups = %d, want 1 (one folder)", len(resp.Groups))
}
got := map[string][]string{}
for _, tr := range resp.Groups[0].Tracks {
got[tr.Title] = tr.Markers
}
want := map[string][]string{
"Saturnz Barz (Official Video)": {"music video"},
"Momentz (Visualizer)": {"visualiser"},
"Andromeda [Audio]": {"[Audio]"},
}
if len(got) != len(want) {
t.Fatalf("tracks = %v, want %v", got, want)
}
for title, labels := range want {
if !slices.Equal(got[title], labels) {
t.Errorf("%s: markers %v, want %v", title, got[title], labels)
}
}
}
+3
View File
@@ -252,6 +252,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// and because the destructive /tracks/{id} route above must // and because the destructive /tracks/{id} route above must
// not be mistaken for it (#2527). // not be mistaken for it (#2527).
admin.Get("/library/missing", h.handleListMissingTracks) admin.Get("/library/missing", h.handleListMissingTracks)
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
admin.Put("/library/suspect-sources/{id}", h.handleSetSuspectSourceVerdict)
admin.Get("/library/coverage", h.handleGetLibraryCoverage) admin.Get("/library/coverage", h.handleGetLibraryCoverage)
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage) admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
@@ -268,6 +270,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// trigger to sweep now, dismissal, and the merge (#3911), which deletes // trigger to sweep now, dismissal, and the merge (#3911), which deletes
// the removed copies' files after moving their history onto the kept one. // the removed copies' files after moving their history onto the kept one.
admin.Get("/library/duplicates", h.handleListDuplicates) admin.Get("/library/duplicates", h.handleListDuplicates)
admin.Get("/library/duplicates/resolved", h.handleListResolvedDuplicates)
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep) admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup) admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup) admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
+27 -7
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"errors" "errors"
"net/http" "net/http"
"slices"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -50,20 +51,36 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.InternalMsg("lookup failed", err)) writeErr(w, apierror.InternalMsg("lookup failed", err))
return return
} }
rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}) liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
if err != nil { if err != nil {
h.logger.Error("api: like track insert", "err", err) h.logger.Error("api: like track insert", "err", err)
writeErr(w, apierror.InternalMsg("insert failed", err)) writeErr(w, apierror.InternalMsg("insert failed", err))
return return
} }
if rows == 1 { if slices.Contains(liked, id) {
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger) _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
} }
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert) // The like is on the song (M498 #5438): every copy it reached changes on
h.publishLikeEvent(user.ID, id, "track", true) // the client too.
for _, t := range withTrack(id, liked) {
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpUpsert)
h.publishLikeEvent(user.ID, t, "track", true)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// withTrack is the tracks a like or unlike changed, led by the one asked for,
// which is reported even when it was already in that state.
func withTrack(id pgtype.UUID, changed []pgtype.UUID) []pgtype.UUID {
out := []pgtype.UUID{id}
for _, t := range changed {
if t != id {
out = append(out, t)
}
}
return out
}
func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r) user, ok := requireUser(w, r)
if !ok { if !ok {
@@ -74,7 +91,8 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
return return
} }
q := dbq.New(h.pool) q := dbq.New(h.pool)
if err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { unliked, err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id})
if err != nil {
h.logger.Error("api: unlike track", "err", err) h.logger.Error("api: unlike track", "err", err)
writeErr(w, apierror.InternalMsg("delete failed", err)) writeErr(w, apierror.InternalMsg("delete failed", err))
return return
@@ -83,8 +101,10 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
h.logger.Error("api: soft-delete contextual_likes", "err", err) h.logger.Error("api: soft-delete contextual_likes", "err", err)
// Don't fail the response — soft-delete is best-effort. // Don't fail the response — soft-delete is best-effort.
} }
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete) for _, t := range withTrack(id, unliked) {
h.publishLikeEvent(user.ID, id, "track", false) h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpDelete)
h.publishLikeEvent(user.ID, t, "track", false)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+5
View File
@@ -61,6 +61,11 @@ const (
// log can answer "where did that file go" long after the report is gone. // log can answer "where did that file go" long after the report is gone.
ActionDuplicateMerge Action = "duplicate_merge" ActionDuplicateMerge Action = "duplicate_merge"
// Lidarr release change (M498 #5437): the duplicate resolver moved an
// album's monitored release in Lidarr to one that lists each song once.
// Written with no actor; the metadata names the album and both releases.
ActionLidarrReleaseChange Action = "lidarr_release_change"
// Subsonic password (#5026): generated in Settings for t/s-only clients. // Subsonic password (#5026): generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set" ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear" ActionSubsonicPasswordClear Action = "subsonic_password_clear"
+69
View File
@@ -11,6 +11,25 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const countAuditLogByActions = `-- name: CountAuditLogByActions :one
SELECT count(*)::bigint FROM audit_log
WHERE action = ANY($1::text[])
AND (NOT $2::boolean OR actor_id IS NULL)
`
type CountAuditLogByActionsParams struct {
Actions []string
SystemOnly bool
}
// System actions carry no actor; system_only narrows to them.
func (q *Queries) CountAuditLogByActions(ctx context.Context, arg CountAuditLogByActionsParams) (int64, error) {
row := q.db.QueryRow(ctx, countAuditLogByActions, arg.Actions, arg.SystemOnly)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const listAuditLog = `-- name: ListAuditLog :many const listAuditLog = `-- name: ListAuditLog :many
SELECT id, actor_id, target_id, action, metadata, created_at FROM audit_log SELECT id, actor_id, target_id, action, metadata, created_at FROM audit_log
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -49,6 +68,56 @@ func (q *Queries) ListAuditLog(ctx context.Context, arg ListAuditLogParams) ([]A
return items, nil return items, nil
} }
const listAuditLogByActions = `-- name: ListAuditLogByActions :many
SELECT id, actor_id, target_id, action, metadata, created_at FROM audit_log
WHERE action = ANY($1::text[])
AND (NOT $2::boolean OR actor_id IS NULL)
ORDER BY created_at DESC, id
LIMIT $4 OFFSET $3
`
type ListAuditLogByActionsParams struct {
Actions []string
SystemOnly bool
PageOffset int32
PageLimit int32
}
// One page of the given actions, newest first: the duplicates report's
// "resolved automatically" activity (M498) reads the resolver's merges and
// Lidarr release changes from here.
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogByActions,
arg.Actions,
arg.SystemOnly,
arg.PageOffset,
arg.PageLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AuditLog
for rows.Next() {
var i AuditLog
if err := rows.Scan(
&i.ID,
&i.ActorID,
&i.TargetID,
&i.Action,
&i.Metadata,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const writeAuditLog = `-- name: WriteAuditLog :exec const writeAuditLog = `-- name: WriteAuditLog :exec
INSERT INTO audit_log (actor_id, target_id, action, metadata) INSERT INTO audit_log (actor_id, target_id, action, metadata)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
+238 -7
View File
@@ -62,6 +62,29 @@ func (q *Queries) CountPendingDuplicateGroups(ctx context.Context) (int64, error
return column_1, err return column_1, err
} }
const countPendingDuplicateGroupsInView = `-- name: CountPendingDuplicateGroupsInView :one
SELECT count(*)::bigint
FROM duplicate_groups g
WHERE g.status = 'pending'
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
AND CASE $1::text
WHEN 'cross_release' THEN g.class = 'cross_release'
ELSE g.class IS DISTINCT FROM 'cross_release'
END
`
// CountPendingDuplicateGroups narrowed to one view of the report (M498):
//
// review what the operator still has to decide: every class but
// cross_release, and groups not yet classified
// cross_release the same song on different releases, which is kept, not merged
func (q *Queries) CountPendingDuplicateGroupsInView(ctx context.Context, view string) (int64, error) {
row := q.db.QueryRow(ctx, countPendingDuplicateGroupsInView, view)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const deleteStalePendingDuplicateGroups = `-- name: DeleteStalePendingDuplicateGroups :execrows const deleteStalePendingDuplicateGroups = `-- name: DeleteStalePendingDuplicateGroups :execrows
DELETE FROM duplicate_groups g DELETE FROM duplicate_groups g
WHERE g.status = 'pending' WHERE g.status = 'pending'
@@ -187,6 +210,32 @@ func (q *Queries) GetLatestFingerprintComputedAt(ctx context.Context) (pgtype.Ti
return latest, err return latest, err
} }
const listAlbumPresentTrackTitles = `-- name: ListAlbumPresentTrackTitles :many
SELECT title FROM tracks WHERE album_id = $1 AND missing_since IS NULL
`
// The titles an album has on disk, for choosing the Lidarr release that covers
// them best (M498 #5437).
func (q *Queries) ListAlbumPresentTrackTitles(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listAlbumPresentTrackTitles, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var title string
if err := rows.Scan(&title); err != nil {
return nil, err
}
items = append(items, title)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDismissedDuplicateMemberSets = `-- name: ListDismissedDuplicateMemberSets :many const listDismissedDuplicateMemberSets = `-- name: ListDismissedDuplicateMemberSets :many
SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
FROM duplicate_groups g FROM duplicate_groups g
@@ -288,6 +337,98 @@ func (q *Queries) ListDuplicateCandidates(ctx context.Context, arg ListDuplicate
return items, nil return items, nil
} }
const listDuplicateGroupsForResolve = `-- name: ListDuplicateGroupsForResolve :many
SELECT g.id AS group_id,
g.tier,
t.id AS track_id,
t.album_id,
t.title,
artists.name AS artist_name,
t.file_path,
t.file_format,
t.file_size,
t.added_at,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
albums.release_group_mbid,
albums.title AS album_title,
EXISTS (SELECT 1 FROM tracks o
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
AND t.track_number IS NOT NULL
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
AND o.track_number = t.track_number)::boolean AS position_clash
FROM duplicate_groups g
JOIN duplicate_group_members m ON m.group_id = g.id
JOIN tracks t ON t.id = m.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE g.status = 'pending'
AND t.missing_since IS NULL
ORDER BY g.id, t.id
`
type ListDuplicateGroupsForResolveRow struct {
GroupID pgtype.UUID
Tier string
TrackID pgtype.UUID
AlbumID pgtype.UUID
Title string
ArtistName string
FilePath string
FileFormat string
FileSize int64
AddedAt pgtype.Timestamptz
DiscNumber *int32
TrackNumber *int32
HasMbid bool
ReleaseGroupMbid *string
AlbumTitle string
PositionClash bool
}
// Every pending group's present copies, for the resolver (M498 #5435): what it
// needs to classify the group and to choose the copy to keep. One row per copy,
// grouped by group. position_clash is true when another present track on the
// same album claims the same disc and track number: tags that do not fit the
// album, which the survivor rule weighs against a copy.
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDuplicateGroupsForResolveRow
for rows.Next() {
var i ListDuplicateGroupsForResolveRow
if err := rows.Scan(
&i.GroupID,
&i.Tier,
&i.TrackID,
&i.AlbumID,
&i.Title,
&i.ArtistName,
&i.FilePath,
&i.FileFormat,
&i.FileSize,
&i.AddedAt,
&i.DiscNumber,
&i.TrackNumber,
&i.HasMbid,
&i.ReleaseGroupMbid,
&i.AlbumTitle,
&i.PositionClash,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listExactDuplicateHashes = `-- name: ListExactDuplicateHashes :many const listExactDuplicateHashes = `-- name: ListExactDuplicateHashes :many
SELECT f.audio_stream_sha256, SELECT f.audio_stream_sha256,
array_agg(t.id ORDER BY t.id)::uuid[] AS track_ids array_agg(t.id ORDER BY t.id)::uuid[] AS track_ids
@@ -329,17 +470,32 @@ func (q *Queries) ListExactDuplicateHashes(ctx context.Context, currentVersion i
const listPendingDuplicateGroupMembers = `-- name: ListPendingDuplicateGroupMembers :many const listPendingDuplicateGroupMembers = `-- name: ListPendingDuplicateGroupMembers :many
WITH page AS ( WITH page AS (
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note
FROM duplicate_groups g FROM duplicate_groups g
WHERE g.status = 'pending' WHERE g.status = 'pending'
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2 AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
AND CASE $1::text
WHEN 'cross_release' THEN g.class = 'cross_release'
ELSE g.class IS DISTINCT FROM 'cross_release'
END
ORDER BY g.detected_at DESC, g.id ORDER BY g.detected_at DESC, g.id
LIMIT $2 OFFSET $1 LIMIT $3 OFFSET $2
) )
SELECT p.id AS group_id, SELECT p.id AS group_id,
p.tier, p.tier,
p.worst_bit_error_rate, p.worst_bit_error_rate,
p.detected_at, p.detected_at,
p.class,
p.resolve_note,
m.lidarr_state,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
EXISTS (SELECT 1 FROM tracks o
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
AND t.track_number IS NOT NULL
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
AND o.track_number = t.track_number)::boolean AS position_clash,
t.id AS track_id, t.id AS track_id,
t.title, t.title,
artists.name AS artist_name, artists.name AS artist_name,
@@ -361,6 +517,7 @@ SELECT p.id AS group_id,
` `
type ListPendingDuplicateGroupMembersParams struct { type ListPendingDuplicateGroupMembersParams struct {
View string
PageOffset int32 PageOffset int32
PageLimit int32 PageLimit int32
} }
@@ -370,6 +527,13 @@ type ListPendingDuplicateGroupMembersRow struct {
Tier string Tier string
WorstBitErrorRate *float32 WorstBitErrorRate *float32
DetectedAt pgtype.Timestamptz DetectedAt pgtype.Timestamptz
Class *string
ResolveNote *string
LidarrState *string
DiscNumber *int32
TrackNumber *int32
HasMbid bool
PositionClash bool
TrackID pgtype.UUID TrackID pgtype.UUID
Title string Title string
ArtistName string ArtistName string
@@ -384,12 +548,14 @@ type ListPendingDuplicateGroupMembersRow struct {
PlayCount int64 PlayCount int64
} }
// One page of proposals, newest first, flattened to one row per member so the // One page of proposals in one view (see CountPendingDuplicateGroupsInView),
// handler folds them without a query per group. What each copy carries — likes // newest first, flattened to one row per member so the handler folds them
// and plays from every user — is here because it is what the operator weighs // without a query per group. What each copy carries — likes and plays from
// when deciding which copy to keep. // every user — is here because it is what the operator weighs when deciding
// which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
// be removed without Lidarr downloading it again.
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) { func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.PageOffset, arg.PageLimit) rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.View, arg.PageOffset, arg.PageLimit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -402,6 +568,13 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
&i.Tier, &i.Tier,
&i.WorstBitErrorRate, &i.WorstBitErrorRate,
&i.DetectedAt, &i.DetectedAt,
&i.Class,
&i.ResolveNote,
&i.LidarrState,
&i.DiscNumber,
&i.TrackNumber,
&i.HasMbid,
&i.PositionClash,
&i.TrackID, &i.TrackID,
&i.Title, &i.Title,
&i.ArtistName, &i.ArtistName,
@@ -425,6 +598,64 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
return items, nil return items, nil
} }
const markDuplicateGroupResolvedAutomatically = `-- name: MarkDuplicateGroupResolvedAutomatically :exec
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = $1
`
func (q *Queries) MarkDuplicateGroupResolvedAutomatically(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markDuplicateGroupResolvedAutomatically, id)
return err
}
const setDuplicateGroupClasses = `-- name: SetDuplicateGroupClasses :exec
UPDATE duplicate_groups g
SET class = v.class, resolve_note = NULLIF(v.note, '')
FROM (SELECT unnest($1::uuid[]) AS id,
unnest($2::text[]) AS class,
unnest($3::text[]) AS note) v
WHERE g.id = v.id
AND g.status = 'pending'
AND (g.class IS DISTINCT FROM v.class OR g.resolve_note IS DISTINCT FROM NULLIF(v.note, ''))
`
type SetDuplicateGroupClassesParams struct {
GroupIds []pgtype.UUID
Classes []string
Notes []string
}
// The resolver's verdicts, written in one statement. Only rows whose class or
// note actually changed are touched, so an hourly pass over thousands of
// unchanged groups writes nothing. An empty note clears it.
func (q *Queries) SetDuplicateGroupClasses(ctx context.Context, arg SetDuplicateGroupClassesParams) error {
_, err := q.db.Exec(ctx, setDuplicateGroupClasses, arg.GroupIds, arg.Classes, arg.Notes)
return err
}
const setDuplicateMemberLidarrStates = `-- name: SetDuplicateMemberLidarrStates :exec
UPDATE duplicate_group_members m
SET lidarr_state = NULLIF(v.state, '')
FROM (SELECT unnest($1::uuid[]) AS group_id,
unnest($2::uuid[]) AS track_id,
unnest($3::text[]) AS state) v
WHERE m.group_id = v.group_id
AND m.track_id = v.track_id
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '')
`
type SetDuplicateMemberLidarrStatesParams struct {
GroupIds []pgtype.UUID
TrackIds []pgtype.UUID
States []string
}
// Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
// not consulted). Unchanged rows are left alone.
func (q *Queries) SetDuplicateMemberLidarrStates(ctx context.Context, arg SetDuplicateMemberLidarrStatesParams) error {
_, err := q.db.Exec(ctx, setDuplicateMemberLidarrStates, arg.GroupIds, arg.TrackIds, arg.States)
return err
}
const startDuplicateSweep = `-- name: StartDuplicateSweep :one const startDuplicateSweep = `-- name: StartDuplicateSweep :one
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
` `
+5 -1
View File
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
} }
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source FROM tracks t SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at FROM tracks t
JOIN play_events pe ON pe.track_id = t.id JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1 WHERE pe.session_id = $1
AND pe.started_at < $2 AND pe.started_at < $2
@@ -308,6 +308,10 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+7 -2
View File
@@ -10,7 +10,7 @@ import (
) )
const getFingerprintSettings = `-- name: GetFingerprintSettings :one const getFingerprintSettings = `-- name: GetFingerprintSettings :one
SELECT id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at FROM fingerprint_settings WHERE id = true SELECT id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at, auto_resolve FROM fingerprint_settings WHERE id = true
` `
func (q *Queries) GetFingerprintSettings(ctx context.Context) (FingerprintSetting, error) { func (q *Queries) GetFingerprintSettings(ctx context.Context) (FingerprintSetting, error) {
@@ -24,6 +24,7 @@ func (q *Queries) GetFingerprintSettings(ctx context.Context) (FingerprintSettin
&i.BackfillConcurrency, &i.BackfillConcurrency,
&i.SweepIntervalHours, &i.SweepIntervalHours,
&i.UpdatedAt, &i.UpdatedAt,
&i.AutoResolve,
) )
return i, err return i, err
} }
@@ -35,9 +36,10 @@ UPDATE fingerprint_settings
acoustic_max_bit_error_rate = $3, acoustic_max_bit_error_rate = $3,
backfill_concurrency = $4, backfill_concurrency = $4,
sweep_interval_hours = $5, sweep_interval_hours = $5,
auto_resolve = $6,
updated_at = now() updated_at = now()
WHERE id = true WHERE id = true
RETURNING id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at RETURNING id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at, auto_resolve
` `
type UpdateFingerprintSettingsParams struct { type UpdateFingerprintSettingsParams struct {
@@ -46,6 +48,7 @@ type UpdateFingerprintSettingsParams struct {
AcousticMaxBitErrorRate float64 AcousticMaxBitErrorRate float64
BackfillConcurrency int32 BackfillConcurrency int32
SweepIntervalHours int32 SweepIntervalHours int32
AutoResolve bool
} }
// Whole-row write from the admin card; migration 0061's CHECKs are the backstop // Whole-row write from the admin card; migration 0061's CHECKs are the backstop
@@ -57,6 +60,7 @@ func (q *Queries) UpdateFingerprintSettings(ctx context.Context, arg UpdateFinge
arg.AcousticMaxBitErrorRate, arg.AcousticMaxBitErrorRate,
arg.BackfillConcurrency, arg.BackfillConcurrency,
arg.SweepIntervalHours, arg.SweepIntervalHours,
arg.AutoResolve,
) )
var i FingerprintSetting var i FingerprintSetting
err := row.Scan( err := row.Scan(
@@ -67,6 +71,7 @@ func (q *Queries) UpdateFingerprintSettings(ctx context.Context, arg UpdateFinge
&i.BackfillConcurrency, &i.BackfillConcurrency,
&i.SweepIntervalHours, &i.SweepIntervalHours,
&i.UpdatedAt, &i.UpdatedAt,
&i.AutoResolve,
) )
return i, err return i, err
} }
+5 -1
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id, SELECT pe.id AS event_id,
pe.started_at, pe.started_at,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM play_events pe FROM play_events pe
@@ -82,6 +82,10 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.Track.SourceVerdict,
&i.Track.SourceVerdictAt,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
+70 -16
View File
@@ -34,9 +34,12 @@ func (q *Queries) CountLikedArtists(ctx context.Context, userID pgtype.UUID) (in
} }
const countLikedTracks = `-- name: CountLikedTracks :one const countLikedTracks = `-- name: CountLikedTracks :one
SELECT count(*) FROM general_likes WHERE user_id = $1 SELECT count(DISTINCT t.song_key) FROM general_likes l
JOIN tracks t ON t.id = l.track_id
WHERE l.user_id = $1
` `
// Liked songs, each counted once however many releases hold it.
func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) { func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countLikedTracks, userID) row := q.db.QueryRow(ctx, countLikedTracks, userID)
var count int64 var count int64
@@ -76,10 +79,13 @@ func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error {
return err return err
} }
const likeTrack = `-- name: LikeTrack :execrows const likeTrack = `-- name: LikeTrack :many
INSERT INTO general_likes (user_id, track_id) INSERT INTO general_likes (user_id, track_id)
VALUES ($1, $2) SELECT $1::uuid, t.id
FROM tracks t
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = $2::uuid)
ON CONFLICT (user_id, track_id) DO NOTHING ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING track_id
` `
type LikeTrackParams struct { type LikeTrackParams struct {
@@ -87,12 +93,27 @@ type LikeTrackParams struct {
TrackID pgtype.UUID TrackID pgtype.UUID
} }
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) (int64, error) { // A like is on the song (M498 #5438): every copy of it sharing the track's
result, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) // song_key is liked. Returns the tracks newly liked, for the sync log; empty
// when the song was already liked.
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, likeTrack, arg.UserID, arg.TrackID)
if err != nil { if err != nil {
return 0, err return nil, err
} }
return result.RowsAffected(), nil defer rows.Close()
var items []pgtype.UUID
for rows.Next() {
var track_id pgtype.UUID
if err := rows.Scan(&track_id); err != nil {
return nil, err
}
items = append(items, track_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
} }
const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
@@ -260,10 +281,15 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
} }
const listLikedTrackRows = `-- name: ListLikedTrackRows :many const listLikedTrackRows = `-- name: ListLikedTrackRows :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source FROM tracks t SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at FROM tracks t
JOIN general_likes l ON l.track_id = t.id JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
WHERE l.user_id = $1 FROM general_likes l
ORDER BY l.liked_at DESC JOIN tracks lt ON lt.id = l.track_id
WHERE l.user_id = $1
ORDER BY lt.song_key, (lt.missing_since IS NOT NULL),
(lt.source_verdict IS NOT DISTINCT FROM 'suspect'), l.liked_at, lt.id) l
ON l.track_id = t.id
ORDER BY l.liked_at DESC, t.id
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
` `
@@ -273,6 +299,8 @@ type ListLikedTrackRowsParams struct {
Offset int32 Offset int32
} }
// One row per liked song: of its copies, the one present on disk, then one
// that is not a held-back video rip, then the one liked first.
func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) { func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) {
rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset) rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset)
if err != nil { if err != nil {
@@ -303,6 +331,10 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -342,8 +374,13 @@ func (q *Queries) UnlikeArtist(ctx context.Context, arg UnlikeArtistParams) erro
return err return err
} }
const unlikeTrack = `-- name: UnlikeTrack :exec const unlikeTrack = `-- name: UnlikeTrack :many
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2 DELETE FROM general_likes
WHERE user_id = $1::uuid
AND (track_id = $2::uuid
OR track_id IN (SELECT t.id FROM tracks t
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = $2::uuid)))
RETURNING track_id
` `
type UnlikeTrackParams struct { type UnlikeTrackParams struct {
@@ -351,7 +388,24 @@ type UnlikeTrackParams struct {
TrackID pgtype.UUID TrackID pgtype.UUID
} }
func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) error { // Unlikes the song: every copy sharing the track's song_key. Returns the
_, err := q.db.Exec(ctx, unlikeTrack, arg.UserID, arg.TrackID) // tracks unliked, for the sync log.
return err func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, unlikeTrack, arg.UserID, arg.TrackID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []pgtype.UUID
for rows.Next() {
var track_id pgtype.UUID
if err := rows.Scan(&track_id); err != nil {
return nil, err
}
items = append(items, track_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
} }
+19 -10
View File
@@ -323,19 +323,23 @@ type DiscoverTuning struct {
} }
type DuplicateGroup struct { type DuplicateGroup struct {
ID pgtype.UUID ID pgtype.UUID
MemberKey string MemberKey string
Tier string Tier string
WorstBitErrorRate *float32 WorstBitErrorRate *float32
Status string Status string
DetectedAt pgtype.Timestamptz DetectedAt pgtype.Timestamptz
LastSeenSweepID pgtype.UUID LastSeenSweepID pgtype.UUID
ResolvedAt pgtype.Timestamptz ResolvedAt pgtype.Timestamptz
Class *string
ResolveNote *string
ResolvedAutomatically bool
} }
type DuplicateGroupMember struct { type DuplicateGroupMember struct {
GroupID pgtype.UUID GroupID pgtype.UUID
TrackID pgtype.UUID TrackID pgtype.UUID
LidarrState *string
} }
type DuplicateSweep struct { type DuplicateSweep struct {
@@ -356,6 +360,7 @@ type FingerprintSetting struct {
BackfillConcurrency int32 BackfillConcurrency int32
SweepIntervalHours int32 SweepIntervalHours int32
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
AutoResolve bool
} }
type GeneralLike struct { type GeneralLike struct {
@@ -749,6 +754,10 @@ type Track struct {
TagReadVersion int16 TagReadVersion int16
MissingSince pgtype.Timestamptz MissingSince pgtype.Timestamptz
MbidSource *string MbidSource *string
SongID pgtype.UUID
SongKey pgtype.UUID
SourceVerdict *string
SourceVerdictAt pgtype.Timestamptz
} }
type TrackAcoustidLookup struct { type TrackAcoustidLookup struct {
+20 -4
View File
@@ -208,7 +208,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -271,6 +271,10 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.Track.SourceVerdict,
&i.Track.SourceVerdictAt,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -291,7 +295,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -356,6 +360,10 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.Track.SourceVerdict,
&i.Track.SourceVerdictAt,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -695,7 +703,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
@@ -777,6 +785,10 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.Track.SourceVerdict,
&i.Track.SourceVerdictAt,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
@@ -925,7 +937,7 @@ random_fill AS (
LIMIT $9 LIMIT $9
) )
SELECT SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
@@ -1053,6 +1065,10 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.Track.SourceVerdict,
&i.Track.SourceVerdictAt,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
+154
View File
@@ -0,0 +1,154 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: songs.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const linkTracksAsSong = `-- name: LinkTracksAsSong :one
WITH keys AS (
SELECT DISTINCT song_key FROM tracks WHERE id = ANY($1::uuid[])
), target AS (
SELECT song_key FROM keys ORDER BY song_key LIMIT 1
), moved AS (
UPDATE tracks t
SET song_id = (SELECT song_key FROM target)
WHERE t.song_key IN (SELECT song_key FROM keys)
AND t.song_key <> (SELECT song_key FROM target)
RETURNING t.id
)
SELECT (SELECT song_key FROM target)::uuid AS song_key,
(SELECT count(*) FROM moved)::bigint AS moved
`
type LinkTracksAsSongRow struct {
SongKey pgtype.UUID
Moved int64
}
// Song links (M498 #5438): copies of one song on different releases share a
// song_key (migration 0076).
// Links the tracks into one song, joining any songs they already belong to.
// The song keeps the lowest key among them, so linking the same set again
// changes nothing. Returns that key and how many tracks moved to it.
func (q *Queries) LinkTracksAsSong(ctx context.Context, trackIds []pgtype.UUID) (LinkTracksAsSongRow, error) {
row := q.db.QueryRow(ctx, linkTracksAsSong, trackIds)
var i LinkTracksAsSongRow
err := row.Scan(&i.SongKey, &i.Moved)
return i, err
}
const listTrackSongKeys = `-- name: ListTrackSongKeys :many
SELECT id, song_key, (source_verdict IS NOT DISTINCT FROM 'suspect')::boolean AS held_back
FROM tracks WHERE id = ANY($1::uuid[])
`
type ListTrackSongKeysRow struct {
ID pgtype.UUID
SongKey pgtype.UUID
HeldBack bool
}
// What the mix writer needs to place a track: its song, and whether it is a
// video rip held back from the mixes (#5439).
func (q *Queries) ListTrackSongKeys(ctx context.Context, ids []pgtype.UUID) ([]ListTrackSongKeysRow, error) {
rows, err := q.db.Query(ctx, listTrackSongKeys, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTrackSongKeysRow
for rows.Next() {
var i ListTrackSongKeysRow
if err := rows.Scan(&i.ID, &i.SongKey, &i.HeldBack); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const mergeInheritSongLink = `-- name: MergeInheritSongLink :exec
UPDATE tracks s
SET song_id = l.song_key
FROM tracks l
WHERE s.id = $1::uuid
AND l.id = $2::uuid
AND s.song_id IS NULL
AND l.song_key <> l.id
`
type MergeInheritSongLinkParams struct {
SurvivorID pgtype.UUID
LoserID pgtype.UUID
}
// A merge keeps the removed copy's song link: when the copy being removed was
// linked to copies on other releases and the survivor was not, the survivor
// joins that song.
func (q *Queries) MergeInheritSongLink(ctx context.Context, arg MergeInheritSongLinkParams) error {
_, err := q.db.Exec(ctx, mergeInheritSongLink, arg.SurvivorID, arg.LoserID)
return err
}
const shareSongLikes = `-- name: ShareSongLikes :many
INSERT INTO general_likes (user_id, track_id, liked_at)
SELECT l.user_id, t.id, min(l.liked_at)
FROM general_likes l
JOIN tracks lt ON lt.id = l.track_id
JOIN tracks t ON t.song_key = lt.song_key
WHERE lt.song_key = $1::uuid
GROUP BY l.user_id, t.id
ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING user_id, track_id
`
type ShareSongLikesRow struct {
UserID pgtype.UUID
TrackID pgtype.UUID
}
// A like on any copy of the song becomes a like on every copy, dated the
// earliest. Returns the likes added, for the sync log.
func (q *Queries) ShareSongLikes(ctx context.Context, songKey pgtype.UUID) ([]ShareSongLikesRow, error) {
rows, err := q.db.Query(ctx, shareSongLikes, songKey)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShareSongLikesRow
for rows.Next() {
var i ShareSongLikesRow
if err := rows.Scan(&i.UserID, &i.TrackID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const unlinkDuplicateGroupSongs = `-- name: UnlinkDuplicateGroupSongs :exec
UPDATE tracks SET song_id = NULL
WHERE song_id IS NOT NULL
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = $1)
`
// "Not the same song": the group's copies stop sharing a song. Likes already
// shared stay where they are.
func (q *Queries) UnlinkDuplicateGroupSongs(ctx context.Context, groupID pgtype.UUID) error {
_, err := q.db.Exec(ctx, unlinkDuplicateGroupSongs, groupID)
return err
}
+201 -8
View File
@@ -39,6 +39,23 @@ func (q *Queries) AdoptTrackPath(ctx context.Context, arg AdoptTrackPathParams)
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const clearStaleSuspectSourceTracks = `-- name: ClearStaleSuspectSourceTracks :execrows
UPDATE tracks
SET source_verdict = NULL, source_verdict_at = NULL
WHERE source_verdict = 'suspect'
AND NOT regexp_replace(file_path, '^.*/', '') ~* $1::text
`
// A held-back track whose name no longer carries a marker (renamed, retagged
// by Lidarr) is released again.
func (q *Queries) ClearStaleSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
result, err := q.db.Exec(ctx, clearStaleSuspectSourceTracks, pattern)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const clearTracksMissing = `-- name: ClearTracksMissing :execrows const clearTracksMissing = `-- name: ClearTracksMissing :execrows
UPDATE tracks UPDATE tracks
SET missing_since = NULL SET missing_since = NULL
@@ -70,6 +87,20 @@ func (q *Queries) CountMissingTracks(ctx context.Context) (int64, error) {
return count, err return count, err
} }
const countSuspectSourceTracks = `-- name: CountSuspectSourceTracks :one
SELECT COUNT(*) FROM tracks t
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* $1::text
`
// Total for the report's paging; the same filter as the list above.
func (q *Queries) CountSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
row := q.db.QueryRow(ctx, countSuspectSourceTracks, pattern)
var count int64
err := row.Scan(&count)
return count, err
}
const countTracksByAlbum = `-- name: CountTracksByAlbum :one const countTracksByAlbum = `-- name: CountTracksByAlbum :one
SELECT count(*) FROM tracks WHERE album_id = $1 SELECT count(*) FROM tracks WHERE album_id = $1
` `
@@ -232,8 +263,27 @@ func (q *Queries) FindMissingTrackByMbid(ctx context.Context, mbid string) ([]Fi
return items, nil return items, nil
} }
const flagSuspectSourceTracks = `-- name: FlagSuspectSourceTracks :execrows
UPDATE tracks
SET source_verdict = 'suspect', source_verdict_at = now()
WHERE missing_since IS NULL
AND source_verdict IS NULL
AND regexp_replace(file_path, '^.*/', '') ~* $1::text
`
// Holds back present tracks whose basename carries a video-rip marker (M498
// #5439), the same pattern the report lists. A track the operator called fine
// keeps that verdict.
func (q *Queries) FlagSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
result, err := q.db.Exec(ctx, flagSuspectSourceTracks, pattern)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const getTrackByID = `-- name: GetTrackByID :one const getTrackByID = `-- name: GetTrackByID :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = $1 SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE id = $1
` `
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) { func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
@@ -260,12 +310,16 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
) )
return i, err return i, err
} }
const getTrackByPath = `-- name: GetTrackByPath :one const getTrackByPath = `-- name: GetTrackByPath :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE file_path = $1 SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE file_path = $1
` `
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) { func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
@@ -292,12 +346,16 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
) )
return i, err return i, err
} }
const getTracksByIDs = `-- name: GetTracksByIDs :many const getTracksByIDs = `-- name: GetTracksByIDs :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = ANY($1::uuid[]) SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE id = ANY($1::uuid[])
` `
// Batched lookup used by /api/library/sync to hydrate upsert payloads // Batched lookup used by /api/library/sync to hydrate upsert payloads
@@ -332,6 +390,10 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -344,7 +406,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
} }
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM tracks t FROM tracks t
@@ -405,6 +467,10 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.Track.SourceVerdict,
&i.Track.SourceVerdictAt,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -505,7 +571,7 @@ func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksPa
} }
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM tracks t FROM tracks t
@@ -563,6 +629,10 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.Track.SourceVerdict,
&i.Track.SourceVerdictAt,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -576,6 +646,95 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
return items, nil return items, nil
} }
const listSuspectSourceTracks = `-- name: ListSuspectSourceTracks :many
SELECT t.id,
t.title,
t.file_path,
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
t.duration_ms,
t.disc_number,
t.track_number,
albums.id AS album_id,
albums.title AS album_title,
artists.id AS artist_id,
artists.name AS artist_name,
t.source_verdict,
t.source_verdict_at
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* $1::text
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT $3 OFFSET $2
`
type ListSuspectSourceTracksParams struct {
Pattern string
PageOffset int32
PageLimit int32
}
type ListSuspectSourceTracksRow struct {
ID pgtype.UUID
Title string
FilePath string
Directory string
DurationMs int32
DiscNumber *int32
TrackNumber *int32
AlbumID pgtype.UUID
AlbumTitle string
ArtistID pgtype.UUID
ArtistName string
SourceVerdict *string
SourceVerdictAt pgtype.Timestamptz
}
// The admin report of present tracks whose filename looks like a video rip
// (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
//
// pattern is built in Go (internal/api/admin_suspect_sources.go) from the
// same marker list that labels each row, so the filter and the labels cannot
// drift. It is matched against the basename only: a folder called "Reaction
// Sessions" says nothing about how a file was sourced.
//
// Ordered by directory, like ListMissingTracks, so the handler's fold gets
// each folder as one contiguous run.
func (q *Queries) ListSuspectSourceTracks(ctx context.Context, arg ListSuspectSourceTracksParams) ([]ListSuspectSourceTracksRow, error) {
rows, err := q.db.Query(ctx, listSuspectSourceTracks, arg.Pattern, arg.PageOffset, arg.PageLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSuspectSourceTracksRow
for rows.Next() {
var i ListSuspectSourceTracksRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.FilePath,
&i.Directory,
&i.DurationMs,
&i.DiscNumber,
&i.TrackNumber,
&i.AlbumID,
&i.AlbumTitle,
&i.ArtistID,
&i.ArtistName,
&i.SourceVerdict,
&i.SourceVerdictAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
SELECT id, file_path, missing_since FROM tracks SELECT id, file_path, missing_since FROM tracks
` `
@@ -612,7 +771,7 @@ func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPa
} }
const listTracksByAlbum = `-- name: ListTracksByAlbum :many const listTracksByAlbum = `-- name: ListTracksByAlbum :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
WHERE album_id = $1 WHERE album_id = $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
@@ -659,6 +818,10 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -732,7 +895,7 @@ func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int
} }
const searchTracks = `-- name: SearchTracks :many const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
WHERE title ILIKE '%' || $1::text || '%' WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
@@ -786,6 +949,10 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -815,6 +982,28 @@ func (q *Queries) SetTrackMbidIfNull(ctx context.Context, arg SetTrackMbidIfNull
return err return err
} }
const setTrackSourceVerdict = `-- name: SetTrackSourceVerdict :execrows
UPDATE tracks
SET source_verdict = $1::text, source_verdict_at = now()
WHERE id = $2
AND source_verdict IS NOT NULL
`
type SetTrackSourceVerdictParams struct {
Verdict string
ID pgtype.UUID
}
// The operator's call on a flagged track: 'fine' lets it back into radio and
// the mixes for good; 'suspect' holds it back again.
func (q *Queries) SetTrackSourceVerdict(ctx context.Context, arg SetTrackSourceVerdictParams) (int64, error) {
result, err := q.db.Exec(ctx, setTrackSourceVerdict, arg.Verdict, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const upsertTrack = `-- name: UpsertTrack :one const upsertTrack = `-- name: UpsertTrack :one
INSERT INTO tracks ( INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number, title, album_id, artist_id, track_number, disc_number,
@@ -842,7 +1031,7 @@ ON CONFLICT (file_path) DO UPDATE SET
-- next scan can short-circuit them again (#2499). -- next scan can short-circuit them again (#2499).
tag_read_version = EXCLUDED.tag_read_version, tag_read_version = EXCLUDED.tag_read_version,
updated_at = now() updated_at = now()
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at
` `
type UpsertTrackParams struct { type UpsertTrackParams struct {
@@ -902,6 +1091,10 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
&i.SourceVerdict,
&i.SourceVerdictAt,
) )
return i, err return i, err
} }
@@ -0,0 +1,17 @@
DELETE FROM user_notifications WHERE kind = 'duplicates_resolved';
DELETE FROM user_notification_prefs WHERE kind = 'duplicates_resolved';
ALTER TABLE user_notification_prefs DROP CONSTRAINT user_notification_prefs_kind_check;
ALTER TABLE user_notification_prefs ADD CONSTRAINT user_notification_prefs_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'playback_errors'));
ALTER TABLE user_notifications DROP CONSTRAINT user_notifications_kind_check;
ALTER TABLE user_notifications ADD CONSTRAINT user_notifications_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'playback_errors'));
ALTER TABLE fingerprint_settings DROP COLUMN IF EXISTS auto_resolve;
ALTER TABLE duplicate_group_members DROP COLUMN IF EXISTS lidarr_state;
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS resolved_automatically;
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS resolve_note;
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS class;
@@ -0,0 +1,56 @@
-- 0075_duplicate_resolve.up.sql — duplicates resolve themselves (Scribe
-- milestone #498: #5435, #5436, #5437).
--
-- The sweep (0059) proposes groups and, until now, every proposal waited for the
-- operator. The resolver classifies each pending group and acts on the classes
-- that are safe. What makes deletion safe is Lidarr's view of the file: Lidarr
-- maps one file to each track of the release it monitors, so deleting a mapped
-- file opens a hole it fills by downloading again. Only a copy Lidarr does not
-- map may go.
-- What kind of duplicate a group is. NULL until the resolver has classified it.
-- same_release every copy is on one album
-- cross_release the same song on different releases (a single and its album)
-- mismatch identical audio under different titles on different albums:
-- a file imported as the wrong song
-- review anything else: the operator decides
-- Rule 36: a new value swaps this constraint in the same migration.
ALTER TABLE duplicate_groups ADD COLUMN class text;
ALTER TABLE duplicate_groups ADD CONSTRAINT duplicate_groups_class_check
CHECK (class IN ('same_release', 'cross_release', 'mismatch', 'review'));
-- Why the resolver left a group for the operator, in words the report shows
-- ("Lidarr tracks every copy as its own track"). NULL when it had nothing to say.
ALTER TABLE duplicate_groups ADD COLUMN resolve_note text;
-- True when the resolver merged the group itself, so the report can tell its
-- merges from the operator's.
ALTER TABLE duplicate_groups ADD COLUMN resolved_automatically boolean NOT NULL DEFAULT false;
-- Lidarr's view of each copy, as of the resolver's last pass:
-- tracked Lidarr holds the file and it is not among its unmapped files
-- unmapped Lidarr holds the file but maps it to no track: removing it opens
-- no hole
-- NULL when Lidarr was not consulted (disabled, or unreachable that pass).
ALTER TABLE duplicate_group_members ADD COLUMN lidarr_state text;
ALTER TABLE duplicate_group_members ADD CONSTRAINT duplicate_group_members_lidarr_state_check
CHECK (lidarr_state IN ('tracked', 'unmapped'));
-- Rule 25: the operator can turn the resolver off. On by default: the operator
-- asked for duplicates to resolve themselves (2026-10-09).
ALTER TABLE fingerprint_settings ADD COLUMN auto_resolve boolean NOT NULL DEFAULT true;
-- The resolver tells admins what it did (rule 36: the kind CHECKs of 0073 are
-- swapped to carry the new value). Postgres named both from their columns.
ALTER TABLE user_notifications DROP CONSTRAINT user_notifications_kind_check;
ALTER TABLE user_notifications ADD CONSTRAINT user_notifications_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'duplicates_resolved',
'playback_errors'));
ALTER TABLE user_notification_prefs DROP CONSTRAINT user_notification_prefs_kind_check;
ALTER TABLE user_notification_prefs ADD CONSTRAINT user_notification_prefs_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'duplicates_resolved',
'playback_errors'));
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS tracks_song_key_idx;
ALTER TABLE tracks DROP COLUMN IF EXISTS song_key;
ALTER TABLE tracks DROP COLUMN IF EXISTS song_id;
@@ -0,0 +1,15 @@
-- 0076_track_song_link.up.sql — the same song on several releases counts as
-- one song (Scribe milestone #498, #5438; operator decision D-b).
--
-- A single and the album it came from each hold the song, and Lidarr keeps
-- both: each fulfils its own release. Minstrel keeps both files and links
-- them, so a like on one is a like on the song, and a mix or radio picks the
-- song once rather than once per release.
--
-- song_id is set only on a linked track, to the song_key the link chose.
-- song_key is what everything reads: the track's own id until it is linked.
-- No foreign key: the track whose id names a song may itself be merged away
-- later, and the key still groups the copies left.
ALTER TABLE tracks ADD COLUMN song_id uuid;
ALTER TABLE tracks ADD COLUMN song_key uuid GENERATED ALWAYS AS (COALESCE(song_id, id)) STORED;
CREATE INDEX tracks_song_key_idx ON tracks (song_key);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS tracks_source_suspect_idx;
ALTER TABLE tracks DROP COLUMN IF EXISTS source_verdict_at;
ALTER TABLE tracks DROP COLUMN IF EXISTS source_verdict;
@@ -0,0 +1,15 @@
-- 0077_track_source_verdict.up.sql — video rips are held back from mixes and
-- radio until the operator says otherwise (Scribe milestone #498, #5439;
-- operator decision D-d).
--
-- A file whose name carries a video-rip marker ("(Official Video)", "[HD]",
-- #5410) holds the audio of an upload, which can carry intros, skits or a
-- different take. The duplicate resolver sets 'suspect' on such a present
-- track each pass, and clears it when the name no longer matches. 'suspect'
-- keeps the track out of radio and the system mixes; it still plays when
-- chosen. 'fine' is the operator's "this one is fine" and is never
-- overwritten by the resolver.
ALTER TABLE tracks ADD COLUMN source_verdict text
CONSTRAINT tracks_source_verdict_check CHECK (source_verdict IN ('suspect', 'fine'));
ALTER TABLE tracks ADD COLUMN source_verdict_at timestamptz;
CREATE INDEX tracks_source_suspect_idx ON tracks (id) WHERE source_verdict = 'suspect';
+16
View File
@@ -6,3 +6,19 @@ VALUES ($1, $2, $3, $4);
SELECT * FROM audit_log SELECT * FROM audit_log
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 OFFSET $2; LIMIT $1 OFFSET $2;
-- name: CountAuditLogByActions :one
-- System actions carry no actor; system_only narrows to them.
SELECT count(*)::bigint FROM audit_log
WHERE action = ANY(sqlc.arg(actions)::text[])
AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL);
-- name: ListAuditLogByActions :many
-- One page of the given actions, newest first: the duplicates report's
-- "resolved automatically" activity (M498) reads the resolver's merges and
-- Lidarr release changes from here.
SELECT * FROM audit_log
WHERE action = ANY(sqlc.arg(actions)::text[])
AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL)
ORDER BY created_at DESC, id
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
+104 -5
View File
@@ -112,16 +112,36 @@ SELECT count(*)::bigint
WHERE g.status = 'pending' WHERE g.status = 'pending'
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2; AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2;
-- name: CountPendingDuplicateGroupsInView :one
-- CountPendingDuplicateGroups narrowed to one view of the report (M498):
-- review what the operator still has to decide: every class but
-- cross_release, and groups not yet classified
-- cross_release the same song on different releases, which is kept, not merged
SELECT count(*)::bigint
FROM duplicate_groups g
WHERE g.status = 'pending'
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
AND CASE sqlc.arg(view)::text
WHEN 'cross_release' THEN g.class = 'cross_release'
ELSE g.class IS DISTINCT FROM 'cross_release'
END;
-- name: ListPendingDuplicateGroupMembers :many -- name: ListPendingDuplicateGroupMembers :many
-- One page of proposals, newest first, flattened to one row per member so the -- One page of proposals in one view (see CountPendingDuplicateGroupsInView),
-- handler folds them without a query per group. What each copy carries — likes -- newest first, flattened to one row per member so the handler folds them
-- and plays from every user — is here because it is what the operator weighs -- without a query per group. What each copy carries — likes and plays from
-- when deciding which copy to keep. -- every user — is here because it is what the operator weighs when deciding
-- which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
-- be removed without Lidarr downloading it again.
WITH page AS ( WITH page AS (
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note
FROM duplicate_groups g FROM duplicate_groups g
WHERE g.status = 'pending' WHERE g.status = 'pending'
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2 AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
AND CASE sqlc.arg(view)::text
WHEN 'cross_release' THEN g.class = 'cross_release'
ELSE g.class IS DISTINCT FROM 'cross_release'
END
ORDER BY g.detected_at DESC, g.id ORDER BY g.detected_at DESC, g.id
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset) LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset)
) )
@@ -129,6 +149,17 @@ SELECT p.id AS group_id,
p.tier, p.tier,
p.worst_bit_error_rate, p.worst_bit_error_rate,
p.detected_at, p.detected_at,
p.class,
p.resolve_note,
m.lidarr_state,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
EXISTS (SELECT 1 FROM tracks o
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
AND t.track_number IS NOT NULL
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
AND o.track_number = t.track_number)::boolean AS position_clash,
t.id AS track_id, t.id AS track_id,
t.title, t.title,
artists.name AS artist_name, artists.name AS artist_name,
@@ -164,3 +195,71 @@ SELECT count(*)::bigint
FROM duplicate_groups FROM duplicate_groups
WHERE status = 'pending' WHERE status = 'pending'
AND detected_at >= sqlc.arg(since); AND detected_at >= sqlc.arg(since);
-- name: ListDuplicateGroupsForResolve :many
-- Every pending group's present copies, for the resolver (M498 #5435): what it
-- needs to classify the group and to choose the copy to keep. One row per copy,
-- grouped by group. position_clash is true when another present track on the
-- same album claims the same disc and track number: tags that do not fit the
-- album, which the survivor rule weighs against a copy.
SELECT g.id AS group_id,
g.tier,
t.id AS track_id,
t.album_id,
t.title,
artists.name AS artist_name,
t.file_path,
t.file_format,
t.file_size,
t.added_at,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
albums.release_group_mbid,
albums.title AS album_title,
EXISTS (SELECT 1 FROM tracks o
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
AND t.track_number IS NOT NULL
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
AND o.track_number = t.track_number)::boolean AS position_clash
FROM duplicate_groups g
JOIN duplicate_group_members m ON m.group_id = g.id
JOIN tracks t ON t.id = m.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE g.status = 'pending'
AND t.missing_since IS NULL
ORDER BY g.id, t.id;
-- name: SetDuplicateGroupClasses :exec
-- The resolver's verdicts, written in one statement. Only rows whose class or
-- note actually changed are touched, so an hourly pass over thousands of
-- unchanged groups writes nothing. An empty note clears it.
UPDATE duplicate_groups g
SET class = v.class, resolve_note = NULLIF(v.note, '')
FROM (SELECT unnest(sqlc.arg(group_ids)::uuid[]) AS id,
unnest(sqlc.arg(classes)::text[]) AS class,
unnest(sqlc.arg(notes)::text[]) AS note) v
WHERE g.id = v.id
AND g.status = 'pending'
AND (g.class IS DISTINCT FROM v.class OR g.resolve_note IS DISTINCT FROM NULLIF(v.note, ''));
-- name: SetDuplicateMemberLidarrStates :exec
-- Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
-- not consulted). Unchanged rows are left alone.
UPDATE duplicate_group_members m
SET lidarr_state = NULLIF(v.state, '')
FROM (SELECT unnest(sqlc.arg(group_ids)::uuid[]) AS group_id,
unnest(sqlc.arg(track_ids)::uuid[]) AS track_id,
unnest(sqlc.arg(states)::text[]) AS state) v
WHERE m.group_id = v.group_id
AND m.track_id = v.track_id
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '');
-- name: MarkDuplicateGroupResolvedAutomatically :exec
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = sqlc.arg(id);
-- name: ListAlbumPresentTrackTitles :many
-- The titles an album has on disk, for choosing the Lidarr release that covers
-- them best (M498 #5437).
SELECT title FROM tracks WHERE album_id = sqlc.arg(album_id) AND missing_since IS NULL;
@@ -10,6 +10,7 @@ UPDATE fingerprint_settings
acoustic_max_bit_error_rate = sqlc.arg(acoustic_max_bit_error_rate), acoustic_max_bit_error_rate = sqlc.arg(acoustic_max_bit_error_rate),
backfill_concurrency = sqlc.arg(backfill_concurrency), backfill_concurrency = sqlc.arg(backfill_concurrency),
sweep_interval_hours = sqlc.arg(sweep_interval_hours), sweep_interval_hours = sqlc.arg(sweep_interval_hours),
auto_resolve = sqlc.arg(auto_resolve),
updated_at = now() updated_at = now()
WHERE id = true WHERE id = true
RETURNING *; RETURNING *;
+32 -9
View File
@@ -1,20 +1,43 @@
-- name: LikeTrack :execrows -- name: LikeTrack :many
-- A like is on the song (M498 #5438): every copy of it sharing the track's
-- song_key is liked. Returns the tracks newly liked, for the sync log; empty
-- when the song was already liked.
INSERT INTO general_likes (user_id, track_id) INSERT INTO general_likes (user_id, track_id)
VALUES ($1, $2) SELECT sqlc.arg(user_id)::uuid, t.id
ON CONFLICT (user_id, track_id) DO NOTHING; FROM tracks t
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = sqlc.arg(track_id)::uuid)
ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING track_id;
-- name: UnlikeTrack :exec -- name: UnlikeTrack :many
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2; -- Unlikes the song: every copy sharing the track's song_key. Returns the
-- tracks unliked, for the sync log.
DELETE FROM general_likes
WHERE user_id = sqlc.arg(user_id)::uuid
AND (track_id = sqlc.arg(track_id)::uuid
OR track_id IN (SELECT t.id FROM tracks t
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = sqlc.arg(track_id)::uuid)))
RETURNING track_id;
-- name: ListLikedTrackRows :many -- name: ListLikedTrackRows :many
-- One row per liked song: of its copies, the one present on disk, then one
-- that is not a held-back video rip, then the one liked first.
SELECT t.* FROM tracks t SELECT t.* FROM tracks t
JOIN general_likes l ON l.track_id = t.id JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
WHERE l.user_id = $1 FROM general_likes l
ORDER BY l.liked_at DESC JOIN tracks lt ON lt.id = l.track_id
WHERE l.user_id = $1
ORDER BY lt.song_key, (lt.missing_since IS NOT NULL),
(lt.source_verdict IS NOT DISTINCT FROM 'suspect'), l.liked_at, lt.id) l
ON l.track_id = t.id
ORDER BY l.liked_at DESC, t.id
LIMIT $2 OFFSET $3; LIMIT $2 OFFSET $3;
-- name: CountLikedTracks :one -- name: CountLikedTracks :one
SELECT count(*) FROM general_likes WHERE user_id = $1; -- Liked songs, each counted once however many releases hold it.
SELECT count(DISTINCT t.song_key) FROM general_likes l
JOIN tracks t ON t.id = l.track_id
WHERE l.user_id = $1;
-- name: ListLikedTrackIDs :many -- name: ListLikedTrackIDs :many
SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC; SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC;
+58
View File
@@ -0,0 +1,58 @@
-- Song links (M498 #5438): copies of one song on different releases share a
-- song_key (migration 0076).
-- name: LinkTracksAsSong :one
-- Links the tracks into one song, joining any songs they already belong to.
-- The song keeps the lowest key among them, so linking the same set again
-- changes nothing. Returns that key and how many tracks moved to it.
WITH keys AS (
SELECT DISTINCT song_key FROM tracks WHERE id = ANY(sqlc.arg(track_ids)::uuid[])
), target AS (
SELECT song_key FROM keys ORDER BY song_key LIMIT 1
), moved AS (
UPDATE tracks t
SET song_id = (SELECT song_key FROM target)
WHERE t.song_key IN (SELECT song_key FROM keys)
AND t.song_key <> (SELECT song_key FROM target)
RETURNING t.id
)
SELECT (SELECT song_key FROM target)::uuid AS song_key,
(SELECT count(*) FROM moved)::bigint AS moved;
-- name: ShareSongLikes :many
-- A like on any copy of the song becomes a like on every copy, dated the
-- earliest. Returns the likes added, for the sync log.
INSERT INTO general_likes (user_id, track_id, liked_at)
SELECT l.user_id, t.id, min(l.liked_at)
FROM general_likes l
JOIN tracks lt ON lt.id = l.track_id
JOIN tracks t ON t.song_key = lt.song_key
WHERE lt.song_key = sqlc.arg(song_key)::uuid
GROUP BY l.user_id, t.id
ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING user_id, track_id;
-- name: UnlinkDuplicateGroupSongs :exec
-- "Not the same song": the group's copies stop sharing a song. Likes already
-- shared stay where they are.
UPDATE tracks SET song_id = NULL
WHERE song_id IS NOT NULL
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = sqlc.arg(group_id));
-- name: ListTrackSongKeys :many
-- What the mix writer needs to place a track: its song, and whether it is a
-- video rip held back from the mixes (#5439).
SELECT id, song_key, (source_verdict IS NOT DISTINCT FROM 'suspect')::boolean AS held_back
FROM tracks WHERE id = ANY(sqlc.arg(ids)::uuid[]);
-- name: MergeInheritSongLink :exec
-- A merge keeps the removed copy's song link: when the copy being removed was
-- linked to copies on other releases and the survivor was not, the survivor
-- joins that song.
UPDATE tracks s
SET song_id = l.song_key
FROM tracks l
WHERE s.id = sqlc.arg(survivor_id)::uuid
AND l.id = sqlc.arg(loser_id)::uuid
AND s.song_id IS NULL
AND l.song_key <> l.id;
+64
View File
@@ -263,3 +263,67 @@ SELECT t.id,
-- Total for the admin surface's badge and paging. Uses the same partial index -- Total for the admin surface's badge and paging. Uses the same partial index
-- (tracks_missing_since_idx) as the list above. -- (tracks_missing_since_idx) as the list above.
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL; SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
-- name: ListSuspectSourceTracks :many
-- The admin report of present tracks whose filename looks like a video rip
-- (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
--
-- pattern is built in Go (internal/api/admin_suspect_sources.go) from the
-- same marker list that labels each row, so the filter and the labels cannot
-- drift. It is matched against the basename only: a folder called "Reaction
-- Sessions" says nothing about how a file was sourced.
--
-- Ordered by directory, like ListMissingTracks, so the handler's fold gets
-- each folder as one contiguous run.
SELECT t.id,
t.title,
t.file_path,
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
t.duration_ms,
t.disc_number,
t.track_number,
albums.id AS album_id,
albums.title AS album_title,
artists.id AS artist_id,
artists.name AS artist_name,
t.source_verdict,
t.source_verdict_at
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
-- name: CountSuspectSourceTracks :one
-- Total for the report's paging; the same filter as the list above.
SELECT COUNT(*) FROM tracks t
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: FlagSuspectSourceTracks :execrows
-- Holds back present tracks whose basename carries a video-rip marker (M498
-- #5439), the same pattern the report lists. A track the operator called fine
-- keeps that verdict.
UPDATE tracks
SET source_verdict = 'suspect', source_verdict_at = now()
WHERE missing_since IS NULL
AND source_verdict IS NULL
AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: ClearStaleSuspectSourceTracks :execrows
-- A held-back track whose name no longer carries a marker (renamed, retagged
-- by Lidarr) is released again.
UPDATE tracks
SET source_verdict = NULL, source_verdict_at = NULL
WHERE source_verdict = 'suspect'
AND NOT regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: SetTrackSourceVerdict :execrows
-- The operator's call on a flagged track: 'fine' lets it back into radio and
-- the mixes for good; 'suspect' holds it back again.
UPDATE tracks
SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now()
WHERE id = sqlc.arg(id)
AND source_verdict IS NOT NULL;
+143
View File
@@ -0,0 +1,143 @@
package library
import (
"regexp"
"strings"
"unicode"
)
// Duplicate classification (M498 #5435).
//
// A duplicate group is one recording found more than once. What should happen
// to it depends on where the copies sit, and the measured library (milestone
// 498's body) splits into four shapes:
//
// - same_release: every copy is on one album. An album holds a song once, so
// the extra copies are the ones to fold away — when Lidarr does not need
// them (the resolver checks).
// - cross_release: the copies are on different albums under one title. A
// single and the album it came from, a deluxe edition and the standard one:
// each copy fulfils its own release in Lidarr, so none is deleted.
// - mismatch: byte-identical audio on different albums under different
// titles. One file carries another song's tags — a wrong-file import.
// - review: anything else. The operator decides.
// DuplicateClass is a group's shape. The values are duplicate_groups.class.
type DuplicateClass string
const (
ClassSameRelease DuplicateClass = "same_release"
ClassCrossRelease DuplicateClass = "cross_release"
ClassMismatch DuplicateClass = "mismatch"
ClassReview DuplicateClass = "review"
)
// ClassifyMember is what classifying needs to know about one copy.
type ClassifyMember struct {
AlbumID string
Title string
ArtistName string
}
// ClassifyDuplicateGroup decides a group's shape from its tier ("exact" or
// "acoustic") and its copies.
//
// Titles are compared by MatchTitleKey, which drops what a release or an upload
// adds to a title (featuring credits, remaster notes, video markers) and keeps
// what names a different recording (live, demo, remix, instrumental). So one
// album holding "WWW" and "WWW (instrumental)" as an acoustic match is review,
// not a copy to fold away: the #3885 case, where they were in fact different
// recordings mislabelled. Byte-identical copies on one album are the same file
// twice whatever their tags say.
func ClassifyDuplicateGroup(tier string, members []ClassifyMember) DuplicateClass {
if len(members) < 2 {
return ClassReview
}
oneAlbum, oneTitle := true, true
key := MatchTitleKey(members[0].Title, members[0].ArtistName)
for _, m := range members[1:] {
if m.AlbumID != members[0].AlbumID {
oneAlbum = false
}
if MatchTitleKey(m.Title, m.ArtistName) != key {
oneTitle = false
}
}
exact := tier == string(tierExact)
switch {
case oneAlbum && (oneTitle || exact):
return ClassSameRelease
case oneAlbum:
return ClassReview
case oneTitle:
return ClassCrossRelease
case exact:
return ClassMismatch
default:
return ClassReview
}
}
var (
// A bracketed credit: "(feat. Panda Bear)", "[ft. X]", "(featuring Y)".
featBracket = regexp.MustCompile(`(?i)[(\[][^)\]]*\b(feat\.?|ft\.?|featuring)\s[^)\]]*[)\]]`)
// A credit trailing the title without brackets: "Doin' It Right ft. Panda Bear".
featTrailing = regexp.MustCompile(`(?i)\s(feat\.?|ft\.?|featuring)\s.*$`)
// "(2011 Remaster)", "[Remastered]", "- Remastered 2009".
remasterBracket = regexp.MustCompile(`(?i)[(\[][^)\]]*remaster[^)\]]*[)\]]`)
remasterSuffix = regexp.MustCompile(`(?i)\s-\s[^-]*remaster.*$`)
// Any bracketed segment, to test each against the video-rip markers.
bracketed = regexp.MustCompile(`[(\[][^)\]]*[)\]]`)
)
// MatchTitleKey reduces a title to what identifies the song, for deciding
// whether two copies carry the same one.
//
// Dropped: a leading "Artist - " (a video upload's title), bracketed featuring
// credits and remaster notes, and bracketed segments carrying a video-rip marker
// ("(Official Video)", "[HD]"). Kept: every other bracketed word, so "(live)",
// "(demo)", "(acoustic)", "(remix)" and "(instrumental)" still tell recordings
// apart. Then case, "&" against "and", and punctuation and spacing are ignored.
func MatchTitleKey(title, artist string) string {
t := strings.TrimSpace(title)
if a := strings.TrimSpace(artist); a != "" {
if prefix := a + " - "; len(t) > len(prefix) && strings.EqualFold(t[:len(prefix)], prefix) {
t = t[len(prefix):]
}
}
t = featBracket.ReplaceAllString(t, " ")
t = remasterBracket.ReplaceAllString(t, " ")
t = bracketed.ReplaceAllStringFunc(t, func(seg string) string {
for _, re := range suspectSourceRegexps {
if re.MatchString(seg) {
return " "
}
}
return seg
})
t = remasterSuffix.ReplaceAllString(t, "")
t = featTrailing.ReplaceAllString(t, "")
if key := alnumKey(t); key != "" {
return key
}
return alnumKey(title)
}
// ReleaseTitleKey is the lighter comparison for asking whether one release
// lists a song twice: case, "&" and punctuation only. Every word stays, so a
// box set's "Song" and "Song (demo)" are two songs, as the release means them.
func ReleaseTitleKey(title string) string {
return alnumKey(title)
}
// alnumKey lowercases, reads "&" as "and", and keeps only letters and digits.
func alnumKey(s string) string {
s = strings.ReplaceAll(strings.ToLower(s), "&", " and ")
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
@@ -0,0 +1,71 @@
package library
import "testing"
// The shapes measured on the operator's library (milestone 498's body).
func TestClassifyDuplicateGroup(t *testing.T) {
m := func(album, title string) ClassifyMember {
return ClassifyMember{AlbumID: album, Title: title, ArtistName: "Gorillaz"}
}
cases := []struct {
name string
tier string
members []ClassifyMember
want DuplicateClass
}{
{"Humanz: one album, one song twice", "exact",
[]ClassifyMember{m("humanz", "Saturnz Barz"), m("humanz", "Saturnz Barz (Official Video)")}, ClassSameRelease},
{"one album, video upload title with artist prefix", "acoustic",
[]ClassifyMember{m("humanz", "Andromeda"), m("humanz", "Gorillaz - Andromeda (Official Audio)")}, ClassSameRelease},
{"one album, identical bytes under different titles", "exact",
[]ClassifyMember{m("www", "WWW"), m("www", "WWW (instrumental)")}, ClassSameRelease},
{"one album, acoustic match, different recordings named", "acoustic",
[]ClassifyMember{m("www", "WWW"), m("www", "WWW (instrumental)")}, ClassReview},
{"Big Me: the single and the album", "exact",
[]ClassifyMember{m("single", "Big Me"), m("album", "Big Me")}, ClassCrossRelease},
{"remaster and featuring credits are one song", "acoustic",
[]ClassifyMember{m("a", "Feel Good Inc. (2017 Remaster)"), m("b", "Feel Good Inc feat. De La Soul")}, ClassCrossRelease},
{"Nervosa / Anorexia: identical bytes, different songs", "exact",
[]ClassifyMember{m("a", "Nervosa"), m("b", "Anorexia")}, ClassMismatch},
{"studio and live across albums", "acoustic",
[]ClassifyMember{m("studio", "Charger"), m("live", "Charger (live)")}, ClassReview},
{"a lone copy", "exact", []ClassifyMember{m("a", "Song")}, ClassReview},
}
for _, tc := range cases {
if got := ClassifyDuplicateGroup(tc.tier, tc.members); got != tc.want {
t.Errorf("%s: got %s, want %s", tc.name, got, tc.want)
}
}
}
func TestMatchTitleKey(t *testing.T) {
cases := []struct{ title, artist, want string }{
{"Saturnz Barz (Official Video)", "Gorillaz", "saturnzbarz"},
{"Gorillaz - Saturnz Barz", "Gorillaz", "saturnzbarz"},
{"Doin' It Right (Music Video) ft. Panda Bear", "Daft Punk", "doinitright"},
{"Song [feat. X]", "", "song"},
{"Song - Remastered 2009", "", "song"},
{"Rock & Roll", "", "rockandroll"},
// What names a different recording stays.
{"Song (live)", "", "songlive"},
{"Song (demo)", "", "songdemo"},
{"Song (Remix)", "", "songremix"},
// A title that is nothing but a marker keeps its words rather than vanishing.
{"(Official Video)", "", "officialvideo"},
}
for _, tc := range cases {
if got := MatchTitleKey(tc.title, tc.artist); got != tc.want {
t.Errorf("MatchTitleKey(%q, %q) = %q, want %q", tc.title, tc.artist, got, tc.want)
}
}
}
// The release check keeps every word: a box set's demo is its own song.
func TestReleaseTitleKey(t *testing.T) {
if ReleaseTitleKey("Song (demo)") == ReleaseTitleKey("Song") {
t.Error("ReleaseTitleKey collapsed a demo into the song")
}
if ReleaseTitleKey("Hallelujah Money") != ReleaseTitleKey("hallelujah money!") {
t.Error("ReleaseTitleKey kept case or punctuation")
}
}
+31
View File
@@ -25,6 +25,16 @@ var ErrDuplicateGroupNotPending = errors.New("library: duplicate group is not pe
// ErrSurvivorNotInGroup means the copy chosen to keep is not a member of the group. // ErrSurvivorNotInGroup means the copy chosen to keep is not a member of the group.
var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the group") var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the group")
// ErrCopyTrackedByLidarr means a copy the merge would remove is one Lidarr maps
// to a track of the release it monitors (M498). Removing it would open a hole
// that Lidarr fills by downloading the song again, so the merge refuses and
// changes nothing.
var ErrCopyTrackedByLidarr = errors.New("library: a copy to remove is tracked by Lidarr")
// RemovableFunc reports whether the file at filePath may be removed. A merge
// asks it about every copy it would remove, before touching anything.
type RemovableFunc func(filePath string) bool
// MergedCopy is one copy a merge kept or removed. // MergedCopy is one copy a merge kept or removed.
type MergedCopy struct { type MergedCopy struct {
TrackID pgtype.UUID TrackID pgtype.UUID
@@ -75,6 +85,17 @@ type MergeResult struct {
func MergeDuplicateGroup( func MergeDuplicateGroup(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
groupID, survivorID pgtype.UUID, groupID, survivorID pgtype.UUID,
) (MergeResult, error) {
return MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, groupID, survivorID, nil)
}
// MergeDuplicateGroupGuarded is MergeDuplicateGroup with a check on what it may
// remove: when removable says no to any copy that would go, the merge returns
// ErrCopyTrackedByLidarr (wrapped with the path) and changes nothing. A nil
// removable allows every copy.
func MergeDuplicateGroupGuarded(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
groupID, survivorID pgtype.UUID, removable RemovableFunc,
) (MergeResult, error) { ) (MergeResult, error) {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
@@ -109,6 +130,13 @@ func MergeDuplicateGroup(
return MergeResult{}, err return MergeResult{}, err
} }
if removable != nil {
for _, l := range losers {
if !removable(l.FilePath) {
return MergeResult{}, fmt.Errorf("%w: %s", ErrCopyTrackedByLidarr, l.FilePath)
}
}
}
for _, l := range losers { for _, l := range losers {
if err := removeTrackFileOnDisk(l.FilePath); err != nil { if err := removeTrackFileOnDisk(l.FilePath); err != nil {
return MergeResult{}, err return MergeResult{}, err
@@ -241,6 +269,9 @@ func foldTrackInto(
if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: survivorID, LoserID: loserID}); err != nil { if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("inherit recording mbid: %w", err) return f, fmt.Errorf("inherit recording mbid: %w", err)
} }
if err := tq.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("inherit song link: %w", err)
}
// Everything the loser carried now sits on the survivor, so the CASCADE // Everything the loser carried now sits on the survivor, so the CASCADE
// this delete sets off has nothing left to destroy. // this delete sets off has nothing left to destroy.
+791
View File
@@ -0,0 +1,791 @@
package library
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Duplicate resolver (M498 #5435, #5437).
//
// The sweep proposes duplicate groups; the resolver decides what each one is
// and settles the ones that are safe to settle without asking. Safety is
// Lidarr's to define: Lidarr maps one file to each track of the release it
// monitors, and re-downloads any mapped file that disappears. So:
//
// - A copy Lidarr maps is never removed.
// - On one album, copies Lidarr does not map are merged into the copy it does
// (or, when it maps none, into the best by ProposeSurvivor).
// - When Lidarr maps every copy on one album, the release it monitors lists
// the song twice: the Humanz box set, 14 vinyl sides then the digital
// medium. The resolver moves Lidarr to a release of that album that lists
// each song once. Lidarr then rescans the folder, maps one copy and leaves
// the rest unmapped, and the next pass merges them.
//
// The release change has a fixed point (lesson #4183): it happens only while
// the monitored release repeats a title, and the release it chooses repeats
// none, so the next pass finds nothing to change.
// LidarrLibrary is the part of Lidarr's API the resolver uses. *lidarr.Client
// satisfies it.
type LidarrLibrary interface {
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
LookupAlbumByMBID(ctx context.Context, mbid string) (lidarr.LidarrAlbum, error)
ListAlbumTracks(ctx context.Context, albumID int) ([]lidarr.ReleaseTrack, error)
GetAlbumReleases(ctx context.Context, albumID int) ([]lidarr.AlbumRelease, error)
ListReleaseTracks(ctx context.Context, releaseID int) ([]lidarr.ReleaseTrack, error)
SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error
}
const (
// resolveMergeCap bounds the merges one pass makes. The first pass after
// deploy meets the whole backlog (433 groups measured on 2026-10-09); a
// cap spreads it over a few hours, each pass short.
resolveMergeCap = 200
// resolveReleaseCap bounds the Lidarr release changes one pass makes. Each
// makes Lidarr rescan an artist folder.
resolveReleaseCap = 10
// releaseChangeSettle is how long after a release change the resolver
// leaves that album's groups alone. Lidarr unmaps every file of the album
// and rescans; until the rescan lands, every copy reads as unmapped, and a
// merge then could remove the very file Lidarr is about to map.
releaseChangeSettle = 24 * time.Hour
// duplicateResolveTick is how often the worker runs a pass.
duplicateResolveTick = time.Hour
)
const (
lidarrStateTracked = "tracked"
lidarrStateUnmapped = "unmapped"
)
// ReleaseChange is one Lidarr release the resolver switched.
type ReleaseChange struct {
AlbumTitle string
ArtistName string
From, To lidarr.AlbumRelease
}
// DuplicateResolveResult tallies one pass.
type DuplicateResolveResult struct {
Groups int
Classes map[DuplicateClass]int
LidarrConsulted bool
Merged int
MergeFailed int
ReleaseChanges []ReleaseChange
// SongsLinked counts cross-release groups newly linked as one song.
SongsLinked int
// HeldBack counts tracks newly held back from radio and the mixes as video
// rips; Released counts held-back tracks whose name no longer says so.
HeldBack, Released int64
}
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
// three components (artist folder, album folder, file). Both see the library
// under their own mount; on the operator's deploy both happen to say /music,
// and the key keeps the match from depending on that.
func LidarrPathKey(p string) string {
parts := strings.Split(strings.Trim(p, "/"), "/")
if len(parts) > 3 {
parts = parts[len(parts)-3:]
}
return strings.Join(parts, "/")
}
// resolveGroup is one pending group and its present copies.
type resolveGroup struct {
id pgtype.UUID
tier string
members []dbq.ListDuplicateGroupsForResolveRow
class DuplicateClass
note string
states []string // per member: tracked, unmapped, or "" when Lidarr was not asked
}
// autoMergeable says whether the resolver may merge the group on its own: when
// every copy it would remove is one Lidarr does not map (D-a rule 1). On one
// album that is any group with at most one mapped copy. Across releases each
// mapped copy fulfils its own release (D-b), so only a group with exactly one
// mapped copy qualifies: the others fulfil nothing — a rip beside the clean
// copy on another release, or a wrong-file import Lidarr holds unmapped
// (#5439) — and the mapped copy is the clear one to keep.
func autoMergeable(g *resolveGroup) bool {
switch g.class {
case ClassSameRelease:
return g.tracked() <= 1
case ClassCrossRelease, ClassMismatch:
return g.tracked() == 1
default:
return false
}
}
// settling says whether any of the group's albums had its Lidarr release
// changed recently, so Lidarr may still be remapping its files.
func (g *resolveGroup) settling(albums map[string]bool) bool {
for _, m := range g.members {
if albums[syncpkg.FormatUUID(m.AlbumID)] {
return true
}
}
return false
}
func (g *resolveGroup) tracked() int {
n := 0
for _, s := range g.states {
if s == lidarrStateTracked {
n++
}
}
return n
}
// ResolveDuplicates runs one pass. lid is nil when Lidarr is disabled; act is
// the operator's auto-resolve setting. Without Lidarr, or with act off, the
// pass only classifies: nothing is removed when Lidarr cannot say what is safe.
func ResolveDuplicates(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, lid LidarrLibrary, act bool,
) (DuplicateResolveResult, error) {
if logger == nil {
logger = slog.Default()
}
q := dbq.New(pool)
res := DuplicateResolveResult{Classes: map[DuplicateClass]int{}}
rows, err := q.ListDuplicateGroupsForResolve(ctx)
if err != nil {
return res, fmt.Errorf("list duplicate groups: %w", err)
}
groups := foldResolveGroups(rows)
res.Groups = len(groups)
var unmapped map[string]bool
if lid != nil {
files, err := lid.ListUnmappedTrackFiles(ctx)
if err != nil {
logger.Warn("duplicate resolve: Lidarr's unmapped files unavailable; classifying only", "err", err)
} else {
unmapped = make(map[string]bool, len(files))
for _, f := range files {
unmapped[LidarrPathKey(f.Path)] = true
}
res.LidarrConsulted = true
}
}
for _, g := range groups {
cm := make([]ClassifyMember, len(g.members))
g.states = make([]string, len(g.members))
for i, m := range g.members {
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
if res.LidarrConsulted {
if unmapped[LidarrPathKey(m.FilePath)] {
g.states[i] = lidarrStateUnmapped
} else {
g.states[i] = lidarrStateTracked
}
}
}
g.class = ClassifyDuplicateGroup(g.tier, cm)
g.note = resolveNote(g)
res.Classes[g.class]++
}
if err := writeResolveVerdicts(ctx, q, groups); err != nil {
return res, err
}
if !act {
return res, nil
}
// Holding rips back and linking songs remove nothing and need nothing from
// Lidarr.
if res.HeldBack, err = q.FlagSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
return res, fmt.Errorf("hold back video rips: %w", err)
}
if res.Released, err = q.ClearStaleSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
return res, fmt.Errorf("release renamed tracks: %w", err)
}
linked, err := linkCrossRelease(ctx, pool, logger, groups)
res.SongsLinked = linked
if err != nil {
return res, err
}
if !res.LidarrConsulted {
return res, nil
}
settling, err := recentlyChangedAlbums(ctx, q, time.Now())
if err != nil {
return res, err
}
// Merges first, on the state Lidarr reported at the start of the pass.
removable := func(p string) bool { return unmapped[LidarrPathKey(p)] }
for _, g := range groups {
if res.Merged >= resolveMergeCap {
break
}
if ctx.Err() != nil {
return res, ctx.Err()
}
if !autoMergeable(g) || g.settling(settling) {
continue
}
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
res.MergeFailed++
logger.Warn("duplicate resolve: merge failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
continue
}
res.Merged++
}
// Then release changes, for albums where Lidarr maps every copy.
changes, err := changeRepeatingReleases(ctx, q, pool, logger, lid, groups, settling)
res.ReleaseChanges = changes
if err != nil {
return res, err
}
if res.Merged > 0 || len(changes) > 0 {
notifyAdmins(ctx, notifications.KindDuplicatesResolved, notifications.Payload{
Count: int64(res.Merged + len(changes)),
Detail: resolvedDetail(res.Merged, changes),
})
}
return res, nil
}
// linkCrossRelease links each cross-release group's copies as one song
// (#5438): a like on one is a like on all, and the Liked list, shuffle and the
// mixes count the song once. Linking an already-linked group changes nothing,
// so this runs every pass; likes are shared only when a link is new, since a
// like made after it reaches every copy as it is made.
func linkCrossRelease(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup) (int, error) {
linked := 0
for _, g := range groups {
if g.class != ClassCrossRelease {
continue
}
if ctx.Err() != nil {
return linked, ctx.Err()
}
ids := make([]pgtype.UUID, len(g.members))
for i, m := range g.members {
ids[i] = m.TrackID
}
moved, err := linkSong(ctx, pool, ids)
if err != nil {
logger.Warn("duplicate resolve: song link failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
continue
}
if moved {
linked++
}
}
return linked, nil
}
// linkSong links the tracks as one song and, when that changed anything,
// shares their likes across the song and logs each added like for sync.
func linkSong(ctx context.Context, pool *pgxpool.Pool, ids []pgtype.UUID) (bool, error) {
tx, err := pool.Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
link, err := tq.LinkTracksAsSong(ctx, ids)
if err != nil {
return false, fmt.Errorf("link: %w", err)
}
if link.Moved == 0 {
return false, nil
}
added, err := tq.ShareSongLikes(ctx, link.SongKey)
if err != nil {
return false, fmt.Errorf("share likes: %w", err)
}
likeIDs := make([]string, len(added))
for i, a := range added {
likeIDs[i] = syncpkg.EncodeLikeID(syncpkg.FormatUUID(a.UserID), syncpkg.FormatUUID(a.TrackID))
}
if err := syncpkg.LogChanges(ctx, tx, syncpkg.EntityLikeTrack, likeIDs, syncpkg.OpUpsert); err != nil {
return false, fmt.Errorf("log shared likes: %w", err)
}
return true, tx.Commit(ctx)
}
func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGroup {
var groups []*resolveGroup
for _, r := range rows {
if n := len(groups); n == 0 || groups[n-1].id != r.GroupID {
groups = append(groups, &resolveGroup{id: r.GroupID, tier: r.Tier})
}
g := groups[len(groups)-1]
g.members = append(g.members, r)
}
// A group whose other copies went missing has nothing left to resolve.
out := groups[:0]
for _, g := range groups {
if len(g.members) >= 2 {
out = append(out, g)
}
}
return out
}
// resolveNote is what the report says beside a group the resolver leaves for
// the operator, when there is something to say.
func resolveNote(g *resolveGroup) string {
switch g.class {
case ClassSameRelease:
if g.tracked() > 1 {
return "Lidarr tracks every copy as its own track, so removing one would make Lidarr download it again."
}
case ClassCrossRelease:
return "Each copy belongs to its own release, and Lidarr keeps each one. Nothing is removed; they count as one song."
case ClassMismatch:
return "Identical audio filed under different titles: one file carries another song's tags."
}
return ""
}
func writeResolveVerdicts(ctx context.Context, q *dbq.Queries, groups []*resolveGroup) error {
classes := dbq.SetDuplicateGroupClassesParams{}
states := dbq.SetDuplicateMemberLidarrStatesParams{}
for _, g := range groups {
classes.GroupIds = append(classes.GroupIds, g.id)
classes.Classes = append(classes.Classes, string(g.class))
classes.Notes = append(classes.Notes, g.note)
for i, m := range g.members {
states.GroupIds = append(states.GroupIds, g.id)
states.TrackIds = append(states.TrackIds, m.TrackID)
states.States = append(states.States, g.states[i])
}
}
if len(classes.GroupIds) == 0 {
return nil
}
if err := q.SetDuplicateGroupClasses(ctx, classes); err != nil {
return fmt.Errorf("write duplicate classes: %w", err)
}
if err := q.SetDuplicateMemberLidarrStates(ctx, states); err != nil {
return fmt.Errorf("write lidarr states: %w", err)
}
return nil
}
// survivorCandidates builds what ProposeSurvivor weighs for a group's copies.
func survivorCandidates(g *resolveGroup) []SurvivorCandidate {
cands := make([]SurvivorCandidate, len(g.members))
for i, m := range g.members {
cands[i] = SurvivorCandidate{
TrackID: syncpkg.FormatUUID(m.TrackID),
FileFormat: m.FileFormat,
FileSize: m.FileSize,
AddedAt: m.AddedAt.Time,
LidarrTracked: g.states[i] == lidarrStateTracked,
TagFit: TagFitScore(m.TrackNumber != nil, m.PositionClash, m.FilePath, m.HasMbid),
}
}
return cands
}
// autoMerge merges one group into the copy ProposeSurvivor picks, which is the
// copy Lidarr tracks when there is one. removable is the guard: a copy Lidarr
// maps can never be among those removed, whatever the survivor rule said.
func autoMerge(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, g *resolveGroup, removable RemovableFunc,
) error {
survivorKey, reason := ProposeSurvivor(survivorCandidates(g))
var survivorID pgtype.UUID
if err := survivorID.Scan(survivorKey); err != nil {
return fmt.Errorf("parse survivor id: %w", err)
}
res, err := MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, g.id, survivorID, removable)
if err != nil {
return err
}
if err := dbq.New(pool).MarkDuplicateGroupResolvedAutomatically(ctx, g.id); err != nil {
logger.Warn("duplicate resolve: marking the merge automatic failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
}
removed := make([]map[string]string, 0, len(res.Removed))
for _, c := range res.Removed {
removed = append(removed, map[string]string{"track_id": syncpkg.FormatUUID(c.TrackID), "file_path": c.FilePath})
}
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateMerge, map[string]any{
"automatic": true,
"group_id": syncpkg.FormatUUID(g.id),
"tier": res.Tier,
"class": string(g.class),
"survivor_track_id": syncpkg.FormatUUID(res.Survivor.TrackID),
"survivor_path": res.Survivor.FilePath,
"survivor_reason": reason,
"removed": removed,
"moved": map[string]any{
"play_events": res.PlayEvents, "skip_events": res.SkipEvents,
"likes": res.Likes, "playlist_entries": res.PlaylistEntries,
},
})
return nil
}
// recentlyChangedAlbums is the Minstrel albums whose Lidarr release the
// resolver changed within releaseChangeSettle, read from the audit log so a
// restart does not forget them.
func recentlyChangedAlbums(ctx context.Context, q *dbq.Queries, now time.Time) (map[string]bool, error) {
rows, err := q.ListAuditLogByActions(ctx, dbq.ListAuditLogByActionsParams{
Actions: []string{string(audit.ActionLidarrReleaseChange)}, SystemOnly: true,
PageLimit: 200, PageOffset: 0,
})
if err != nil {
return nil, fmt.Errorf("read recent release changes: %w", err)
}
out := map[string]bool{}
for _, r := range rows {
if now.Sub(r.CreatedAt.Time) > releaseChangeSettle {
break // newest first
}
var meta struct {
AlbumID string `json:"album_id"`
}
if json.Unmarshal(r.Metadata, &meta) == nil && meta.AlbumID != "" {
out[meta.AlbumID] = true
}
}
return out, nil
}
// repeatingAlbum is a Minstrel album where Lidarr maps more than one copy of a
// song, and the groups that showed it.
type repeatingAlbum struct {
albumID pgtype.UUID
releaseGroupMbid string
title, artist string
groups []*resolveGroup
}
func changeRepeatingReleases(
ctx context.Context, q *dbq.Queries, pool *pgxpool.Pool, logger *slog.Logger,
lid LidarrLibrary, groups []*resolveGroup, settling map[string]bool,
) ([]ReleaseChange, error) {
albums := map[string]*repeatingAlbum{}
for _, g := range groups {
if g.class != ClassSameRelease || g.tracked() < 2 {
continue
}
m := g.members[0]
key := syncpkg.FormatUUID(m.AlbumID)
if settling[key] || m.ReleaseGroupMbid == nil || *m.ReleaseGroupMbid == "" {
continue
}
a := albums[key]
if a == nil {
a = &repeatingAlbum{albumID: m.AlbumID, releaseGroupMbid: *m.ReleaseGroupMbid, title: m.AlbumTitle, artist: m.ArtistName}
albums[key] = a
}
a.groups = append(a.groups, g)
}
keys := make([]string, 0, len(albums))
for k := range albums {
keys = append(keys, k)
}
sort.Strings(keys)
var changes []ReleaseChange
for _, k := range keys {
if len(changes) >= resolveReleaseCap {
break
}
if ctx.Err() != nil {
return changes, ctx.Err()
}
a := albums[k]
change, note, err := changeRelease(ctx, q, lid, a)
if err != nil {
logger.Warn("duplicate resolve: Lidarr release check failed", "album", a.title, "err", err)
continue
}
if change != nil {
changes = append(changes, *change)
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionLidarrReleaseChange, map[string]any{
"automatic": true,
"album_id": k,
"album_title": a.title,
"artist_name": a.artist,
"from_release": releaseLabel(change.From),
"to_release": releaseLabel(change.To),
})
}
if note != "" {
if err := setGroupNotes(ctx, q, a.groups, note); err != nil {
return changes, err
}
}
}
return changes, nil
}
// changeRelease checks one album's monitored release and, when it lists a
// song twice, moves Lidarr to the release that lists each song once and best
// covers what is on disk. It returns the change made (nil when none) and the
// note for the album's groups.
func changeRelease(
ctx context.Context, q *dbq.Queries, lid LidarrLibrary, a *repeatingAlbum,
) (*ReleaseChange, string, error) {
la, err := lid.LookupAlbumByMBID(ctx, a.releaseGroupMbid)
if errors.Is(err, lidarr.ErrNotFound) {
return nil, "Lidarr does not hold this album, so its copies are left for you.", nil
}
if err != nil {
return nil, "", err
}
current, err := lid.ListAlbumTracks(ctx, la.ID)
if err != nil {
return nil, "", err
}
if !releaseRepeats(current) {
return nil, "Lidarr maps each copy to a different track of this album, so none can go without a download.", nil
}
releases, err := lid.GetAlbumReleases(ctx, la.ID)
if err != nil {
return nil, "", err
}
titles, err := q.ListAlbumPresentTrackTitles(ctx, a.albumID)
if err != nil {
return nil, "", fmt.Errorf("list album titles: %w", err)
}
onDisk := map[string]bool{}
for _, t := range titles {
if k := ReleaseTitleKey(t); k != "" {
onDisk[k] = true
}
}
var from lidarr.AlbumRelease
var candidates []releaseCandidate
for _, r := range releases {
if r.Monitored {
from = r
continue
}
tracks, err := lid.ListReleaseTracks(ctx, r.ID)
if err != nil {
return nil, "", err
}
if len(tracks) == 0 || releaseRepeats(tracks) {
continue
}
candidates = append(candidates, releaseCandidate{release: r, coverage: coverage(tracks, onDisk)})
}
best, ok := pickRelease(candidates, coverage(current, onDisk))
if !ok {
return nil, "Lidarr's release of this album lists songs twice, and no other release lists each once while keeping what is on disk.", nil
}
if err := lid.SetMonitoredRelease(ctx, la.ID, best.ID); err != nil {
return nil, "", err
}
note := fmt.Sprintf("Lidarr now monitors the %s release, which lists each song once. The extra copies are merged once Lidarr has rescanned.", releaseLabel(best))
return &ReleaseChange{AlbumTitle: a.title, ArtistName: a.artist, From: from, To: best}, note, nil
}
type releaseCandidate struct {
release lidarr.AlbumRelease
coverage int
}
// pickRelease chooses among releases that list each song once: the one
// covering most of the titles on disk, then the fewest tracks (the plainest
// edition that holds them), then a digital one, then the lowest id so the
// choice is stable. It refuses a release covering fewer titles on disk than
// the current one does: the change must not drop a song Lidarr now keeps.
func pickRelease(cands []releaseCandidate, currentCoverage int) (lidarr.AlbumRelease, bool) {
if len(cands) == 0 {
return lidarr.AlbumRelease{}, false
}
sort.SliceStable(cands, func(i, j int) bool {
a, b := cands[i], cands[j]
if a.coverage != b.coverage {
return a.coverage > b.coverage
}
if a.release.TrackCount != b.release.TrackCount {
return a.release.TrackCount < b.release.TrackCount
}
if da, db := isDigital(a.release), isDigital(b.release); da != db {
return da
}
return a.release.ID < b.release.ID
})
if cands[0].coverage < currentCoverage {
return lidarr.AlbumRelease{}, false
}
return cands[0].release, true
}
func isDigital(r lidarr.AlbumRelease) bool {
return strings.Contains(strings.ToLower(r.Format), "digital")
}
// releaseRepeats reports whether a release lists one title more than once.
func releaseRepeats(tracks []lidarr.ReleaseTrack) bool {
seen := map[string]bool{}
for _, t := range tracks {
k := ReleaseTitleKey(t.Title)
if k == "" {
continue
}
if seen[k] {
return true
}
seen[k] = true
}
return false
}
// coverage counts the distinct titles on disk that the release lists.
func coverage(tracks []lidarr.ReleaseTrack, onDisk map[string]bool) int {
hit := map[string]bool{}
for _, t := range tracks {
if k := ReleaseTitleKey(t.Title); onDisk[k] {
hit[k] = true
}
}
return len(hit)
}
func releaseLabel(r lidarr.AlbumRelease) string {
label := r.Format
if label == "" {
label = r.Title
}
if r.Disambiguation != "" {
label = r.Disambiguation + ", " + label
}
return fmt.Sprintf("%s (%d tracks)", label, r.TrackCount)
}
func setGroupNotes(ctx context.Context, q *dbq.Queries, groups []*resolveGroup, note string) error {
p := dbq.SetDuplicateGroupClassesParams{}
for _, g := range groups {
g.note = note
p.GroupIds = append(p.GroupIds, g.id)
p.Classes = append(p.Classes, string(g.class))
p.Notes = append(p.Notes, note)
}
if err := q.SetDuplicateGroupClasses(ctx, p); err != nil {
return fmt.Errorf("write duplicate notes: %w", err)
}
return nil
}
func resolvedDetail(merged int, changes []ReleaseChange) string {
var parts []string
if merged > 0 {
parts = append(parts, fmt.Sprintf("Merged %d %s Lidarr does not need.", merged, pluralWord(merged, "copy", "copies")))
}
switch len(changes) {
case 0:
case 1:
c := changes[0]
parts = append(parts, fmt.Sprintf("Lidarr now monitors the %s release of %s by %s, which lists each song once.",
releaseLabel(c.To), c.AlbumTitle, c.ArtistName))
default:
parts = append(parts, fmt.Sprintf("Lidarr now monitors a release that lists each song once for %d albums.", len(changes)))
}
return strings.Join(parts, " ")
}
func pluralWord(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
// DuplicateResolveWorker runs a resolver pass every hour.
type DuplicateResolveWorker struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string
settings *FingerprintSettingsService
lidarr func() LidarrLibrary
tick time.Duration
}
// NewDuplicateResolveWorker builds a worker with the production cadence.
// lidarrFn is asked each pass, so a Lidarr setting saved in admin takes effect
// without a restart; it returns nil while Lidarr is disabled.
func NewDuplicateResolveWorker(
pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
settings *FingerprintSettingsService, lidarrFn func() LidarrLibrary,
) *DuplicateResolveWorker {
return &DuplicateResolveWorker{
pool: pool, logger: logger, dataDir: dataDir, settings: settings, lidarr: lidarrFn, tick: duplicateResolveTick,
}
}
// Run blocks until ctx is cancelled, running a pass at start and then each tick.
func (w *DuplicateResolveWorker) Run(ctx context.Context) {
w.tickOnce(ctx)
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tickOnce(ctx)
}
}
}
// tickOnce contains one pass so nothing it does can stop the next tick (rule 157).
func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
w.logger.Error("duplicate resolve: tick panicked", "panic", r)
}
}()
// A sweep in flight is rewriting the groups; the next tick sees its result.
_, err := dbq.New(w.pool).GetInFlightDuplicateSweep(ctx)
switch {
case err == nil:
return
case !errors.Is(err, pgx.ErrNoRows):
if ctx.Err() == nil {
w.logger.Warn("duplicate resolve: sweep check failed", "err", err)
}
return
}
var lid LidarrLibrary
if w.lidarr != nil {
lid = w.lidarr()
}
res, err := ResolveDuplicates(ctx, w.pool, w.logger, w.dataDir, lid, w.settings.Get().AutoResolve)
if err != nil && ctx.Err() == nil {
w.logger.Warn("duplicate resolve: pass failed", "err", err)
}
w.logger.Info("duplicate resolve complete",
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked,
"held_back", res.HeldBack, "released", res.Released)
}
+504
View File
@@ -0,0 +1,504 @@
package library
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
func TestLidarrPathKey(t *testing.T) {
// Minstrel and Lidarr see the library under different mounts; the key is
// what they agree on.
a := LidarrPathKey("/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
b := LidarrPathKey("/data/media/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
if a != b || a != "Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3" {
t.Errorf("keys differ or are wrong: %q, %q", a, b)
}
if LidarrPathKey("/music/Gorillaz/Humanz (2017)/a.mp3") == LidarrPathKey("/music/Gorillaz/Demon Days (2005)/a.mp3") {
t.Error("two albums' files share a key")
}
}
func rt(titles ...string) []lidarr.ReleaseTrack {
out := make([]lidarr.ReleaseTrack, len(titles))
for i, title := range titles {
out[i] = lidarr.ReleaseTrack{ID: i + 1, Title: title}
}
return out
}
func TestReleaseRepeats(t *testing.T) {
if !releaseRepeats(rt("Ascension", "Strobelite", "Ascension")) {
t.Error("the box set lists Ascension twice, but no repeat was found")
}
if releaseRepeats(rt("Charger", "Charger (demo)")) {
t.Error("a demo was read as a repeat of the song")
}
}
// The Humanz releases as Lidarr lists them (2026-10-09): the box set is
// monitored, and the 40-track digital release is the one that lists each song
// once and covers what is on disk.
func TestPickRelease(t *testing.T) {
digital40 := lidarr.AlbumRelease{ID: 10149, Format: "Digital Media", TrackCount: 40}
deluxe26 := lidarr.AlbumRelease{ID: 10150, Format: "CD", TrackCount: 26}
standard20 := lidarr.AlbumRelease{ID: 10151, Format: "Digital Media", TrackCount: 20}
got, ok := pickRelease([]releaseCandidate{
{release: standard20, coverage: 20}, {release: deluxe26, coverage: 26}, {release: digital40, coverage: 34},
}, 34)
if !ok || got.ID != digital40.ID {
t.Errorf("picked %+v (ok=%v), want the 40-track digital release", got, ok)
}
// Equal coverage: the plainer edition, then digital.
cd := lidarr.AlbumRelease{ID: 1, Format: "CD", TrackCount: 26}
web := lidarr.AlbumRelease{ID: 2, Format: "Digital Media", TrackCount: 26}
if got, _ := pickRelease([]releaseCandidate{{release: cd, coverage: 26}, {release: web, coverage: 26}}, 26); got.ID != web.ID {
t.Errorf("picked %+v, want the digital one of two equal releases", got)
}
// Never a release that keeps fewer of the songs on disk than the current one.
if got, ok := pickRelease([]releaseCandidate{{release: deluxe26, coverage: 26}}, 34); ok {
t.Errorf("picked %+v, which covers fewer songs on disk than the current release", got)
}
}
// fakeLidarr is one Lidarr album with several releases. Switching the
// monitored release changes what ListAlbumTracks answers, as in Lidarr.
type fakeLidarr struct {
unmapped []string
unmappedErr error
album lidarr.LidarrAlbum
releases []lidarr.AlbumRelease
releaseTracks map[int][]lidarr.ReleaseTrack
setCalls [][2]int
}
func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) {
out := make([]lidarr.TrackFile, len(f.unmapped))
for i, p := range f.unmapped {
out[i] = lidarr.TrackFile{ID: i + 1, Path: p}
}
return out, f.unmappedErr
}
func (f *fakeLidarr) LookupAlbumByMBID(_ context.Context, mbid string) (lidarr.LidarrAlbum, error) {
if f.album.ForeignAlbumID != mbid {
return lidarr.LidarrAlbum{}, lidarr.ErrNotFound
}
return f.album, nil
}
func (f *fakeLidarr) ListAlbumTracks(context.Context, int) ([]lidarr.ReleaseTrack, error) {
for _, r := range f.releases {
if r.Monitored {
return f.releaseTracks[r.ID], nil
}
}
return nil, nil
}
func (f *fakeLidarr) GetAlbumReleases(context.Context, int) ([]lidarr.AlbumRelease, error) {
return append([]lidarr.AlbumRelease(nil), f.releases...), nil
}
func (f *fakeLidarr) ListReleaseTracks(_ context.Context, releaseID int) ([]lidarr.ReleaseTrack, error) {
return f.releaseTracks[releaseID], nil
}
func (f *fakeLidarr) SetMonitoredRelease(_ context.Context, albumID, releaseID int) error {
f.setCalls = append(f.setCalls, [2]int{albumID, releaseID})
for i := range f.releases {
f.releases[i].Monitored = f.releases[i].ID == releaseID
}
return nil
}
// resolveFixture is the Humanz shape: one album, one song twice, both copies on
// disk under an artist/album folder, in one pending exact-tier group.
type resolveFixture struct {
pool *pgxpool.Pool
clean, rip dbq.Track
cleanPath, ripPath string
groupID pgtype.UUID
}
func newResolveFixture(t *testing.T) resolveFixture {
t.Helper()
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
dir := filepath.Join(t.TempDir(), "Gorillaz", "Humanz (2017)")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
f := resolveFixture{pool: pool}
f.cleanPath = filepath.Join(dir, "Gorillaz - Humanz - 03 - Saturnz Barz.mp3")
f.ripPath = filepath.Join(dir, "Gorillaz - Humanz - 03 - Saturnz Barz (Official Video).mp3")
for _, p := range []string{f.cleanPath, f.ripPath} {
if err := os.WriteFile(p, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
}
var album dbq.Album
var artist dbq.Artist
f.clean, album, artist = seedTrack(t, pool, f.cleanPath)
rip, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Saturnz Barz (Official Video)", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: f.ripPath, FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatal(err)
}
f.rip = rip
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("exec %q: %v", sql, err)
}
}
exec(`UPDATE tracks SET title = 'Saturnz Barz' WHERE id = $1`, f.clean.ID)
exec(`UPDATE albums SET release_group_mbid = 'rg-humanz' WHERE id = $1`, album.ID)
if err := pool.QueryRow(ctx,
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('resolve-fixture', 'exact') RETURNING id`,
).Scan(&f.groupID); err != nil {
t.Fatal(err)
}
exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.clean.ID, f.rip.ID)
return f
}
// lidarrPath is where Lidarr, mounted elsewhere, sees a fixture file.
func lidarrPath(p string) string { return "/music/" + LidarrPathKey(p) }
func (f resolveFixture) group(t *testing.T) (status string, class *string, note *string, auto bool) {
t.Helper()
if err := f.pool.QueryRow(context.Background(),
`SELECT status, class, resolve_note, resolved_automatically FROM duplicate_groups WHERE id = $1`, f.groupID,
).Scan(&status, &class, &note, &auto); err != nil {
t.Fatal(err)
}
return status, class, note, auto
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
// Lidarr maps the clean copy and holds the rip unmapped: the rip goes, the
// clean copy stays, and the merge is recorded as the resolver's.
func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
f := newResolveFixture(t)
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.Merged != 1 || !res.LidarrConsulted {
t.Fatalf("result = %+v, want one merge with Lidarr consulted", res)
}
if exists(f.ripPath) || !exists(f.cleanPath) {
t.Errorf("rip exists=%v clean exists=%v; want only the clean copy left", exists(f.ripPath), exists(f.cleanPath))
}
status, class, _, auto := f.group(t)
if status != "merged" || class == nil || *class != string(ClassSameRelease) || !auto {
t.Errorf("group = (%s, %v, auto=%v), want merged same_release by the resolver", status, class, auto)
}
var audits int
if err := f.pool.QueryRow(context.Background(),
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_merge' AND actor_id IS NULL
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
syncpkg.FormatUUID(f.groupID)).Scan(&audits); err != nil {
t.Fatal(err)
}
if audits != 1 {
t.Errorf("automatic merge audit rows = %d, want 1", audits)
}
}
// Lidarr maps both copies, and its release lists the song twice: nothing is
// deleted, the release moves to the one listing each song once, and the next
// pass leaves it there (lesson #4183's fixed point).
func TestResolveDuplicates_ChangesARepeatingRelease_Integration(t *testing.T) {
f := newResolveFixture(t)
lid := &fakeLidarr{
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz", Title: "Humanz"},
releases: []lidarr.AlbumRelease{
{ID: 10148, Format: "14x12\" Vinyl, Digital Media", TrackCount: 4, Monitored: true},
{ID: 10149, Format: "Digital Media", TrackCount: 2},
},
releaseTracks: map[int][]lidarr.ReleaseTrack{
10148: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
10149: rt("Saturnz Barz", "Ascension"),
},
}
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
t.Fatalf("merged %d; a copy Lidarr maps was touched", res.Merged)
}
if len(lid.setCalls) != 1 || lid.setCalls[0] != [2]int{4723, 10149} {
t.Fatalf("release changes = %v, want album 4723 to release 10149", lid.setCalls)
}
status, _, note, _ := f.group(t)
if status != "pending" || note == nil || *note == "" {
t.Errorf("group = (%s, note %v), want pending with a note saying what changed", status, note)
}
// The next pass: Lidarr has not rescanned yet, so both copies still read
// as tracked. The album is settling, so nothing changes.
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
t.Fatalf("second resolve: %v", err)
}
if len(lid.setCalls) != 1 {
t.Errorf("release changed again: %v", lid.setCalls)
}
// And once it has settled, the check itself finds nothing to do: the
// release it chose lists each song once.
change, _, err := changeRelease(context.Background(), dbq.New(f.pool), lid,
&repeatingAlbum{albumID: f.clean.AlbumID, releaseGroupMbid: "rg-humanz"})
if err != nil || change != nil || len(lid.setCalls) != 1 {
t.Errorf("after the change, changeRelease = (%+v, %v) with %d calls; want no change", change, err, len(lid.setCalls))
}
}
// After a release change Lidarr unmaps every file of the album until it has
// rescanned. A pass in that window must not read "every copy unmapped" as
// licence to merge: the copy it would remove may be the one Lidarr maps next.
func TestResolveDuplicates_LeavesAnAlbumAloneWhileLidarrRescans_Integration(t *testing.T) {
f := newResolveFixture(t)
lid := &fakeLidarr{
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz"},
releases: []lidarr.AlbumRelease{
{ID: 10148, TrackCount: 2, Monitored: true}, {ID: 10149, TrackCount: 1},
},
releaseTracks: map[int][]lidarr.ReleaseTrack{10148: rt("Saturnz Barz", "Saturnz Barz"), 10149: rt("Saturnz Barz")},
}
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
t.Fatal(err)
}
lid.unmapped = []string{lidarrPath(f.cleanPath), lidarrPath(f.ripPath)}
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
if err != nil {
t.Fatal(err)
}
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
t.Errorf("merged %d during the rescan window", res.Merged)
}
}
// Without Lidarr, or with auto-resolve off, the pass classifies and removes
// nothing.
func TestResolveDuplicates_ClassifiesOnlyWithoutLidarrOrPermission_Integration(t *testing.T) {
for name, run := range map[string]func(resolveFixture) (DuplicateResolveResult, error){
"lidarr disabled": func(f resolveFixture) (DuplicateResolveResult, error) {
return ResolveDuplicates(context.Background(), f.pool, nil, "", nil, true)
},
"lidarr unreachable": func(f resolveFixture) (DuplicateResolveResult, error) {
return ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{unmappedErr: errors.New("timeout")}, true)
},
"auto-resolve off": func(f resolveFixture) (DuplicateResolveResult, error) {
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
return ResolveDuplicates(context.Background(), f.pool, nil, "", lid, false)
},
} {
t.Run(name, func(t *testing.T) {
f := newResolveFixture(t)
res, err := run(f)
if err != nil {
t.Fatal(err)
}
if res.Merged != 0 || !exists(f.ripPath) {
t.Errorf("merged %d", res.Merged)
}
status, class, _, _ := f.group(t)
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
t.Errorf("group = (%s, %v), want pending and classified", status, class)
}
})
}
}
// The guard itself: a merge asked to remove a copy the check refuses changes
// nothing, file or row.
func TestMergeDuplicateGroupGuarded_RefusesATrackedCopy_Integration(t *testing.T) {
f := newResolveFixture(t)
refuse := func(p string) bool { return p != f.ripPath }
_, err := MergeDuplicateGroupGuarded(context.Background(), f.pool, nil, "", f.groupID, f.clean.ID, refuse)
if !errors.Is(err, ErrCopyTrackedByLidarr) {
t.Fatalf("err = %v, want ErrCopyTrackedByLidarr", err)
}
if !exists(f.ripPath) {
t.Error("the refused copy's file was removed")
}
if status, _, _, _ := f.group(t); status != "pending" {
t.Errorf("group status = %s, want pending", status)
}
}
// crossReleaseRipFixture is a video rip on one release with the clean copy on
// another: the album cut, and the same song's video filed as a single.
type crossReleaseRipFixture struct {
pool *pgxpool.Pool
clean, rip dbq.Track
cleanPath, ripPath string
groupID pgtype.UUID
}
func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture {
t.Helper()
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
root := t.TempDir()
f := crossReleaseRipFixture{pool: pool}
f.cleanPath = filepath.Join(root, "Gorillaz", "Demon Days (2005)", "06 - Feel Good Inc.mp3")
f.ripPath = filepath.Join(root, "Gorillaz", "Feel Good Inc (2005)", "01 - Feel Good Inc. (Official Video).mp3")
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Gorillaz", SortName: "Gorillaz"})
if err != nil {
t.Fatal(err)
}
track := func(albumTitle, title, path string) dbq.Track {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: albumTitle, SortTitle: albumTitle, ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatal(err)
}
return tr
}
f.clean = track("Demon Days", "Feel Good Inc.", f.cleanPath)
f.rip = track("Feel Good Inc.", "Feel Good Inc. (Official Video)", f.ripPath)
if err := pool.QueryRow(ctx,
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('cross-rip', 'acoustic') RETURNING id`,
).Scan(&f.groupID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`,
f.groupID, f.clean.ID, f.rip.ID); err != nil {
t.Fatal(err)
}
return f
}
// The rip fulfils nothing in Lidarr while the clean copy on the album does:
// the rip goes and the album copy stays (#5439, D-a rule 1).
func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *testing.T) {
f := newCrossReleaseRipFixture(t)
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
if err != nil {
t.Fatal(err)
}
if res.Classes[ClassCrossRelease] != 1 || res.Merged != 1 {
t.Fatalf("classes %v, merged %d; want the cross-release group merged", res.Classes, res.Merged)
}
if exists(f.ripPath) || !exists(f.cleanPath) {
t.Errorf("rip exists %v, clean exists %v; want only the clean copy", exists(f.ripPath), exists(f.cleanPath))
}
}
// When Lidarr maps both copies, each fulfils its own release: nothing is
// removed, and the copies are linked as one song instead.
func TestResolveDuplicates_KeepsCopiesLidarrMapsOnTwoReleases_Integration(t *testing.T) {
f := newCrossReleaseRipFixture(t)
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{}, true)
if err != nil {
t.Fatal(err)
}
if res.Merged != 0 || !exists(f.ripPath) {
t.Errorf("merged %d; want both copies kept", res.Merged)
}
if res.SongsLinked != 1 {
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
}
}
// A rip is held back from radio and the mixes; the operator's "fine" sticks
// through later passes; a renamed file is released.
func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
f := newCrossReleaseRipFixture(t)
ctx := context.Background()
q := dbq.New(f.pool)
verdict := func(id pgtype.UUID) *string {
t.Helper()
var v *string
if err := f.pool.QueryRow(ctx, `SELECT source_verdict FROM tracks WHERE id = $1`, id).Scan(&v); err != nil {
t.Fatal(err)
}
return v
}
resolve := func() DuplicateResolveResult {
t.Helper()
res, err := ResolveDuplicates(ctx, f.pool, nil, "", &fakeLidarr{}, true)
if err != nil {
t.Fatal(err)
}
return res
}
if res := resolve(); res.HeldBack != 1 {
t.Errorf("held back %d, want the rip", res.HeldBack)
}
if v := verdict(f.rip.ID); v == nil || *v != "suspect" {
t.Errorf("rip verdict %v, want suspect", v)
}
if v := verdict(f.clean.ID); v != nil {
t.Errorf("clean copy verdict %v, want none", *v)
}
if n, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "fine", ID: f.rip.ID}); err != nil || n != 1 {
t.Fatalf("set fine: %d, %v", n, err)
}
if res := resolve(); res.HeldBack != 0 {
t.Errorf("held back %d after the operator's fine, want 0", res.HeldBack)
}
if v := verdict(f.rip.ID); v == nil || *v != "fine" {
t.Errorf("rip verdict %v, want fine kept", v)
}
// Held back again, then renamed: the next pass lets it go.
if _, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "suspect", ID: f.rip.ID}); err != nil {
t.Fatal(err)
}
if _, err := f.pool.Exec(ctx, `UPDATE tracks SET file_path = $2 WHERE id = $1`,
f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - Feel Good Inc.mp3")); err != nil {
t.Fatal(err)
}
if res := resolve(); res.Released != 1 {
t.Errorf("released %d, want the renamed track", res.Released)
}
if v := verdict(f.rip.ID); v != nil {
t.Errorf("renamed track verdict %v, want none", *v)
}
// The clean copy can't be flagged by hand: there is nothing to judge.
if n, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "fine", ID: f.clean.ID}); err != nil || n != 0 {
t.Errorf("set on an unflagged track: %d, %v; want 0 rows", n, err)
}
}
+43
View File
@@ -13,6 +13,36 @@ type SurvivorCandidate struct {
FileFormat string FileFormat string
FileSize int64 FileSize int64
AddedAt time.Time AddedAt time.Time
// LidarrTracked: Lidarr maps this file to a track of the release it
// monitors. Removing it opens a hole Lidarr downloads again (M498), so a
// tracked copy outranks everything else. False when Lidarr was not asked.
LidarrTracked bool
// TagFit is TagFitScore: how well the copy's tags and name fit the album.
TagFit int
}
// TagFitScore counts the signs that a copy belongs where it is filed (M498
// #5436), one point each:
// - it has a track number that no other track on its album also claims
// - its file name carries no video-rip marker (SourceMarkersFor)
// - it has a MusicBrainz recording id
//
// Two copies of one song differ in these far more often than in anything a
// listener hears: the Humanz rips differ by a few bytes, and file size picked
// the wrong one in 6 of 21 groups.
func TagFitScore(hasTrackNumber, positionClash bool, filePath string, hasMBID bool) int {
score := 0
if hasTrackNumber && !positionClash {
score++
}
if len(SourceMarkersFor(filePath)) == 0 {
score++
}
if hasMBID {
score++
}
return score
} }
// losslessFormats are the scanned extensions that are lossless by definition. // losslessFormats are the scanned extensions that are lossless by definition.
@@ -26,6 +56,9 @@ var losslessFormats = map[string]bool{"flac": true, "wav": true}
// the report shows it and the merge (#3911) lets the operator choose another. // the report shows it and the merge (#3911) lets the operator choose another.
// //
// In order: // In order:
// 0. the copy Lidarr maps, then the copy whose tags fit the album best
// (TagFitScore). Both are zero for every copy when the caller does not
// know them, and the order falls through to the quality rules below
// 1. lossless over lossy — the one difference no later step can recover // 1. lossless over lossy — the one difference no later step can recover
// 2. the larger file — for one recording at one duration that is the higher // 2. the larger file — for one recording at one duration that is the higher
// bitrate. The scanner does not record bitrate (tracks.bitrate is never // bitrate. The scanner does not record bitrate (tracks.bitrate is never
@@ -48,6 +81,10 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
// runner-up — the rule that actually decided, not every rule it passed. // runner-up — the rule that actually decided, not every rule it passed.
next := ranked[1] next := ranked[1]
switch { switch {
case best.LidarrTracked != next.LidarrTracked:
return best.TrackID, "the copy Lidarr tracks"
case best.TagFit != next.TagFit:
return best.TrackID, "tags fit the album"
case isLossless(best) != isLossless(next): case isLossless(best) != isLossless(next):
return best.TrackID, "lossless (" + strings.ToLower(best.FileFormat) + ")" return best.TrackID, "lossless (" + strings.ToLower(best.FileFormat) + ")"
case best.FileSize != next.FileSize: case best.FileSize != next.FileSize:
@@ -60,6 +97,12 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
} }
func survivorBefore(a, b SurvivorCandidate) bool { func survivorBefore(a, b SurvivorCandidate) bool {
if a.LidarrTracked != b.LidarrTracked {
return a.LidarrTracked
}
if a.TagFit != b.TagFit {
return a.TagFit > b.TagFit
}
if isLossless(a) != isLossless(b) { if isLossless(a) != isLossless(b) {
return isLossless(a) return isLossless(a)
} }
@@ -89,3 +89,66 @@ func TestProposeSurvivor_ReasonIsTheDecidingRule(t *testing.T) {
t.Fatalf("got (%q, %q), want (flac-big, largest file)", id, reason) t.Fatalf("got (%q, %q), want (flac-big, largest file)", id, reason)
} }
} }
// M498 #5436: the copy Lidarr maps outranks everything, then the copy whose
// tags fit the album; quality decides only between copies equal on both. Each
// case is built so the older rules would choose the other copy, so a reorder
// that drops the new rules fails.
func TestProposeSurvivor_LidarrThenTagFit(t *testing.T) {
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
cands []SurvivorCandidate
wantID string
wantReason string
}{
{
name: "tracked beats lossless and better tags",
cands: []SurvivorCandidate{
{TrackID: "flac", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at, TagFit: 3},
{TrackID: "tracked", FileFormat: "mp3", FileSize: 5_000_000, AddedAt: at, TagFit: 1, LidarrTracked: true},
},
wantID: "tracked", wantReason: "the copy Lidarr tracks",
},
{
// The Humanz shape: two rips a few bytes apart, one carrying a
// video marker and clashing with another track's position.
name: "tag fit beats a few bytes",
cands: []SurvivorCandidate{
{TrackID: "rip", FileFormat: "mp3", FileSize: 5_104_498, AddedAt: at, TagFit: 1},
{TrackID: "clean", FileFormat: "mp3", FileSize: 5_104_492, AddedAt: at, TagFit: 3},
},
wantID: "clean", wantReason: "tags fit the album",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
id, reason := ProposeSurvivor(tc.cands)
if id != tc.wantID || reason != tc.wantReason {
t.Fatalf("ProposeSurvivor = (%q, %q), want (%q, %q)", id, reason, tc.wantID, tc.wantReason)
}
})
}
}
func TestTagFitScore(t *testing.T) {
cases := []struct {
name string
hasTrackNumber bool
clash bool
path string
hasMBID bool
want int
}{
{"clean, numbered, identified", true, false, "/music/A/B/A - B - 01 - Song.flac", true, 3},
{"position taken by another track", true, true, "/music/A/B/A - B - 01 - Song.flac", true, 2},
{"no track number", false, false, "/music/A/B/Song.flac", true, 2},
{"video rip", true, false, "/music/A/B/A - B - 01 - Song (Official Video).mp3", true, 2},
{"rip with nothing else", false, false, "/music/A/B/Song (Official Video).mp3", false, 0},
}
for _, tc := range cases {
if got := TagFitScore(tc.hasTrackNumber, tc.clash, tc.path, tc.hasMBID); got != tc.want {
t.Errorf("%s: TagFitScore = %d, want %d", tc.name, got, tc.want)
}
}
}
+7
View File
@@ -55,6 +55,10 @@ type FingerprintSettings struct {
AcousticMaxBitErrorRate float64 AcousticMaxBitErrorRate float64
BackfillConcurrency int32 BackfillConcurrency int32
SweepIntervalHours int32 SweepIntervalHours int32
// AutoResolve lets the duplicate resolver (M498) act on its own: merge
// copies Lidarr does not need and change a Lidarr release that lists songs
// twice. Off, it only classifies, and every group waits for the operator.
AutoResolve bool
// UpdatedAt is when the settings were last saved. Set by the database; // UpdatedAt is when the settings were last saved. Set by the database;
// ignored by Set. // ignored by Set.
UpdatedAt time.Time UpdatedAt time.Time
@@ -68,6 +72,7 @@ var DefaultFingerprintSettings = FingerprintSettings{
AcousticMaxBitErrorRate: defaultAcousticMaxBitErrorRate, AcousticMaxBitErrorRate: defaultAcousticMaxBitErrorRate,
BackfillConcurrency: fingerprintBackfillConcurrency, BackfillConcurrency: fingerprintBackfillConcurrency,
SweepIntervalHours: defaultSweepIntervalHours, SweepIntervalHours: defaultSweepIntervalHours,
AutoResolve: true,
} }
// ErrFingerprintSettingOutOfRange is returned by Set for a value migration // ErrFingerprintSettingOutOfRange is returned by Set for a value migration
@@ -121,6 +126,7 @@ func (s *FingerprintSettingsService) Set(ctx context.Context, in FingerprintSett
AcousticMaxBitErrorRate: in.AcousticMaxBitErrorRate, AcousticMaxBitErrorRate: in.AcousticMaxBitErrorRate,
BackfillConcurrency: in.BackfillConcurrency, BackfillConcurrency: in.BackfillConcurrency,
SweepIntervalHours: in.SweepIntervalHours, SweepIntervalHours: in.SweepIntervalHours,
AutoResolve: in.AutoResolve,
}) })
if err != nil { if err != nil {
return FingerprintSettings{}, fmt.Errorf("fingerprint settings: save: %w", err) return FingerprintSettings{}, fmt.Errorf("fingerprint settings: save: %w", err)
@@ -158,6 +164,7 @@ func fingerprintSettingsFromRow(row dbq.FingerprintSetting) FingerprintSettings
AcousticMaxBitErrorRate: row.AcousticMaxBitErrorRate, AcousticMaxBitErrorRate: row.AcousticMaxBitErrorRate,
BackfillConcurrency: row.BackfillConcurrency, BackfillConcurrency: row.BackfillConcurrency,
SweepIntervalHours: row.SweepIntervalHours, SweepIntervalHours: row.SweepIntervalHours,
AutoResolve: row.AutoResolve,
UpdatedAt: row.UpdatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
} }
} }
@@ -102,6 +102,7 @@ func TestFingerprintSettingsService_Integration(t *testing.T) {
AcousticMaxBitErrorRate: minAcousticMaxBitErrorRate, AcousticMaxBitErrorRate: minAcousticMaxBitErrorRate,
BackfillConcurrency: minBackfillConcurrency, BackfillConcurrency: minBackfillConcurrency,
SweepIntervalHours: minSweepIntervalHours, SweepIntervalHours: minSweepIntervalHours,
AutoResolve: false,
} }
highest := FingerprintSettings{ highest := FingerprintSettings{
Enabled: true, Enabled: true,
@@ -109,6 +110,7 @@ func TestFingerprintSettingsService_Integration(t *testing.T) {
AcousticMaxBitErrorRate: maxAcousticMaxBitErrorRate, AcousticMaxBitErrorRate: maxAcousticMaxBitErrorRate,
BackfillConcurrency: maxBackfillConcurrency, BackfillConcurrency: maxBackfillConcurrency,
SweepIntervalHours: maxSweepIntervalHours, SweepIntervalHours: maxSweepIntervalHours,
AutoResolve: true,
} }
for _, step := range []struct { for _, step := range []struct {
name string name string
+208
View File
@@ -0,0 +1,208 @@
package library
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
// songLinkFixture is the single and the album it came from: one song on two
// releases, in one pending acoustic group, and a user who liked the single.
type songLinkFixture struct {
single, albumCut dbq.Track
groupID pgtype.UUID
user dbq.User
}
func newSongLinkFixture(t *testing.T, pool *pgxpool.Pool) songLinkFixture {
t.Helper()
ctx := context.Background()
q := dbq.New(pool)
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("exec %q: %v", sql, err)
}
}
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Link Artist", SortName: "Link Artist"})
if err != nil {
t.Fatal(err)
}
track := func(album, path string) dbq.Track {
t.Helper()
a, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: album, SortTitle: album, ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Feel Good Inc.", AlbumID: a.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatal(err)
}
return tr
}
f := songLinkFixture{
single: track("Feel Good Inc. (single)", "/music/Link Artist/Feel Good Inc/01 Feel Good Inc.mp3"),
albumCut: track("Demon Days", "/music/Link Artist/Demon Days/06 Feel Good Inc.mp3"),
}
f.user, err = q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "song-link", PasswordHash: "x", ApiTokenHash: "song-link-token",
})
if err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx,
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('song-link', 'acoustic') RETURNING id`,
).Scan(&f.groupID); err != nil {
t.Fatal(err)
}
exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.single.ID, f.albumCut.ID)
exec(`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, f.user.ID, f.single.ID)
return f
}
// A cross-release group becomes one song: its copies share a song key, the
// like on the single reaches the album cut (and is logged for sync), the Liked
// list shows the song once, a like or unlike on either copy reaches both, and
// "not the same song" undoes the link.
func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
f := newSongLinkFixture(t, pool)
res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true)
if err != nil {
t.Fatal(err)
}
if res.Classes[ClassCrossRelease] != 1 || res.SongsLinked != 1 {
t.Fatalf("classes %v, linked %d; want one cross-release group linked", res.Classes, res.SongsLinked)
}
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
if keys[0] != keys[1] {
t.Fatalf("song keys differ after linking: %v", keys)
}
liked, err := q.ListLikedTrackIDs(ctx, f.user.ID)
if err != nil {
t.Fatal(err)
}
if len(liked) != 2 {
t.Errorf("liked track ids = %d, want both copies", len(liked))
}
var logged int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM library_changes WHERE entity_type = 'like_track' AND op = 'upsert'`,
).Scan(&logged); err != nil {
t.Fatal(err)
}
if logged != 1 {
t.Errorf("logged %d shared likes, want 1 (the album cut)", logged)
}
if n, err := q.CountLikedTracks(ctx, f.user.ID); err != nil || n != 1 {
t.Errorf("CountLikedTracks = %d (%v), want 1 song", n, err)
}
rows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{UserID: f.user.ID, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Errorf("Liked list has %d rows, want the song once", len(rows))
}
// A second pass changes nothing.
if res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true); err != nil || res.SongsLinked != 0 {
t.Errorf("second pass linked %d (%v), want 0", res.SongsLinked, err)
}
unliked, err := q.UnlikeTrack(ctx, dbq.UnlikeTrackParams{UserID: f.user.ID, TrackID: f.albumCut.ID})
if err != nil {
t.Fatal(err)
}
if len(unliked) != 2 {
t.Errorf("unlike removed %d likes, want both copies", len(unliked))
}
added, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID})
if err != nil {
t.Fatal(err)
}
if len(added) != 2 {
t.Errorf("like added %d, want both copies", len(added))
}
if again, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID}); err != nil || len(again) != 0 {
t.Errorf("repeat like added %d (%v), want 0", len(again), err)
}
if err := q.UnlinkDuplicateGroupSongs(ctx, f.groupID); err != nil {
t.Fatal(err)
}
keys = songKeys(t, q, f.single.ID, f.albumCut.ID)
if keys[0] == keys[1] {
t.Error("song keys still shared after unlinking")
}
}
// Without the operator's permission the resolver links nothing.
func TestCrossReleaseLinkNeedsAutoResolve_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
f := newSongLinkFixture(t, pool)
if _, err := ResolveDuplicates(ctx, pool, nil, "", nil, false); err != nil {
t.Fatal(err)
}
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
if keys[0] == keys[1] {
t.Error("linked with auto-resolve off")
}
}
// A merge keeps the removed copy's link to the song on another release.
func TestMergeInheritsSongLink_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
f := newSongLinkFixture(t, pool)
if _, err := q.LinkTracksAsSong(ctx, []pgtype.UUID{f.single.ID, f.albumCut.ID}); err != nil {
t.Fatal(err)
}
// A third, unlinked copy of the album cut stands in for a survivor.
other, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Feel Good Inc.", AlbumID: f.albumCut.AlbumID, ArtistID: f.albumCut.ArtistID,
DurationMs: 1000, FilePath: "/music/Link Artist/Demon Days/06 Feel Good Inc (1).mp3", FileSize: 90, FileFormat: "mp3",
})
if err != nil {
t.Fatal(err)
}
if err := q.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: other.ID, LoserID: f.albumCut.ID}); err != nil {
t.Fatal(err)
}
keys := songKeys(t, q, other.ID, f.single.ID)
if keys[0] != keys[1] {
t.Error("survivor did not join the song")
}
}
func songKeys(t *testing.T, q *dbq.Queries, ids ...pgtype.UUID) []pgtype.UUID {
t.Helper()
rows, err := q.ListTrackSongKeys(context.Background(), ids)
if err != nil {
t.Fatal(err)
}
byID := map[pgtype.UUID]pgtype.UUID{}
for _, r := range rows {
byID[r.ID] = r.SongKey
}
out := make([]pgtype.UUID, len(ids))
for i, id := range ids {
out[i] = byID[id]
}
return out
}
+69
View File
@@ -0,0 +1,69 @@
package library
import (
"path"
"regexp"
"strings"
)
// sourceMarker is one sign that a file was ripped from a video rather than
// taken from a release: words a video title carries and an album track does
// not (#5410).
//
// Each pattern is used twice, so it is written in the subset that Postgres's
// regex flavour and Go's RE2 read the same way: no \b (Postgres spells word
// boundaries \y), no lookaround, and brackets written as (\(|\[) rather than
// as a bracket expression. The SQL side matches it case-insensitively with ~*,
// the Go side with (?i); [^a-zA-Z] is spelled out so neither has to fold case
// inside a negated class.
type sourceMarker struct {
label string
pattern string
}
// suspectSourceMarkers was calibrated against the operator's library on
// 2026-10-08. Two candidates were left out on purpose:
// - "live in"/"live at": 229 files, nearly all from real live albums.
// - a bare "reaction": it caught Beck's "Chain Reaction". The marker below
// wants the phrases a reaction video actually uses.
var suspectSourceMarkers = []sourceMarker{
{"music video", `official[^a-zA-Z]*(music[^a-zA-Z]*)?video|music[^a-zA-Z]*video`},
{"official audio", `official[^a-zA-Z]*audio`},
{"lyric video", `lyrics?[^a-zA-Z]*video`},
{"visualiser", `visuali[sz]er`},
{"reaction", `reaction[^a-zA-Z]*(video|mashup)|reacts?[^a-zA-Z]+to[^a-zA-Z]|first[^a-zA-Z]*time[^a-zA-Z]*(hearing|listening)`},
{"MV", `(^|[^a-zA-Z])(mv|m/v)([^a-zA-Z]|$)`},
{"[Audio]", `(\(|\[)audio(\)|\])`},
{"[HD]", `(\(|\[)(hd|hq|4k)(\)|\])`},
}
// SuspectSourcePattern is every marker as one alternation, for the SQL filter
// behind the admin suspect-sources report.
var SuspectSourcePattern = func() string {
parts := make([]string, len(suspectSourceMarkers))
for i, m := range suspectSourceMarkers {
parts[i] = "(" + m.pattern + ")"
}
return strings.Join(parts, "|")
}()
var suspectSourceRegexps = func() []*regexp.Regexp {
out := make([]*regexp.Regexp, len(suspectSourceMarkers))
for i, m := range suspectSourceMarkers {
out[i] = regexp.MustCompile("(?i)" + m.pattern)
}
return out
}()
// SourceMarkersFor returns the labels of every marker the file's basename
// carries, in list order. Empty for a file the SQL filter would not return.
func SourceMarkersFor(filePath string) []string {
base := path.Base(filePath)
labels := make([]string, 0, 2)
for i, re := range suspectSourceRegexps {
if re.MatchString(base) {
labels = append(labels, suspectSourceMarkers[i].label)
}
}
return labels
}
+53
View File
@@ -0,0 +1,53 @@
package library
import (
"slices"
"testing"
)
// Real basenames from the operator's library (2026-10-08), each with the
// labels it should carry. The negatives are the near misses that shaped the
// list: a song called "Chain Reaction", a live album, an ordinary track.
func TestSourceMarkersFor(t *testing.T) {
cases := []struct {
base string
want []string
}{
{"Daft Punk - Random Access Memories - 06 - Daft Punk - Doin' It Right (Music Video) ft. Panda Bear.mp3", []string{"music video"}},
{"Gorillaz - Humanz - 03 - Gorillaz - Saturnz Barz (Official Video).mp3", []string{"music video"}},
{"Gorillaz - Humanz - 05 - Gorillaz - Andromeda (Official Audio).mp3", []string{"official audio"}},
{"Gorillaz - Gorillaz - 06 - Gorillaz - P45 (Visualizer).mp3", []string{"visualiser"}},
{"Gorillaz - Demon Days - 16 - Gorillaz - Don Quixote's Christmas Bonanza (Visualiser).mp3", []string{"visualiser"}},
{"Artist - Album - 01 - Artist - Song (Lyric Video).mp3", []string{"lyric video"}},
{"Gorillaz - Humanz - 02 - FIRST TIME HEARING Gorillaz - Ascension REACTION.mp3", []string{"reaction"}},
{"Watsky - INTENTION - 05 - MANIAC Reacts to Watsky - AWW SHiT.mp3", []string{"reaction"}},
{"米津玄師 - diorama - 09 - 【MV】米津玄師 - 恋と病熱.mp3", []string{"MV"}},
{"Andora - Ego - 01 - Andora - Ego (feat. Will Stetson) MV.mp3", []string{"MV"}},
{"Jimmy Eat World - Something(s) Loud - 05 - Jimmy Eat World - Call to Love (Audio).mp3", []string{"[Audio]"}},
{"Record Heat - World War IV - 03 - Record Heat - Front Seat Feelin' [Audio].mp3", []string{"[Audio]"}},
{"Aphex Twin - Come To Daddy - 03 - Aphex Twin - Bucephalus Bouncing Ball (HQ).mp3", []string{"[HD]"}},
{"Artist - Album - 01 - Artist - Song (Official Lyric Video) [4K].mp3", []string{"lyric video", "[HD]"}},
{"Beck - Guero - 15 - Beck - Chain Reaction.mp3", nil},
{"Nirvana - MTV Unplugged in New York - 01 - About a Girl (live in New York).flac", nil},
{"Boards of Canada - Music Has the Right to Children - 05 - Roygbiv.flac", nil},
{"Artist - Album - 01 - Mvula.flac", nil},
}
for _, c := range cases {
got := SourceMarkersFor("/music/x/" + c.base)
if len(got) == 0 && len(c.want) == 0 {
continue // nil and empty both mean "no markers"
}
if !slices.Equal(got, c.want) {
t.Errorf("%s:\n got %v\n want %v", c.base, got, c.want)
}
}
}
// The folder is not evidence: a marker word in a directory name must not
// flag the files inside it.
func TestSourceMarkersFor_ReadsOnlyTheBasename(t *testing.T) {
if got := SourceMarkersFor("/music/Official Video Collection/01 - Song.flac"); len(got) != 0 {
t.Errorf("directory name flagged the file: %v", got)
}
}
+154
View File
@@ -0,0 +1,154 @@
package lidarr
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// What Lidarr holds on disk and how it maps it (Scribe milestone #498).
//
// Lidarr maps one file to each track of the ONE release it monitors per album.
// A file it holds but maps to no track is "unmapped": removing it opens no hole.
// Removing a mapped file does, and Lidarr fills the hole by downloading again —
// which is why Minstrel's duplicate resolver asks before it deletes anything.
// TrackFile is a file Lidarr holds on disk.
type TrackFile struct {
ID int `json:"id"`
Path string `json:"path"`
AlbumID int `json:"albumId"`
}
// ListUnmappedTrackFiles hits GET /api/v1/trackfile?unmapped=true: files under
// Lidarr's root folders that it has scanned but matched to no track.
func (c *Client) ListUnmappedTrackFiles(ctx context.Context) ([]TrackFile, error) {
resp, err := c.get(ctx, "/api/v1/trackfile", url.Values{"unmapped": {"true"}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var out []TrackFile
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("%w: decode unmapped track files: %v", ErrInvalidPayload, err)
}
return out, nil
}
// AlbumRelease is one edition of an album as Lidarr knows it: the 14×12" vinyl
// box set, the 2×CD deluxe, the digital standard. Exactly one is Monitored.
type AlbumRelease struct {
ID int `json:"id"`
Title string `json:"title"`
Disambiguation string `json:"disambiguation"`
Format string `json:"format"`
TrackCount int `json:"trackCount"`
MediumCount int `json:"mediumCount"`
Monitored bool `json:"monitored"`
}
// ReleaseTrack is one track of a release.
type ReleaseTrack struct {
ID int `json:"id"`
Title string `json:"title"`
MediumNumber int `json:"mediumNumber"`
TrackFileID int `json:"trackFileId"`
}
// GetAlbumReleases hits GET /api/v1/album/{id} and returns the album's releases.
func (c *Client) GetAlbumReleases(ctx context.Context, albumID int) ([]AlbumRelease, error) {
resp, err := c.get(ctx, "/api/v1/album/"+strconv.Itoa(albumID), nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var album struct {
Releases []AlbumRelease `json:"releases"`
}
if err := json.NewDecoder(resp.Body).Decode(&album); err != nil {
return nil, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
}
return album.Releases, nil
}
// ListAlbumTracks hits GET /api/v1/track?albumId= : the tracks of the release
// Lidarr monitors for that album.
func (c *Client) ListAlbumTracks(ctx context.Context, albumID int) ([]ReleaseTrack, error) {
return c.listTracks(ctx, url.Values{"albumId": {strconv.Itoa(albumID)}})
}
// ListReleaseTracks hits GET /api/v1/track?albumReleaseId= : the tracks of one
// release, monitored or not.
func (c *Client) ListReleaseTracks(ctx context.Context, releaseID int) ([]ReleaseTrack, error) {
return c.listTracks(ctx, url.Values{"albumReleaseId": {strconv.Itoa(releaseID)}})
}
func (c *Client) listTracks(ctx context.Context, q url.Values) ([]ReleaseTrack, error) {
resp, err := c.get(ctx, "/api/v1/track", q)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var out []ReleaseTrack
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("%w: decode tracks: %v", ErrInvalidPayload, err)
}
return out, nil
}
// SetMonitoredRelease makes releaseID the album's monitored release.
//
// It is the round trip Lidarr's own "edit album" makes: GET the album resource,
// flip `monitored` on its releases, PUT the whole resource back. Lidarr's
// UpdateAlbum publishes AlbumEditedEvent, and when the monitored release changed,
// AlbumEditedService unlinks the album's track files and queues
// RescanFoldersCommand for the artist's folder, which maps the files on disk to
// the new release's tracks. Files the new release has no track for come out of
// that rescan unmapped. (Lidarr source, develop: Music/Services/
// AlbumEditedService.cs and AlbumService.UpdateAlbum.)
//
// The resource is kept as raw JSON so every field Lidarr sent goes back
// unchanged; only the releases' monitored flags differ. Returns ErrNotFound when
// the album has no release with that id.
func (c *Client) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
path := "/api/v1/album/" + strconv.Itoa(albumID)
resp, err := c.get(ctx, path, nil)
if err != nil {
return err
}
var album map[string]any
err = json.NewDecoder(resp.Body).Decode(&album)
_ = resp.Body.Close()
if err != nil {
return fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
}
releases, _ := album["releases"].([]any)
found := false
for _, r := range releases {
rel, ok := r.(map[string]any)
if !ok {
continue
}
id, _ := rel["id"].(float64)
match := int(id) == releaseID
rel["monitored"] = match
found = found || match
}
if !found {
return fmt.Errorf("%w: release %d on album %d", ErrNotFound, releaseID, albumID)
}
body, err := json.Marshal(album)
if err != nil {
return fmt.Errorf("%w: marshal album: %v", ErrInvalidPayload, err)
}
putResp, err := c.put(ctx, path, body)
if err != nil {
return err
}
_ = putResp.Body.Close()
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package lidarr
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"testing"
)
func TestListUnmappedTrackFiles(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/trackfile" || r.URL.Query().Get("unmapped") != "true" {
t.Errorf("request = %s?%s, want /api/v1/trackfile?unmapped=true", r.URL.Path, r.URL.RawQuery)
}
_, _ = w.Write([]byte(`[{"id":7,"path":"/music/A/B (2017)/A - B - 02 - X.mp3","albumId":0}]`))
})
defer srv.Close()
got, err := c.ListUnmappedTrackFiles(context.Background())
if err != nil {
t.Fatalf("ListUnmappedTrackFiles: %v", err)
}
if len(got) != 1 || got[0].ID != 7 || got[0].Path != "/music/A/B (2017)/A - B - 02 - X.mp3" {
t.Errorf("got %+v", got)
}
}
func TestReleaseReads(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/album/4723":
_, _ = w.Write([]byte(`{"id":4723,"releases":[
{"id":10148,"title":"Humanz","format":"14x12\" Vinyl, Digital Media","trackCount":68,"mediumCount":15,"monitored":true},
{"id":10149,"title":"Humanz","format":"Digital Media","trackCount":40,"mediumCount":1,"monitored":false}]}`))
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumId") == "4723":
_, _ = w.Write([]byte(`[{"id":1,"title":"Ascension","mediumNumber":1,"trackFileId":9}]`))
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumReleaseId") == "10149":
_, _ = w.Write([]byte(`[{"id":2,"title":"Ascension","mediumNumber":1,"trackFileId":0}]`))
default:
t.Errorf("unexpected request %s?%s", r.URL.Path, r.URL.RawQuery)
w.WriteHeader(http.StatusNotFound)
}
})
defer srv.Close()
ctx := context.Background()
rels, err := c.GetAlbumReleases(ctx, 4723)
if err != nil {
t.Fatalf("GetAlbumReleases: %v", err)
}
if len(rels) != 2 || !rels[0].Monitored || rels[0].MediumCount != 15 || rels[1].TrackCount != 40 {
t.Errorf("releases = %+v", rels)
}
tr, err := c.ListAlbumTracks(ctx, 4723)
if err != nil || len(tr) != 1 || tr[0].TrackFileID != 9 {
t.Errorf("ListAlbumTracks = %+v, %v", tr, err)
}
tr, err = c.ListReleaseTracks(ctx, 10149)
if err != nil || len(tr) != 1 || tr[0].Title != "Ascension" {
t.Errorf("ListReleaseTracks = %+v, %v", tr, err)
}
}
// The PUT carries the whole resource back, with only the releases' monitored
// flags changed: exactly the chosen release is monitored, and a field Minstrel
// does not model survives the round trip.
func TestSetMonitoredRelease_RoundTripsTheResource(t *testing.T) {
var put map[string]any
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/album/4723" {
t.Errorf("path = %q", r.URL.Path)
}
switch r.Method {
case http.MethodGet:
_, _ = w.Write([]byte(`{"id":4723,"anyReleaseOk":false,"secondaryTypes":["Studio"],"releases":[
{"id":10148,"monitored":true},{"id":10149,"monitored":false}]}`))
case http.MethodPut:
b, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(b, &put); err != nil {
t.Errorf("PUT body: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}
})
defer srv.Close()
if err := c.SetMonitoredRelease(context.Background(), 4723, 10149); err != nil {
t.Fatalf("SetMonitoredRelease: %v", err)
}
rels := put["releases"].([]any)
if m := rels[0].(map[string]any)["monitored"]; m != false {
t.Errorf("old release monitored = %v, want false", m)
}
if m := rels[1].(map[string]any)["monitored"]; m != true {
t.Errorf("new release monitored = %v, want true", m)
}
if st, _ := put["secondaryTypes"].([]any); len(st) != 1 || st[0] != "Studio" {
t.Errorf("secondaryTypes = %v, want it carried through unchanged", put["secondaryTypes"])
}
}
func TestSetMonitoredRelease_UnknownReleaseSendsNothing(t *testing.T) {
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
t.Error("PUT sent for a release the album does not have")
}
_, _ = w.Write([]byte(`{"id":4723,"releases":[{"id":10148,"monitored":true}]}`))
})
defer srv.Close()
if err := c.SetMonitoredRelease(context.Background(), 4723, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
+15 -4
View File
@@ -9,8 +9,9 @@
package notifications package notifications
// Kind names one sort of notification. The set is CHECK-gated in migration // Kind names one sort of notification. The set is CHECK-gated in migration
// 0073 (rule 36): a new kind adds its value there, in the same change, and // 0073, swapped since by 0075 (rule 36): a new kind adds its value in a
// TestKindsMatchMigrationCheck fails until it does. // migration of the same change, and TestEveryKindPassesTheSchemaChecks
// fails until it does.
type Kind string type Kind string
const ( const (
@@ -22,7 +23,11 @@ const (
KindScanFailed Kind = "scan_failed" KindScanFailed Kind = "scan_failed"
KindTracksMissing Kind = "tracks_missing" KindTracksMissing Kind = "tracks_missing"
KindDuplicatesFound Kind = "duplicates_found" KindDuplicatesFound Kind = "duplicates_found"
KindPlaybackErrors Kind = "playback_errors" // KindDuplicatesResolved: the duplicate resolver (M498) merged copies or
// changed a Lidarr release on its own. The operator asked for that to
// happen automatically; this is how it stays visible.
KindDuplicatesResolved Kind = "duplicates_resolved"
KindPlaybackErrors Kind = "playback_errors"
) )
// Audience is who a kind can reach. Admin kinds are never offered to, or // Audience is who a kind can reach. Admin kinds are never offered to, or
@@ -77,6 +82,9 @@ type spec struct {
var ( var (
allOn = Channels{Inbox: true, Phone: true, Email: true} allOn = Channels{Inbox: true, Phone: true, Email: true}
healthAlert = Channels{Inbox: true, Phone: true, Email: false} healthAlert = Channels{Inbox: true, Phone: true, Email: false}
// inboxOnly: news an admin may want to read but never needs to be
// interrupted for.
inboxOnly = Channels{Inbox: true}
) )
var specs = map[Kind]spec{ var specs = map[Kind]spec{
@@ -88,7 +96,9 @@ var specs = map[Kind]spec{
KindScanFailed: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert}, KindScanFailed: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
KindTracksMissing: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert}, KindTracksMissing: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert}, KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
KindPlaybackErrors: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert}, // Each pass reports what it did itself, so the counts add up.
KindDuplicatesResolved: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: inboxOnly},
KindPlaybackErrors: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
} }
// order is the display order for settings screens: the requester's own kinds // order is the display order for settings screens: the requester's own kinds
@@ -102,6 +112,7 @@ var order = []Kind{
KindScanFailed, KindScanFailed,
KindTracksMissing, KindTracksMissing,
KindDuplicatesFound, KindDuplicatesFound,
KindDuplicatesResolved,
KindPlaybackErrors, KindPlaybackErrors,
} }
+1 -1
View File
@@ -36,7 +36,7 @@ func TestKinds_OrderAndSpecsAgree(t *testing.T) {
func TestKinds_AdminKindsDefaultEmailOffExceptRequestQueue(t *testing.T) { func TestKinds_AdminKindsDefaultEmailOffExceptRequestQueue(t *testing.T) {
// The burst-prone health kinds stay out of email unless an admin opts // The burst-prone health kinds stay out of email unless an admin opts
// in; the request queue and quarantine flags are things an admin acts on. // in; the request queue and quarantine flags are things an admin acts on.
for _, k := range []Kind{KindScanFailed, KindTracksMissing, KindDuplicatesFound, KindPlaybackErrors} { for _, k := range []Kind{KindScanFailed, KindTracksMissing, KindDuplicatesFound, KindDuplicatesResolved, KindPlaybackErrors} {
require.True(t, k.AdminOnly(), k) require.True(t, k.AdminOnly(), k)
require.False(t, k.Defaults().Email, k) require.False(t, k.Defaults().Email, k)
require.NotEmpty(t, k.coalesceKey(), "%s should coalesce", k) require.NotEmpty(t, k.coalesceKey(), "%s should coalesce", k)
+12 -1
View File
@@ -37,7 +37,8 @@ type Payload struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
// Count is the coalesced kinds' running total. // Count is the coalesced kinds' running total.
Count int64 `json:"count,omitempty"` Count int64 `json:"count,omitempty"`
// Detail is free text for scan_failed (the error message). // Detail is free text: scan_failed's error message, and what the duplicate
// resolver changed in Lidarr for duplicates_resolved.
Detail string `json:"detail,omitempty"` Detail string `json:"detail,omitempty"`
} }
@@ -115,6 +116,16 @@ func Render(kind Kind, payload []byte) Rendered {
Body: "The duplicate sweep found tracks holding the same recording.", Body: "The duplicate sweep found tracks holding the same recording.",
Link: "/admin/duplicates", Link: "/admin/duplicates",
} }
case KindDuplicatesResolved:
body := "Copies Lidarr does not need were merged into the one it tracks."
if p.Detail != "" {
body = p.Detail
}
return Rendered{
Title: plural(p.Count, "duplicate resolved automatically", "duplicates resolved automatically"),
Body: body,
Link: "/admin/duplicates?view=resolved",
}
case KindPlaybackErrors: case KindPlaybackErrors:
return Rendered{ return Rendered{
Title: plural(p.Count, "playback error reported", "playback errors reported"), Title: plural(p.Count, "playback error reported", "playback errors reported"),
+10
View File
@@ -0,0 +1,10 @@
package playlists
// Bridges for the external playlists_test package, whose integration helpers
// (newPool, seedTrack) live there.
// RankedCandidate is rankedCandidate, for tests.
type RankedCandidate = rankedCandidate
// OneCopyPerSong is oneCopyPerSong, for tests.
var OneCopyPerSong = oneCopyPerSong
+50
View File
@@ -0,0 +1,50 @@
package playlists_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// A mix holds a song once (M498 #5438) and never a held-back video rip
// (#5439): of a single and its album cut, the better-ranked copy stays; a rip
// is left out and the next copy of its song takes the place.
func TestOneCopyPerSong_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
single := seedTrack(t, pool, "copies-single", "copies-artist")
albumCut := seedTrack(t, pool, "copies-album-cut", "copies-artist")
rip := seedTrack(t, pool, "copies-rip", "copies-artist")
clean := seedTrack(t, pool, "copies-clean", "copies-artist")
other := seedTrack(t, pool, "copies-other", "copies-artist")
for _, pair := range [][]pgtype.UUID{{single.ID, albumCut.ID}, {rip.ID, clean.ID}} {
if _, err := q.LinkTracksAsSong(ctx, pair); err != nil {
t.Fatal(err)
}
}
if _, err := pool.Exec(ctx, `UPDATE tracks SET source_verdict = 'suspect' WHERE id = $1`, rip.ID); err != nil {
t.Fatal(err)
}
in := []playlists.RankedCandidate{
{TrackID: single.ID}, {TrackID: rip.ID}, {TrackID: albumCut.ID}, {TrackID: other.ID}, {TrackID: clean.ID},
}
out, err := playlists.OneCopyPerSong(ctx, q, in)
if err != nil {
t.Fatal(err)
}
want := []pgtype.UUID{single.ID, other.ID, clean.ID}
if len(out) != len(want) {
t.Fatalf("got %d tracks, want %d", len(out), len(want))
}
for i, w := range want {
if out[i].TrackID != w {
t.Errorf("position %d = %s, want %s", i, uuidString(out[i].TrackID), uuidString(w))
}
}
}
+38
View File
@@ -1070,6 +1070,10 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err) return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err)
} }
tracks, err = oneCopyPerSong(ctx, qtx, tracks)
if err != nil {
return pgtype.UUID{}, err
}
for _, t := range tracks { for _, t := range tracks {
var pickKind *string var pickKind *string
if t.PickKind != "" { if t.PickKind != "" {
@@ -1096,6 +1100,40 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
return p.ID, nil return p.ID, nil
} }
// oneCopyPerSong keeps the first of a song's copies on different releases
// (M498 #5438): a mix holds the song once, in its best-ranked place. A video
// rip held back from the mixes (#5439) is left out, and a clean copy of the
// same song can take its place.
func oneCopyPerSong(ctx context.Context, qtx *dbq.Queries, tracks []rankedCandidate) ([]rankedCandidate, error) {
ids := make([]pgtype.UUID, len(tracks))
for i, t := range tracks {
ids[i] = t.TrackID
}
rows, err := qtx.ListTrackSongKeys(ctx, ids)
if err != nil {
return nil, fmt.Errorf("song keys: %w", err)
}
songOf := make(map[pgtype.UUID]dbq.ListTrackSongKeysRow, len(rows))
for _, r := range rows {
songOf[r.ID] = r
}
seen := make(map[pgtype.UUID]bool, len(tracks))
out := tracks[:0:0]
for _, t := range tracks {
row, ok := songOf[t.TrackID]
if ok && row.HeldBack {
continue
}
song := row.SongKey
if ok && seen[song] {
continue
}
seen[song] = true
out = append(out, t)
}
return out, nil
}
// uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form. // uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form.
// (pgtype.UUID's String method exists on some pgx versions but not others; // (pgtype.UUID's String method exists on some pgx versions but not others;
// also the playlists package needs this independent of test helpers.) // also the playlists package needs this independent of test helpers.)
+27 -3
View File
@@ -52,6 +52,12 @@ func RadioDiversityCaps(limit int) DiversityCaps {
return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album} return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album}
} }
// heldBack says whether the candidate is a video rip held back from radio
// (M498 #5439): it plays when chosen, but radio does not choose it.
func heldBack(c Candidate) bool {
return c.Track.SourceVerdict != nil && *c.Track.SourceVerdict == "suspect"
}
// Shuffle scores each candidate, sorts descending by score, and returns the // Shuffle scores each candidate, sorts descending by score, and returns the
// top `limit` candidates, preferring artist/album diversity. limit <= 0 // top `limit` candidates, preferring artist/album diversity. limit <= 0
// returns nil; nil input returns nil. Pure — no IO, no global state beyond // returns nil; nil input returns nil. Pure — no IO, no global state beyond
@@ -61,7 +67,9 @@ func RadioDiversityCaps(limit int) DiversityCaps {
// first takes candidates that fit under the caps, and the second fills any // first takes candidates that fit under the caps, and the second fills any
// remaining slots from those the first pass skipped, still in score order. // remaining slots from those the first pass skipped, still in score order.
// So the result holds min(limit, len(candidates)) either way — the caps // So the result holds min(limit, len(candidates)) either way — the caps
// change WHICH tracks are chosen, never HOW MANY. // change WHICH tracks are chosen, never HOW MANY. (What is not a candidate at
// all is dropped first: a held-back video rip, and a second copy of a song
// already chosen from another release, M498.)
// //
// That two-pass shape is the whole design, and a hard cap would have been // That two-pass shape is the whole design, and a hard cap would have been
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a // the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
@@ -104,11 +112,24 @@ func Shuffle(
deferred := make([]Candidate, 0, len(scored)-limit) deferred := make([]Candidate, 0, len(scored)-limit)
artistCount := map[pgtype.UUID]int{} artistCount := map[pgtype.UUID]int{}
albumCount := map[pgtype.UUID]int{} albumCount := map[pgtype.UUID]int{}
// One song on several releases is played once (M498 #5438): after its
// best-scoring copy, the others are dropped, not held back.
songs := map[pgtype.UUID]bool{}
repeat := func(c Candidate) bool { return c.Track.SongKey.Valid && songs[c.Track.SongKey] }
take := func(c Candidate) {
if c.Track.SongKey.Valid {
songs[c.Track.SongKey] = true
}
out = append(out, c)
}
for _, s := range scored { for _, s := range scored {
if len(out) == limit { if len(out) == limit {
break break
} }
if heldBack(s.c) || repeat(s.c) {
continue
}
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
if overArtist || overAlbum { if overArtist || overAlbum {
@@ -118,7 +139,7 @@ func Shuffle(
} }
artistCount[s.c.Track.ArtistID]++ artistCount[s.c.Track.ArtistID]++
albumCount[s.c.Track.AlbumID]++ albumCount[s.c.Track.AlbumID]++
out = append(out, s.c) take(s.c)
} }
// Pass two: the caps could not fill the request, so relax them rather // Pass two: the caps could not fill the request, so relax them rather
@@ -128,7 +149,10 @@ func Shuffle(
if len(out) == limit { if len(out) == limit {
break break
} }
out = append(out, c) if repeat(c) {
continue
}
take(c)
} }
return out return out
} }
+42
View File
@@ -84,3 +84,45 @@ func titles(cs []Candidate) []string {
// pure tests; the Track.Title field is the human-readable handle. Track ID // pure tests; the Track.Title field is the human-readable handle. Track ID
// is left as zero pgtype.UUID and never compared. // is left as zero pgtype.UUID and never compared.
var _ pgtype.UUID var _ pgtype.UUID
// One song on two releases is played once, in its better-scoring copy's place,
// and a capped copy held back for pass two does not slip back in either.
func TestShuffle_OneCopyPerSong(t *testing.T) {
song := func(c Candidate, key string) Candidate {
_ = c.Track.SongKey.Scan("00000000-0000-0000-0000-" + key)
return c
}
album := pgtype.UUID{Bytes: [16]byte{9}, Valid: true}
single := song(cand("000000000001", ScoringInputs{IsGeneralLiked: true}), "00000000000a")
albumCut := song(cand("000000000002", ScoringInputs{}), "00000000000a")
other := cand("000000000003", ScoringInputs{})
single.Track.AlbumID, albumCut.Track.AlbumID = album, pgtype.UUID{Bytes: [16]byte{8}, Valid: true}
out := Shuffle([]Candidate{albumCut, single, other}, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
if got := titles(out); len(got) != 2 || got[0] != "000000000001" {
t.Errorf("got %v, want the liked single once and the other song", got)
}
// The single is capped out of pass one; pass two takes one copy, not both.
capped := cand("000000000004", ScoringInputs{IsGeneralLiked: true})
capped.Track.AlbumID = album
out = Shuffle([]Candidate{capped, single, albumCut}, defaultWeights(), time.Now(), fixedRNG(0.5), 3, DiversityCaps{MaxPerAlbum: 1})
if len(out) != 2 {
t.Errorf("got %v, want two tracks: one copy of the song", titles(out))
}
}
// A video rip held back from radio (M498 #5439) is never chosen, even to fill
// the request.
func TestShuffle_SkipsHeldBackRips(t *testing.T) {
suspect := "suspect"
fine := "fine"
rip := cand("000000000001", ScoringInputs{IsGeneralLiked: true})
rip.Track.SourceVerdict = &suspect
cleared := cand("000000000002", ScoringInputs{})
cleared.Track.SourceVerdict = &fine
out := Shuffle([]Candidate{rip, cleared}, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
if got := titles(out); len(got) != 1 || got[0] != "000000000002" {
t.Errorf("got %v, want only the track marked fine", got)
}
}
+11
View File
@@ -57,6 +57,17 @@ func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, a
return c.UnmonitorTrack(ctx, trackMbid, albumMbid) return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
} }
// ListUnmappedTrackFiles lets the duplicate merge refuse to remove a copy
// Lidarr maps (M498). tracks.ErrLidarrDisabled tells it there is no Lidarr to
// protect.
func (a lidarrUnmonitorAdapter) ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error) {
c := a.fn()
if c == nil {
return nil, tracks.ErrLidarrDisabled
}
return c.ListUnmappedTrackFiles(ctx)
}
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an // ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
// interface so tests can stub it without touching the DB. The progressCb // interface so tests can stub it without touching the DB. The progressCb
// parameter (added in m7-scan-progress) lets the orchestrator drive partial- // parameter (added in m7-scan-progress) lets the orchestrator drive partial-
+5 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"slices"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -39,12 +40,13 @@ func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
return return
} }
if trackID.Valid { if trackID.Valid {
rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}) // Stars the song: every copy of it (M498 #5438).
liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID})
if err != nil { if err != nil {
WriteFail(w, r, ErrGeneric, "Could not star track") WriteFail(w, r, ErrGeneric, "Could not star track")
return return
} }
if rows == 1 { if slices.Contains(liked, trackID) {
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger) _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger)
} }
} }
@@ -75,7 +77,7 @@ func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query() params := r.URL.Query()
q := dbq.New(m.pool) q := dbq.New(m.pool)
if t, ok := parseUUID(params.Get("id")); ok { if t, ok := parseUUID(params.Get("id")); ok {
_ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t}) _, _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
_ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t) _ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t)
} }
if a, ok := parseUUID(params.Get("albumId")); ok { if a, ok := parseUUID(params.Get("albumId")); ok {
+48 -2
View File
@@ -31,6 +31,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/audit" "git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
) )
@@ -40,6 +41,22 @@ import (
// same pointer. // same pointer.
var ErrNotFound = apierror.ErrNotFound var ErrNotFound = apierror.ErrNotFound
// ErrLidarrDisabled is what a LidarrUnmonitorer that also lists Lidarr's
// unmapped files returns when Lidarr is turned off: there is then no Lidarr
// record a removal could break, and a merge goes ahead unguarded.
var ErrLidarrDisabled = errors.New("tracks: lidarr disabled")
// ErrLidarrUnavailable means Lidarr is on but did not answer, so a merge cannot
// confirm the copies it would remove are ones Lidarr does not need (M498), and
// refuses rather than guess.
var ErrLidarrUnavailable = errors.New("tracks: lidarr did not answer")
// lidarrFileLister is the optional part of the Lidarr dependency a merge uses to
// learn which files Lidarr holds without mapping to any track.
type lidarrFileLister interface {
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
}
// LidarrUnmonitorer is the subset of *lidarr.Client RemoveTrack uses. // LidarrUnmonitorer is the subset of *lidarr.Client RemoveTrack uses.
// Defined as an interface so tests can stub without spinning up a Lidarr // Defined as an interface so tests can stub without spinning up a Lidarr
// httptest server. UnmonitorTrack failures are non-fatal at the service // httptest server. UnmonitorTrack failures are non-fatal at the service
@@ -151,7 +168,9 @@ func (s *Service) RemoveTrack(
} }
// MergeDuplicates merges a duplicate group into the copy to keep // MergeDuplicates merges a duplicate group into the copy to keep
// (library.MergeDuplicateGroup), then — when asked — tells Lidarr to stop // (library.MergeDuplicateGroupGuarded, refusing to remove a copy Lidarr maps:
// library.ErrCopyTrackedByLidarr, or ErrLidarrUnavailable when Lidarr cannot
// say), then — when asked — tells Lidarr to stop
// monitoring the removed copies so it does not download them again, and records // monitoring the removed copies so it does not download them again, and records
// the merge in the audit log. // the merge in the audit log.
// //
@@ -161,7 +180,11 @@ func (s *Service) RemoveTrack(
func (s *Service) MergeDuplicates( func (s *Service) MergeDuplicates(
ctx context.Context, groupID, survivorID, actorID pgtype.UUID, unmonitor bool, ctx context.Context, groupID, survivorID, actorID pgtype.UUID, unmonitor bool,
) (res library.MergeResult, lidarrUnmonitorFailed bool, err error) { ) (res library.MergeResult, lidarrUnmonitorFailed bool, err error) {
res, err = library.MergeDuplicateGroup(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID) removable, err := s.lidarrRemovable(ctx)
if err != nil {
return res, false, err
}
res, err = library.MergeDuplicateGroupGuarded(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID, removable)
if err != nil { if err != nil {
return res, false, err return res, false, err
} }
@@ -203,6 +226,29 @@ func (s *Service) MergeDuplicates(
return res, lidarrUnmonitorFailed, nil return res, lidarrUnmonitorFailed, nil
} }
// lidarrRemovable is the merge's guard (M498): only a copy Lidarr holds without
// mapping it to a track may be removed, because removing a mapped one makes
// Lidarr download it again. Nil, allowing everything, when Lidarr is disabled
// or the dependency cannot list files.
func (s *Service) lidarrRemovable(ctx context.Context) (library.RemovableFunc, error) {
lister, ok := s.lidarr.(lidarrFileLister)
if !ok {
return nil, nil
}
files, err := lister.ListUnmappedTrackFiles(ctx)
if errors.Is(err, ErrLidarrDisabled) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrLidarrUnavailable, err)
}
unmapped := make(map[string]bool, len(files))
for _, f := range files {
unmapped[library.LidarrPathKey(f.Path)] = true
}
return func(p string) bool { return unmapped[library.LidarrPathKey(p)] }, nil
}
// sameLidarrTrack reports whether two copies are the same Lidarr track: one // sameLidarrTrack reports whether two copies are the same Lidarr track: one
// recording on one album. Lidarr monitors per album track, so when the removed // recording on one album. Lidarr monitors per album track, so when the removed
// copy is a second file of the kept copy's own album track — the #3885 case — // copy is a second file of the kept copy's own album track — the #3885 case —
+668 -1062
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -12,25 +12,28 @@
"tokens": "node scripts/tokens-to-css.js" "tokens": "node scripts/tokens-to-css.js"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^4.0.0",
"@sveltejs/kit": "^3.0.0", "@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"autoprefixer": "^10.5.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"postcss": "^8.5.15", "svelte": "^5.57.1",
"svelte": "^5.1.9", "svelte-check": "^4.7.5",
"svelte-check": "^4.4.8", "tailwindcss": "^4.3.3",
"tailwindcss": "^3.4.14", "typescript": "^6.0.0",
"typescript": "^5.6.3", "vite": "^8.0.12",
"vite": "^8.0.0",
"vitest": "^5.0.0" "vitest": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"@neodrag/svelte": "^2.3.3", "@neodrag/svelte": "^2.3.3",
"@tanstack/svelte-query": "^5.90.2", "@tanstack/svelte-query": "^5.90.2",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
},
"imports": {
"#lib/*": "./src/lib/*",
"#test-utils/*": "./src/test-utils/*"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+71 -4
View File
@@ -1,8 +1,75 @@
@import './lib/styles/tokens.generated.css'; @import './lib/styles/tokens.generated.css' layer(base);
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; /* The FabledSword tokens as Tailwind's theme. `inline` makes each utility emit
* the var(--fs-*) itself, so a theme swap that redefines the tokens reaches the
* utilities without going through a second variable. */
@theme inline {
--color-background: var(--fs-obsidian);
--color-surface: var(--fs-iron);
--color-surface-hover: var(--fs-slate);
--color-border: var(--fs-pewter);
--color-text-primary: var(--fs-parchment);
--color-text-secondary: var(--fs-vellum);
--color-text-muted: var(--fs-ash);
--color-action-primary: var(--fs-moss);
--color-action-secondary: var(--fs-bronze);
--color-action-destructive: var(--fs-oxblood);
--color-action-fg: var(--fs-on-action);
--color-accent: var(--fs-accent);
--color-accent-tint: color-mix(in srgb, var(--fs-accent) 12%, transparent);
--color-accent-fg: var(--fs-accent-fg);
--color-warning: var(--fs-warning);
--color-warning-fg: var(--fs-warning-fg);
--color-error: var(--fs-error);
--color-error-fg: var(--fs-error-fg);
--color-info: var(--fs-info);
--color-info-fg: var(--fs-info-fg);
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-md);
--radius-lg: var(--fs-radius-lg);
--radius-xl: var(--fs-radius-xl);
--font-display: var(--fs-font-display);
--font-sans: var(--fs-font-body);
--font-mono: var(--fs-font-mono);
}
/* Tailwind 3's preflight defaults that 4 changed (#5021). Kept so the upgrade
* changes nothing on screen; each can go once the elements relying on it name
* the value themselves.
* - A border with no colour utility was gray-200; 4 makes it currentcolor.
* - Placeholder text was gray-400; 4 makes it the text colour at 50%.
* - Buttons had a pointer cursor; 4 leaves the browser default. */
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
input::placeholder,
textarea::placeholder {
color: var(--color-gray-400);
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}
:root { :root {
color-scheme: dark; color-scheme: dark;
+29 -4
View File
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { dismissDuplicateGroup, listDuplicates, mergeDuplicateGroup, runDuplicateSweep } from './admin'; import {
dismissDuplicateGroup,
duplicatesNextOffset,
listDuplicates,
listResolvedDuplicates,
mergeDuplicateGroup,
runDuplicateSweep
} from './admin';
import type { AdminDuplicatesResponse } from './types';
vi.mock('./client', () => ({ vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn() } api: { get: vi.fn(), post: vi.fn() }
@@ -10,10 +18,27 @@ import { api } from './client';
describe('admin duplicates API', () => { describe('admin duplicates API', () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it('listDuplicates GETs the paged report', async () => { it('listDuplicates GETs one tab of the paged report', async () => {
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ groups: [] }); (api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ groups: [] });
await listDuplicates(25, 25); await listDuplicates('cross_release', 25);
expect(api.get).toHaveBeenCalledWith('/api/admin/library/duplicates?limit=25&offset=25'); expect(api.get).toHaveBeenCalledWith(
'/api/admin/library/duplicates?view=cross_release&limit=25&offset=25'
);
});
it('listResolvedDuplicates GETs what the resolver did', async () => {
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ items: [] });
await listResolvedDuplicates(50);
expect(api.get).toHaveBeenCalledWith('/api/admin/library/duplicates/resolved?limit=25&offset=50');
});
// The next offset counts groups, and stops at the total or on an empty page.
it('duplicatesNextOffset pages by groups', () => {
const page = (offset: number, n: number, total: number) =>
({ offset, total, groups: Array.from({ length: n }, () => ({})) }) as unknown as AdminDuplicatesResponse;
expect(duplicatesNextOffset(page(0, 25, 60))).toBe(25);
expect(duplicatesNextOffset(page(50, 10, 60))).toBeUndefined();
expect(duplicatesNextOffset(page(25, 0, 60))).toBeUndefined();
}); });
it('runDuplicateSweep POSTs the trigger', async () => { it('runDuplicateSweep POSTs the trigger', async () => {
+2 -1
View File
@@ -49,7 +49,8 @@ describe('admin fingerprint coverage API', () => {
chromaprint_length_sec: 120, chromaprint_length_sec: 120,
acoustic_max_bit_error_rate: 0.15, acoustic_max_bit_error_rate: 0.15,
backfill_concurrency: 2, backfill_concurrency: 2,
sweep_interval_hours: 1 sweep_interval_hours: 1,
auto_resolve: true
}; };
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings); (api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
(api.put as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings); (api.put as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'vitest';
import { suspectSourcesNextOffset } from './admin';
import type { AdminSuspectResponse, AdminSuspectTrack } from './types';
function track(id: string): AdminSuspectTrack {
return {
track_id: id,
title: id,
artist_id: 'ar',
artist_name: 'Artist',
album_id: 'al',
album_title: 'Album',
file_path: `/music/${id}.mp3`,
duration_sec: 180,
disc_number: null,
track_number: null,
markers: [],
verdict: null,
verdict_at: null
};
}
function page(offset: number, total: number): AdminSuspectResponse {
return {
total,
limit: 50,
offset,
groups: [
{ directory: 'a', tracks: [track('1'), track('2')] },
{ directory: 'b', tracks: [track('3')] }
]
};
}
// limit and total are in tracks, so the next offset must count tracks across
// groups, not the number of groups.
describe('suspectSourcesNextOffset', () => {
test('counts tracks, not groups', () => {
expect(suspectSourcesNextOffset(page(0, 10))).toBe(3);
});
test('stops at the total', () => {
expect(suspectSourcesNextOffset(page(7, 10))).toBeUndefined();
});
});
+83 -11
View File
@@ -1,11 +1,15 @@
import { createQuery } from '@tanstack/svelte-query'; import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
import { api } from './client'; import { api } from './client';
import { qk } from './queries'; import { qk } from './queries';
import type { import type {
ActionResult, ActionResult,
AdminMissingResponse, AdminMissingResponse,
AdminSuspectResponse,
SuspectVerdict,
AdminDuplicatesResponse, AdminDuplicatesResponse,
DuplicatesView,
MergeDuplicateResult, MergeDuplicateResult,
ResolvedDuplicatesResponse,
AdminPlaybackError, AdminPlaybackError,
AdminQuarantineRow, AdminQuarantineRow,
LidarrConfig, LidarrConfig,
@@ -352,6 +356,9 @@ export type FingerprintSettings = {
acoustic_max_bit_error_rate: number; acoustic_max_bit_error_rate: number;
backfill_concurrency: number; backfill_concurrency: number;
sweep_interval_hours: number; sweep_interval_hours: number;
// Lets the duplicate resolver act on its own (M498): merge copies Lidarr
// doesn't need and change a Lidarr release that lists songs twice.
auto_resolve: boolean;
}; };
export async function getFingerprintSettings(): Promise<FingerprintSettings> { export async function getFingerprintSettings(): Promise<FingerprintSettings> {
@@ -828,24 +835,55 @@ export async function updatePublicUrl(publicUrl: string): Promise<NetworkSetting
// Duplicates report (#3912) ------------------------------------------------- // Duplicates report (#3912) -------------------------------------------------
export const DUPLICATES_PAGE_SIZE = 25;
export async function listDuplicates( export async function listDuplicates(
view: DuplicatesView = 'review',
offset: number = 0, offset: number = 0,
limit: number = 25 limit: number = DUPLICATES_PAGE_SIZE
): Promise<AdminDuplicatesResponse> { ): Promise<AdminDuplicatesResponse> {
return api.get<AdminDuplicatesResponse>( return api.get<AdminDuplicatesResponse>(
`/api/admin/library/duplicates?limit=${limit}&offset=${offset}` `/api/admin/library/duplicates?view=${view}&limit=${limit}&offset=${offset}`
); );
} }
// Takes a plain offset, like createMissingFilesQuery, so a $derived caller export function duplicatesNextOffset(last: AdminDuplicatesResponse): number | undefined {
// re-creates the query on paging. Polls while a sweep might be running: the const loaded = last.offset + last.groups.length;
// page is where the operator waits for one to finish. return last.groups.length === 0 || loaded >= last.total ? undefined : loaded;
export function createDuplicatesQuery(offset: number = 0, limit: number = 25) { }
return createQuery({
queryKey: qk.adminDuplicates(offset), // Loads as the page scrolls (rule 172), one tab at a time. Polls so a sweep
queryFn: () => listDuplicates(offset, limit), // or resolver pass finishing shows up while the operator is on the page.
export function createDuplicatesQuery(view: DuplicatesView = 'review') {
return createInfiniteQuery({
queryKey: qk.adminDuplicates(view),
queryFn: ({ pageParam }) => listDuplicates(view, pageParam as number),
initialPageParam: 0,
getNextPageParam: duplicatesNextOffset,
staleTime: 30_000, staleTime: 30_000,
refetchInterval: 15_000 refetchInterval: 30_000
});
}
export async function listResolvedDuplicates(offset: number = 0): Promise<ResolvedDuplicatesResponse> {
return api.get<ResolvedDuplicatesResponse>(
`/api/admin/library/duplicates/resolved?limit=${DUPLICATES_PAGE_SIZE}&offset=${offset}`
);
}
export function resolvedDuplicatesNextOffset(last: ResolvedDuplicatesResponse): number | undefined {
const loaded = last.offset + last.items.length;
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
}
// What the resolver did by itself (M498), newest first.
export function createResolvedDuplicatesQuery() {
return createInfiniteQuery({
queryKey: qk.adminResolvedDuplicates(),
queryFn: ({ pageParam }) => listResolvedDuplicates(pageParam as number),
initialPageParam: 0,
getNextPageParam: resolvedDuplicatesNextOffset,
staleTime: 60_000
}); });
} }
@@ -896,6 +934,40 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
}); });
} }
// Suspect sources (#5410) --------------------------------------------------
export const SUSPECT_SOURCES_PAGE_SIZE = 50;
export async function listSuspectSources(offset: number = 0): Promise<AdminSuspectResponse> {
return api.get<AdminSuspectResponse>(
`/api/admin/library/suspect-sources?limit=${SUSPECT_SOURCES_PAGE_SIZE}&offset=${offset}`
);
}
// The next offset counts tracks, not groups: limit and total are in tracks.
export function suspectSourcesNextOffset(last: AdminSuspectResponse): number | undefined {
const loaded = last.offset + last.groups.reduce((n, g) => n + g.tracks.length, 0);
return loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172). Changes only when files are added or
// removed, so it is not re-fetched on every focus.
export function createSuspectSourcesQuery() {
return createInfiniteQuery({
queryKey: qk.adminSuspectSources(),
queryFn: ({ pageParam }) => listSuspectSources(pageParam as number),
initialPageParam: 0,
getNextPageParam: suspectSourcesNextOffset,
staleTime: 120_000
});
}
// 'fine' lets a flagged track back into radio and the mixes; 'suspect' holds
// it back again (M498 #5439).
export async function setSuspectVerdict(trackId: string, verdict: SuspectVerdict): Promise<void> {
await api.put(`/api/admin/library/suspect-sources/${encodeURIComponent(trackId)}`, { verdict });
}
// Missing-file re-acquisition (#2527 / milestone #290) ---------------------- // Missing-file re-acquisition (#2527 / milestone #290) ----------------------
export type ReacquisitionSettings = { export type ReacquisitionSettings = {
+2 -2
View File
@@ -83,7 +83,7 @@ describe('api.get/post/del', () => {
describe('apiFetch 401 interceptor', () => { describe('apiFetch 401 interceptor', () => {
test('401 response triggers auth.logout({silent:true}) and still throws', async () => { test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
const logoutSpy = vi.fn(); const logoutSpy = vi.fn();
vi.doMock('$lib/auth/store.svelte', () => ({ vi.doMock('#lib/auth/store.svelte.js', () => ({
logout: logoutSpy, logout: logoutSpy,
login: vi.fn(), login: vi.fn(),
bootstrap: vi.fn(), bootstrap: vi.fn(),
@@ -97,6 +97,6 @@ describe('apiFetch 401 interceptor', () => {
status: 401 status: 401
}); });
expect(logoutSpy).toHaveBeenCalledWith({ silent: true }); expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
vi.doUnmock('$lib/auth/store.svelte'); vi.doUnmock('#lib/auth/store.svelte.js');
}); });
}); });
+1 -1
View File
@@ -44,7 +44,7 @@ export async function apiFetch(path: string, init?: RequestInit): Promise<unknow
// Lazy import: auth/store imports from this file, so a top-level // Lazy import: auth/store imports from this file, so a top-level
// import would be circular. By the time any 401 actually happens // import would be circular. By the time any 401 actually happens
// at runtime, both modules have finished loading. // at runtime, both modules have finished loading.
const { logout } = await import('$lib/auth/store.svelte'); const { logout } = await import('#lib/auth/store.svelte.js');
await logout({ silent: true }); await logout({ silent: true });
} }
// Two error envelope shapes ship in this codebase today: // Two error envelope shapes ship in this codebase today:
+1
View File
@@ -122,5 +122,6 @@ export const NOTIFICATION_KIND_LABELS: Record<string, string> = {
scan_failed: 'Library scan failed', scan_failed: 'Library scan failed',
tracks_missing: 'Tracks gone missing', tracks_missing: 'Tracks gone missing',
duplicates_found: 'Duplicates to review', duplicates_found: 'Duplicates to review',
duplicates_resolved: 'Duplicates resolved',
playback_errors: 'Playback errors' playback_errors: 'Playback errors'
}; };
+4 -2
View File
@@ -56,8 +56,10 @@ export const qk = {
['adminDiagnostics', f] as const, ['adminDiagnostics', f] as const,
adminMissingFiles: (offset?: number) => adminMissingFiles: (offset?: number) =>
['adminMissingFiles', { offset: offset ?? 0 }] as const, ['adminMissingFiles', { offset: offset ?? 0 }] as const,
adminDuplicates: (offset?: number) => adminDuplicates: (view: string = 'review') =>
['adminDuplicates', { offset: offset ?? 0 }] as const, ['adminDuplicates', { view }] as const,
adminResolvedDuplicates: () => ['adminResolvedDuplicates'] as const,
adminSuspectSources: () => ['adminSuspectSources'] as const,
adminDiagnosticDevices: (userId?: string) => adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const, ['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const, smtpConfig: () => ['smtpConfig'] as const,
+85
View File
@@ -41,6 +41,10 @@ export type TrackRef = {
disc_number?: number; disc_number?: number;
duration_sec: number; duration_sec: number;
stream_url: string; stream_url: string;
// #5418: the file is gone from the library (missing_since is set). Sent on
// direct lookups such as album detail and search; lists that pick tracks
// for playback leave missing files out instead.
unavailable?: boolean;
}; };
export type ArtistDetail = ArtistRef & { export type ArtistDetail = ArtistRef & {
@@ -419,6 +423,43 @@ export type AdminMissingResponse = {
groups: AdminMissingGroup[]; groups: AdminMissingGroup[];
}; };
// Suspect sources (#5410) --------------------------------------------------
// A present track whose filename carries a video-rip marker. markers are the
// reasons, read from the file's basename: "music video", "[Audio]", "MV"...
export type AdminSuspectTrack = {
track_id: string;
title: string;
artist_id: string;
artist_name: string;
album_id: string;
album_title: string;
file_path: string;
duration_sec: number;
disc_number: number | null;
track_number: number | null;
markers: string[];
// 'suspect': held back from radio and the mixes. 'fine': the operator let it
// back. null: the resolver hasn't looked yet (M498 #5439).
verdict: SuspectVerdict | null;
verdict_at: string | null;
};
export type SuspectVerdict = 'suspect' | 'fine';
// A folder's worth of flagged tracks; the server orders and groups by folder.
export type AdminSuspectGroup = {
directory: string;
tracks: AdminSuspectTrack[];
};
export type AdminSuspectResponse = {
total: number;
limit: number;
offset: number;
groups: AdminSuspectGroup[];
};
// Duplicates report (#3912) ------------------------------------------------- // Duplicates report (#3912) -------------------------------------------------
// One copy in a proposed duplicate group. like_count and play_count cover every // One copy in a proposed duplicate group. like_count and play_count cover every
@@ -436,8 +477,17 @@ export type AdminDuplicateMember = {
added_at: string; added_at: string;
like_count: number; like_count: number;
play_count: number; play_count: number;
// Lidarr's view of the file as of the resolver's last pass (M498): tracked
// means Lidarr maps it to a track, so removing it would make Lidarr download
// it again. null when Lidarr wasn't asked.
lidarr_state: 'tracked' | 'unmapped' | null;
disc_number: number | null;
track_number: number | null;
}; };
// The resolver's verdict on a group (M498). null until its first pass.
export type DuplicateClass = 'same_release' | 'cross_release' | 'mismatch' | 'review';
// exact: identical encoded audio. acoustic: the same recording, differently // exact: identical encoded audio. acoustic: the same recording, differently
// encoded; worst_bit_error_rate is the weakest link between any two members. // encoded; worst_bit_error_rate is the weakest link between any two members.
export type AdminDuplicateGroup = { export type AdminDuplicateGroup = {
@@ -447,6 +497,9 @@ export type AdminDuplicateGroup = {
detected_at: string; detected_at: string;
survivor_track_id: string; survivor_track_id: string;
survivor_reason: string; survivor_reason: string;
class: DuplicateClass | null;
// Why the resolver left the group for the operator, when it had something to say.
resolve_note: string | null;
members: AdminDuplicateMember[]; members: AdminDuplicateMember[];
}; };
@@ -478,8 +531,40 @@ export type AdminDuplicatesResponse = {
pending: number; pending: number;
enabled: boolean; enabled: boolean;
}; };
view: DuplicatesView;
// Each tab's size, for its pill.
counts: { review: number; cross_release: number; resolved: number };
total: number; total: number;
limit: number; limit: number;
offset: number; offset: number;
groups: AdminDuplicateGroup[]; groups: AdminDuplicateGroup[];
}; };
// The report's group tabs. 'resolved' is the third tab, served separately.
export type DuplicatesView = 'review' | 'cross_release';
// One thing the resolver did on its own (M498), from the audit log.
export type ResolvedDuplicateActivity =
| {
id: string;
action: 'duplicate_merge';
created_at: string;
details: {
survivor_path?: string;
survivor_reason?: string;
removed?: { track_id: string; file_path: string }[];
};
}
| {
id: string;
action: 'lidarr_release_change';
created_at: string;
details: { album_title?: string; artist_name?: string; from_release?: string; to_release?: string };
};
export type ResolvedDuplicatesResponse = {
total: number;
limit: number;
offset: number;
items: ResolvedDuplicateActivity[];
};
+4 -4
View File
@@ -1,10 +1,10 @@
import { api, type User, type LoginResponse } from '$lib/api/client'; import { api, type User, type LoginResponse } from '#lib/api/client.js';
import { putMyTimezone } from '$lib/api/me'; import { putMyTimezone } from '#lib/api/me.js';
import { queryClient } from '$lib/query/client'; import { queryClient } from '#lib/query/client.js';
import { signalSessionEnd } from './sessionEnd.svelte'; import { signalSessionEnd } from './sessionEnd.svelte';
import { user, setUser } from './user.svelte'; import { user, setUser } from './user.svelte';
// Re-export so existing `import { user } from '$lib/auth/store.svelte'` // Re-export so existing `import { user } from '#lib/auth/store.svelte'`
// callers keep working. New code can import directly from auth/user.svelte. // callers keep working. New code can import directly from auth/user.svelte.
export { user }; export { user };
+4 -4
View File
@@ -1,14 +1,14 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { apiClientMock } from '../../test-utils/mocks/client'; import { apiClientMock } from '../../test-utils/mocks/client';
vi.mock('$lib/api/client', () => apiClientMock()); vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('$lib/query/client', () => ({ vi.mock('#lib/query/client.js', () => ({
queryClient: { clear: vi.fn() } queryClient: { clear: vi.fn() }
})); }));
import { api } from '$lib/api/client'; import { api } from '#lib/api/client.js';
import { queryClient } from '$lib/query/client'; import { queryClient } from '#lib/query/client.js';
import { bootstrap, login, logout, user } from './store.svelte'; import { bootstrap, login, logout, user } from './store.svelte';
beforeEach(() => { beforeEach(() => {
+1 -1
View File
@@ -4,7 +4,7 @@
// component that needs the current user can import `user` from either // component that needs the current user can import `user` from either
// this module or auth/store — auth/store re-exports it for backward // this module or auth/store — auth/store re-exports it for backward
// compatibility. // compatibility.
import type { User } from '$lib/api/client'; import type { User } from '#lib/api/client.js';
let _user = $state<User | null>(null); let _user = $state<User | null>(null);
+6 -6
View File
@@ -7,9 +7,9 @@
updateAcoustIDSettings, updateAcoustIDSettings,
type AcoustIDOverview, type AcoustIDOverview,
type AcoustIDUnsettled type AcoustIDUnsettled
} from '$lib/api/admin'; } from '#lib/api/admin.js';
import { errMessage } from '$lib/api/errors'; import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '$lib/stores/toast.svelte'; import { pushToast } from '#lib/stores/toast.svelte.js';
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that // AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
// untagged tracks lack, so the similarity arms can see them. Off until the // untagged tracks lack, so the similarity arms can see them. Off until the
@@ -125,7 +125,7 @@
const rowClass = 'flex items-center justify-between gap-4 py-2'; const rowClass = 'flex items-center justify-between gap-4 py-2';
const inputClass = const inputClass =
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' + 'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
'placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent'; 'placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent';
</script> </script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card"> <section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
@@ -238,7 +238,7 @@
<button <button
type="button" type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90 class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50" disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !scoreOk} disabled={!dirty || saving || !scoreOk}
onclick={save} onclick={save}
@@ -293,7 +293,7 @@
<button <button
type="button" type="button"
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50" disabled:cursor-not-allowed disabled:opacity-50"
disabled={!overview.ready || overview.status.running || starting} disabled={!overview.ready || overview.status.running || starting}
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'} title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
+4 -4
View File
@@ -1,15 +1,15 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import type { AcoustIDOverview } from "$lib/api/admin"; import type { AcoustIDOverview } from "#lib/api/admin.js";
vi.mock("$lib/api/admin", () => ({ vi.mock("#lib/api/admin.js", () => ({
getAcoustID: vi.fn(), getAcoustID: vi.fn(),
updateAcoustIDSettings: vi.fn(), updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(), runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn(), listUnsettledAcoustID: vi.fn(),
})); }));
vi.mock("$lib/stores/toast.svelte", () => ({ pushToast: vi.fn() })); vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
import AcoustIDCard from "./AcoustIDCard.svelte"; import AcoustIDCard from "./AcoustIDCard.svelte";
import { import {
@@ -17,7 +17,7 @@ import {
listUnsettledAcoustID, listUnsettledAcoustID,
runAcoustIDLookup, runAcoustIDLookup,
updateAcoustIDSettings, updateAcoustIDSettings,
} from "$lib/api/admin"; } from "#lib/api/admin.js";
const base: AcoustIDOverview = { const base: AcoustIDOverview = {
settings: { enabled: true, min_score: 0.85, api_key_set: true }, settings: { enabled: true, min_score: 0.85, api_key_set: true },
+5 -5
View File
@@ -6,9 +6,9 @@
revokeSession, revokeSession,
revokeOtherSessions, revokeOtherSessions,
type ActiveSession type ActiveSession
} from '$lib/api/me'; } from '#lib/api/me.js';
import { errCode } from '$lib/api/errors'; import { errCode } from '#lib/api/errors.js';
import { pushToast } from '$lib/stores/toast.svelte'; import { pushToast } from '#lib/stores/toast.svelte.js';
// Self-contained: nothing else in the app reads this data, so it holds its // Self-contained: nothing else in the app reads this data, so it holds its
// own state and reloads explicitly rather than joining the query cache. // own state and reloads explicitly rather than joining the query cache.
@@ -143,7 +143,7 @@
<li class="flex items-start gap-3 py-3"> <li class="flex items-start gap-3 py-3">
<MonitorSmartphone <MonitorSmartphone
size={18} size={18}
class="mt-0.5 flex-shrink-0 text-text-secondary" class="mt-0.5 shrink-0 text-text-secondary"
aria-hidden="true" aria-hidden="true"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -173,7 +173,7 @@
{#if !s.current} {#if !s.current}
<button <button
type="button" type="button"
class="flex-shrink-0 rounded border border-border px-2 py-1 text-sm class="shrink-0 rounded border border-border px-2 py-1 text-sm
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50" disabled:opacity-50"
disabled={busy} disabled={busy}
@@ -6,13 +6,13 @@ const listSessions = vi.fn();
const revokeSession = vi.fn(); const revokeSession = vi.fn();
const revokeOtherSessions = vi.fn(); const revokeOtherSessions = vi.fn();
vi.mock('$lib/api/me', () => ({ vi.mock('#lib/api/me.js', () => ({
listSessions: (...a: unknown[]) => listSessions(...a), listSessions: (...a: unknown[]) => listSessions(...a),
revokeSession: (...a: unknown[]) => revokeSession(...a), revokeSession: (...a: unknown[]) => revokeSession(...a),
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a) revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a)
})); }));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() })); vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
type Row = { type Row = {
id: string; id: string;
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Plus } from 'lucide-svelte'; import { Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists'; import { createPlaylistsQuery, appendTracks, createPlaylist } from '#lib/api/playlists.js';
import { qk } from '$lib/api/queries'; import { qk } from '#lib/api/queries.js';
import { errMessage } from '$lib/api/errors'; import { errMessage } from '#lib/api/errors.js';
import TrackMenuItem from './TrackMenuItem.svelte'; import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte'; import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '#lib/api/types.js';
let { let {
tracks, tracks,
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { readable } from 'svelte/store'; import { readable } from 'svelte/store';
import { makeTrack } from '$test-utils/fixtures/track'; import { makeTrack } from '#test-utils/fixtures/track.js';
const playlistsData = vi.hoisted(() => ({ const playlistsData = vi.hoisted(() => ({
owned: [ owned: [
@@ -43,11 +43,11 @@ const playlistsData = vi.hoisted(() => ({
public: [] as unknown[] public: [] as unknown[]
})); }));
vi.mock('$lib/auth/store.svelte', () => ({ vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } } user: { value: { id: 'u-self', username: 'me', is_admin: false } }
})); }));
vi.mock('$lib/api/playlists', () => ({ vi.mock('#lib/api/playlists.js', () => ({
createPlaylistsQuery: () => createPlaylistsQuery: () =>
readable({ data: playlistsData, isPending: false, isError: false }), readable({ data: playlistsData, isPending: false, isError: false }),
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
@@ -86,7 +86,7 @@ describe('AddToPlaylistMenu', () => {
test('clicking a playlist appends and closes', async () => { test('clicking a playlist appends and closes', async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const { appendTracks } = await import('$lib/api/playlists'); const { appendTracks } = await import('#lib/api/playlists.js');
render(AddToPlaylistMenu, { props: { tracks: [track], onClose } }); render(AddToPlaylistMenu, { props: { tracks: [track], onClose } });
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ })); await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
await waitFor(() => expect(onClose).toHaveBeenCalled()); await waitFor(() => expect(onClose).toHaveBeenCalled());
+1
View File
@@ -10,6 +10,7 @@
{ href: '/admin/quarantine', label: 'Quarantine' }, { href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/missing-files', label: 'Missing files' }, { href: '/admin/missing-files', label: 'Missing files' },
{ href: '/admin/duplicates', label: 'Duplicates' }, { href: '/admin/duplicates', label: 'Duplicates' },
{ href: '/admin/suspect-sources', label: 'Suspect sources' },
{ href: '/admin/playback-errors', label: 'Playback errors' }, { href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' }, { href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' }, { href: '/admin/tuning', label: 'Tuning' },
+5 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState'; import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/admin') pageUrl: new URL('http://localhost/admin')
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
); );
}); });
test('renders all ten tabs in order', () => { test('renders all eleven tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin'); state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs); render(AdminTabs);
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');
@@ -67,6 +67,9 @@ describe('AdminTabs', () => {
// Duplicates follows Missing files: both are library-health reports on // Duplicates follows Missing files: both are library-health reports on
// what the library holds, rather than a queue of user reports. // what the library holds, rather than a queue of user reports.
'Duplicates', 'Duplicates',
// Suspect sources is the third library-health report: files whose
// names say they were ripped from a video.
'Suspect sources',
'Playback errors', 'Playback errors',
'Diagnostics', 'Diagnostics',
'Tuning', 'Tuning',
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { AlbumRef, AlbumDetail } from '$lib/api/types'; import type { AlbumRef, AlbumDetail } from '#lib/api/types.js';
import { api } from '$lib/api/client'; import { api } from '#lib/api/client.js';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte'; import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
import { Play } from 'lucide-svelte'; import { Play } from 'lucide-svelte';
import AlbumMenu from './AlbumMenu.svelte'; import AlbumMenu from './AlbumMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte'; import CardActionCluster from './CardActionCluster.svelte';
@@ -31,7 +31,7 @@
> >
<div <div
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
shadow-sm transition-all duration-150 shadow-xs transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40" group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
> >
<Cover <Cover
+10 -10
View File
@@ -1,27 +1,27 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte'; import { render, screen, fireEvent } from '@testing-library/svelte';
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types'; import type { AlbumRef, AlbumDetail, TrackRef } from '#lib/api/types.js';
import { FALLBACK_COVER } from '$lib/media/covers'; import { FALLBACK_COVER } from '#lib/media/covers.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { apiClientMock } from '../../test-utils/mocks/client'; import { apiClientMock } from '../../test-utils/mocks/client';
import { makeTrack } from '$test-utils/fixtures/track'; import { makeTrack } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/client', () => apiClientMock()); vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('$lib/player/store.svelte', () => ({ vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn(), enqueueTracks: vi.fn(),
playQueue: vi.fn() playQueue: vi.fn()
})); }));
vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/albums', async (orig) => { vi.mock('#lib/api/albums.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>; const actual = (await orig()) as Record<string, unknown>;
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) }; return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
}); });
vi.mock('$lib/api/playlists', async (orig) => { vi.mock('#lib/api/playlists.js', async (orig) => {
const { readable } = await import('svelte/store'); const { readable } = await import('svelte/store');
const actual = (await orig()) as Record<string, unknown>; const actual = (await orig()) as Record<string, unknown>;
return { return {
@@ -34,8 +34,8 @@ vi.mock('$lib/api/playlists', async (orig) => {
}); });
import AlbumCard from './AlbumCard.svelte'; import AlbumCard from './AlbumCard.svelte';
import { api } from '$lib/api/client'; import { api } from '#lib/api/client.js';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte'; import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
const album: AlbumRef = { const album: AlbumRef = {
id: 'xyz', id: 'xyz',
+4 -4
View File
@@ -2,13 +2,13 @@
import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte'; import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes'; import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { listAlbumTracks } from '$lib/api/albums'; import { listAlbumTracks } from '#lib/api/albums.js';
import { enqueueTracks } from '$lib/player/store.svelte'; import { enqueueTracks } from '#lib/player/store.svelte.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte'; import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte'; import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '#lib/api/types.js';
let { let {
albumId, albumId,
+9 -9
View File
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '#lib/api/types.js';
import { makeTracks } from '$test-utils/fixtures/track'; import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/albums', async (orig) => { vi.mock('#lib/api/albums.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>; const actual = (await orig()) as Record<string, unknown>;
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) }; return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
}); });
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({ vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn() enqueueTracks: vi.fn()
})); }));
import AlbumMenu from './AlbumMenu.svelte'; import AlbumMenu from './AlbumMenu.svelte';
import { enqueueTracks } from '$lib/player/store.svelte'; import { enqueueTracks } from '#lib/player/store.svelte.js';
import { listAlbumTracks } from '$lib/api/albums'; import { listAlbumTracks } from '#lib/api/albums.js';
import { likeEntity } from '$lib/api/likes'; import { likeEntity } from '#lib/api/likes.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
const TRACKS: TrackRef[] = makeTracks(2, { const TRACKS: TrackRef[] = makeTracks(2, {
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { ApiError } from '$lib/api/client'; import type { ApiError } from '#lib/api/client.js';
let { let {
error, error,
+6 -14
View File
@@ -1,32 +1,24 @@
<script lang="ts"> <script lang="ts">
import type { ArtistRef, TrackRef } from '$lib/api/types'; import { shuffled } from '#lib/utils/shuffle.js';
import { api } from '$lib/api/client'; import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { playQueue, enqueueTracks } from '$lib/player/store.svelte'; import { api } from '#lib/api/client.js';
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
import { Play } from 'lucide-svelte'; import { Play } from 'lucide-svelte';
import ArtistMenu from './ArtistMenu.svelte'; import ArtistMenu from './ArtistMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte'; import CardActionCluster from './CardActionCluster.svelte';
import Cover from './Cover.svelte'; import Cover from './Cover.svelte';
import { listArtistTracks } from '$lib/api/artists'; import { listArtistTracks } from '#lib/api/artists.js';
let { artist }: { artist: ArtistRef } = $props(); let { artist }: { artist: ArtistRef } = $props();
let queueing = $state(false); let queueing = $state(false);
function shuffle<T>(items: T[]): T[] {
const arr = items.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
async function onPlayClick(e: MouseEvent) { async function onPlayClick(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`); const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
if (tracks.length === 0) return; if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0); playQueue(shuffled(tracks), 0);
} }
async function onAddToQueue(e: MouseEvent) { async function onAddToQueue(e: MouseEvent) {
+10 -10
View File
@@ -2,21 +2,21 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte'; import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { apiClientMock } from '../../test-utils/mocks/client'; import { apiClientMock } from '../../test-utils/mocks/client';
import type { ArtistRef, TrackRef } from '$lib/api/types'; import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { makeTrack, makeTracks } from '$test-utils/fixtures/track'; import { makeTrack, makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/client', () => apiClientMock()); vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('$lib/player/store.svelte', () => ({ vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(), playQueue: vi.fn(),
enqueueTracks: vi.fn() enqueueTracks: vi.fn()
})); }));
vi.mock('$lib/api/artists', () => ({ vi.mock('#lib/api/artists.js', () => ({
listArtistTracks: vi.fn().mockResolvedValue([]) listArtistTracks: vi.fn().mockResolvedValue([])
})); }));
vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('#lib/api/likes.js', () => emptyLikesMock());
import ArtistCard from './ArtistCard.svelte'; import ArtistCard from './ArtistCard.svelte';
import { api } from '$lib/api/client'; import { api } from '#lib/api/client.js';
import { playQueue } from '$lib/player/store.svelte'; import { playQueue } from '#lib/player/store.svelte.js';
const artist: ArtistRef = { const artist: ArtistRef = {
id: 'art-1', id: 'art-1',
@@ -82,7 +82,7 @@ describe('ArtistCard', () => {
const fakeTracks = [ const fakeTracks = [
makeTrack({ artist_id: 'art-1', artist_name: 'BoC' }) makeTrack({ artist_id: 'art-1', artist_name: 'BoC' })
]; ];
const { listArtistTracks } = await import('$lib/api/artists'); const { listArtistTracks } = await import('#lib/api/artists.js');
(listArtistTracks as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakeTracks); (listArtistTracks as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakeTracks);
render(ArtistCard, { props: { artist } }); render(ArtistCard, { props: { artist } });
const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i }); const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i });
@@ -90,7 +90,7 @@ describe('ArtistCard', () => {
// Allow promise microtask to flush. // Allow promise microtask to flush.
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(listArtistTracks).toHaveBeenCalledWith('art-1'); expect(listArtistTracks).toHaveBeenCalledWith('art-1');
const { enqueueTracks } = await import('$lib/player/store.svelte'); const { enqueueTracks } = await import('#lib/player/store.svelte.js');
expect(enqueueTracks).toHaveBeenCalledWith(fakeTracks); expect(enqueueTracks).toHaveBeenCalledWith(fakeTracks);
}); });
}); });
+4 -4
View File
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte'; import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes'; import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { listArtistTracks } from '$lib/api/artists'; import { listArtistTracks } from '#lib/api/artists.js';
import { enqueueTracks } from '$lib/player/store.svelte'; import { enqueueTracks } from '#lib/player/store.svelte.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte'; import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte'; import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '#lib/api/types.js';
let { let {
artistId, artistId,
+9 -9
View File
@@ -2,27 +2,27 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '#lib/api/types.js';
import { makeTracks } from '$test-utils/fixtures/track'; import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/artists', () => ({ vi.mock('#lib/api/artists.js', () => ({
listArtistTracks: vi.fn().mockResolvedValue([]) listArtistTracks: vi.fn().mockResolvedValue([])
})); }));
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({ vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn() enqueueTracks: vi.fn()
})); }));
import ArtistMenu from './ArtistMenu.svelte'; import ArtistMenu from './ArtistMenu.svelte';
import { enqueueTracks } from '$lib/player/store.svelte'; import { enqueueTracks } from '#lib/player/store.svelte.js';
import { listArtistTracks } from '$lib/api/artists'; import { listArtistTracks } from '#lib/api/artists.js';
import { likeEntity, unlikeEntity } from '$lib/api/likes'; import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
const TRACKS: TrackRef[] = makeTracks(2, { const TRACKS: TrackRef[] = makeTracks(2, {
album_id: 'al1', artist_id: 'a1', artist_name: 'Test' album_id: 'al1', artist_id: 'a1', artist_name: 'Test'
@@ -0,0 +1,60 @@
<script lang="ts">
// The header of every page that is one collection of tracks, album and
// playlist (milestone 494): cover, title, a line or two of facts, then
// Play and Shuffle (PlayActions).
import type { Snippet } from 'svelte';
import PlayActions from './PlayActions.svelte';
import Cover from './Cover.svelte';
let {
title,
coverUrl,
coverAlt = '',
playDisabled = false,
onPlay,
onShuffle,
titleExtra,
tools,
children,
actions
}: {
title: string;
coverUrl: string | null | undefined;
coverAlt?: string;
playDisabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Beside the title, e.g. the album's like button. */
titleExtra?: Snippet;
/** Top right: refresh, link, edit, delete. */
tools?: Snippet;
/** The facts under the title. */
children?: Snippet;
/** After Play and Shuffle, e.g. an admin's Refetch cover. */
actions?: Snippet;
} = $props();
</script>
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md md:h-40 md:w-40">
<Cover src={coverUrl ?? undefined} alt={coverAlt} />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start gap-3">
<h1 class="min-w-0 truncate font-display text-2xl font-medium text-text-primary">{title}</h1>
{#if titleExtra}{@render titleExtra()}{/if}
{#if tools}
<div class="ml-auto flex shrink-0 items-center gap-1">{@render tools()}</div>
{/if}
</div>
{#if children}{@render children()}{/if}
<div class="pt-1">
<PlayActions name={title} disabled={playDisabled} {onPlay} {onShuffle}>
{#if actions}{@render actions()}{/if}
</PlayActions>
</div>
</div>
</header>
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Plus } from 'lucide-svelte'; import { Plus } from 'lucide-svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '#lib/api/types.js';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte'; import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
import { coverUrl } from '$lib/media/covers'; import { coverUrl } from '#lib/media/covers.js';
import TrackMenu from './TrackMenu.svelte'; import TrackMenu from './TrackMenu.svelte';
import LikeButton from './LikeButton.svelte'; import LikeButton from './LikeButton.svelte';
import Cover from './Cover.svelte'; import Cover from './Cover.svelte';
@@ -43,7 +43,7 @@
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent" hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
> >
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover"> <div class="h-12 w-12 shrink-0 overflow-hidden rounded bg-surface-hover">
<Cover src={cover} /> <Cover src={cover} />
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -1,31 +1,31 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte'; import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types'; import type { TrackRef } from '#lib/api/types.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTracks } from '$test-utils/fixtures/track'; import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/auth/store.svelte', () => ({ vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } } user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } }
})); }));
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/player/store.svelte', () => ({ vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(), playQueue: vi.fn(),
enqueueTrack: vi.fn(), enqueueTrack: vi.fn(),
playNext: vi.fn() playNext: vi.fn()
})); }));
import CompactTrackCard from './CompactTrackCard.svelte'; import CompactTrackCard from './CompactTrackCard.svelte';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte'; import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
const titles = ['First', 'Second', 'Third']; const titles = ['First', 'Second', 'Third'];
const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({ const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { FALLBACK_COVER } from '$lib/media/covers'; import { FALLBACK_COVER } from '#lib/media/covers.js';
import { Disc3 } from 'lucide-svelte'; import { Disc3 } from 'lucide-svelte';
// Shared cover-artwork renderer. The web counterpart of Android's // Shared cover-artwork renderer. The web counterpart of Android's
@@ -86,7 +86,7 @@
<button <button
type="button" type="button"
aria-label={`Bring ${title} back`} aria-label={`Bring ${title} back`}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent" class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze?.()} onclick={() => onUnsnooze?.()}
> >
Undo Undo
@@ -112,7 +112,7 @@
type="button" type="button"
aria-label={`Not right now — hide ${title} for a while`} aria-label={`Not right now — hide ${title} for a while`}
title="Not right now" title="Not right now"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onSnooze?.()} onclick={() => onSnooze?.()}
> >
<Clock size={16} strokeWidth={1} /> <Clock size={16} strokeWidth={1} />
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState'; import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/discover') pageUrl: new URL('http://localhost/discover')
@@ -5,9 +5,9 @@
getFingerprintSettings, getFingerprintSettings,
updateFingerprintSettings, updateFingerprintSettings,
type FingerprintSettings type FingerprintSettings
} from '$lib/api/admin'; } from '#lib/api/admin.js';
import { errMessage } from '$lib/api/errors'; import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '$lib/stores/toast.svelte'; import { pushToast } from '#lib/stores/toast.svelte.js';
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913). // How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
// Lives on the Duplicates page, beside the results these settings shape. // Lives on the Duplicates page, beside the results these settings shape.
@@ -83,7 +83,7 @@
const inputClass = const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' + 'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent'; 'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
</script> </script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5"> <section class="space-y-4 rounded-xl border border-border bg-surface p-5">
@@ -115,6 +115,19 @@
</span> </span>
</label> </label>
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.auto_resolve} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
<span class="block text-xs text-text-secondary">
Merges copies Lidarr doesn't use into the one it does, and moves a Lidarr album off a
release that lists songs twice. A song kept on several releases counts as one song, and
video rips are kept out of radio and the mixes. A copy Lidarr uses is never removed.
Off, every group waits here for you.
</span>
</span>
</label>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="block"> <label class="block">
<span class="text-sm text-text-primary">Seconds of audio to fingerprint</span> <span class="text-sm text-text-primary">Seconds of audio to fingerprint</span>
@@ -183,7 +196,7 @@
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary" class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
data-testid="length-warning" data-testid="length-warning"
> >
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" /> <TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span> <span>
Saving re-fingerprints {libraryTotal > 0 Saving re-fingerprints {libraryTotal > 0
? `all ${libraryTotal.toLocaleString()} tracks` ? `all ${libraryTotal.toLocaleString()} tracks`
@@ -206,7 +219,7 @@
<button <button
type="button" type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90 class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50" disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || problems.length > 0} disabled={!dirty || saving || problems.length > 0}
onclick={save} onclick={save}

Some files were not shown because too many files have changed in this diff Show More