165c87047e971e0e432e598565f606d7bae7e242
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
327d49428f |
feat(auth): store Subsonic API keys hashed; a new key is shown once (M462 #4983)
test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token held each user's apiKey in plaintext and was looked up by equality, so a leaked row or backup handed out working keys. Migration 0063 replaces it with api_token_hash (sha256, hex), computed in place from the existing keys so every Subsonic client keeps working. The key can no longer be read back: GET /api/me/api-token is gone, and POST returns the new key once. Settings shows it right after Regenerate with a copy button and a "won't be shown again" note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
11ef044ef6 |
feat(library): merge duplicates without losing history (M400 #3911)
test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.
In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
lidarr_requests.matched_track_id and playlist_tracks. The last is
keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
would point a track at itself and keeping the kept copy's existing
edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
marks the group merged
- logs sync changes: track deletes, and like and playlist-track
delete/upsert pairs
The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.
tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.
POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.
On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
removes, with the consequence stated beside an opt-in Lidarr checkbox
Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
deduped at the earlier time, plays and skips counted, playlist
position unchanged, tags unioned, similarity rewritten with no
duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
|
||
|
|
d7a8e5f300 |
fix(library): a track delete that cannot remove its file deletes nothing — #3918
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.
One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.
Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.
The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.
DELETE /api/admin/tracks/{id} has had no client since
|
||
|
|
754a7955cc | refactor(server): final writeErr migration + ErrNotFound aliases (T3c) | ||
|
|
5c386dc73e |
fix(server,web/m7-cover-sources): forward-fix CI
Three failures from the prior push: 1. internal/api/library.go and 3 other callers of q.GetArtistByID broke because T2 modified the query to use an explicit column list, making sqlc generate GetArtistByIDRow instead of returning dbq.Artist. dbq.Artist already has every column added by migration 0018 (artist_thumb_path / artist_fanart_path / artist_art_source / artist_art_sources_version), so the explicit column list was unnecessary work. Revert the SELECT to SELECT *, regenerate sqlc, GetArtistByIDRow disappears, all callers compile again. 2. internal/tracks/service_test.go missed the dataDir parameter added to NewService in T9. Add "" as the 4th arg to every test call site (tests don't exercise the artist-art cleanup path; the empty dataDir is fine). 3. integrations.test.ts:356's mock.calls.filter callback had a too-narrow type annotation that svelte-check rejected. Relax to any[] to match what mock.calls actually is. |
||
|
|
295d9da361 |
feat(server/m7-cover-sources): artist art enricher (thumb + fanart)
Parallels EnrichAlbum but with no sidecar layer (artists have no per-artist directory in a music library). Iterates EnabledArtistProviders, writes thumb.jpg + fanart.jpg to <data_dir>/artist-art/<artist_id>/, stamps artist_art_source + artist_art_sources_version atomically. Atomicity: provider returns whichever images it has — partial success (thumb-only or fanart-only) is persisted with whichever paths landed, source still stamped. ErrNotFound from every enabled provider settles the row to 'none' with the current version stamp. Any ErrTransient leaves the row NULL for next-pass retry. CleanupArtistArt removes <data_dir>/artist-art/<artist_id>/ on artist delete; idempotent on missing dir. Hooked into the artist- delete cascade in tracks/service.go after the transaction commits; non-fatal on failure (logs but doesn't fail the delete). EnrichArtistBatch drains rows from ListArtistsMissingArt; the orchestrator's 4th stage (added in T10) calls this. Tests cover the eligibility table, atomicity (thumb-only / fanart-only), all-404-settles, ErrTransient-leaves-NULL, filesystem layout, and CleanupArtistArt idempotency. tracks.Service gains a dataDir field; tracks.NewService takes it as a new parameter. All three call sites updated (server/server.go, api/auth_test.go, api/admin_tracks_test.go). The dataDir flows from cfg.Storage.DataDir → server.New → s.DataDir → tracks.NewService without any change to cmd/minstrel/main.go. |
||
|
|
a8fd33d4ed |
chore(lint): clear pre-existing CI lint debt
Nine golangci-lint failures accumulated across M7 #352, #372, and
|
||
|
|
f6975cfad3 |
fix(tracks): rewrite RemoveTrack — direct os.Remove + optional Lidarr unmonitor
Replaces commit 50a231f's wrong-shape Lidarr-routed delete. The previous
design called lidarrquarantine.DeleteViaLidarr for Lidarr-managed tracks,
which deletes the entire **album** in Lidarr (Lidarr is album-granular,
no per-track delete API) — silently dropping sibling tracks the operator
didn't ask to remove.
New shape per spec revision
|
||
|
|
50a231fdc1 |
feat(tracks): RemoveTrack service with Lidarr-aware delete + cascade
RemoveTrack handles both Lidarr-managed (delegates to existing lidarrquarantine.DeleteViaLidarr) and non-Lidarr (direct os.Remove) file deletion, then runs the cascade album-if-empty / artist-if-empty DB cleanup in a single transaction. Lidarr errors propagate as typed errors so the API layer can map them to the existing wire codes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |