Commit graph

1459 commits

Author SHA1 Message Date
BoulderBadgeDad
e148f859e7 Sync detail modal: click '→ Wishlist' to re-add a track with the original context
In the dashboard Recent Syncs detail modal, the '→ Wishlist' status on unmatched
tracks is now a button. Clicking it re-adds that exact track to the wishlist with
the SAME context the sync used (source_type='playlist' + the playlist's name/id +
failure_reason), so it's indistinguishable from the original auto-add.

- reconstruct_sync_track_data() (pure, tested): prefers the full cached track from
  tracks_json (by source_track_id, then index) so album art/full data carry over;
  falls back to the track_result fields; refuses non-'wishlist' rows and rows with
  no id (can't re-wishlist a matched/unidentifiable track).
- POST /api/sync/history/<id>/track/<i>/wishlist resolves the entry server-side and
  calls the wishlist service; idempotent (reports added vs already-on-wishlist).
- button shows a busy state then '✓ Re-added' / '✓ On wishlist'.

7 pure tests (full-track preference, id-vs-index match, fallback rebuild, non-
wishlist + out-of-range refusal). JS/PY/ruff clean.
2026-06-24 16:09:55 -07:00
BoulderBadgeDad
49d3c77808 Align playlists: add Jellyfin support (in-place reorder via Move endpoint)
Completes align across all three servers. Jellyfin reorders in place: DELETE the
extra entries (Mirror) then POST /Playlists/{id}/Items/{entryId}/Move/{index} for
each desired track in ascending order — so the playlist's poster/name/Id survive
(no delete-recreate), same as Plex/Navidrome. Mirrors the existing reconcile path's
entry-id handling (PlaylistItemId via /Playlists/{id}/Items).

- jellyfin reorder_playlist() + get_playlist_track_ids(); reuses the shared, tested
  plan_align_rewrite planner (no new pure logic).
- /align endpoint + frontend gate now cover navidrome|plex|jellyfin.

UNTESTED LIVE: no Jellyfin instance to verify against (same status as the Navidrome
path). Plex is the only one confirmed working end-to-end so far.
2026-06-24 13:20:32 -07:00
BoulderBadgeDad
bac5da9177 Align playlists: add Plex support + cover art + modal redesign
The align buttons were gated to Navidrome, so Plex users (the actual tester) never
saw them. Plex reorders in place via plexapi moveItem/removeItems — preserves the
playlist's poster/summary/ratingKey (no delete-recreate), same spirit as Navidrome's
overwrite.

- plex_client.reorder_playlist(): moves each desired track into sequence, removes
  any current item not in the ordered list (Mirror drops extras; Keep includes them).
  get_playlist_track_ids() feeds the shared tested plan_align_rewrite.
- /align endpoint dispatches navidrome + plex; reuses the pure planner for both.
- frontend gate opened to navidrome|plex.
- modal redesigned: cover art per row, gradient header, pop/fade animation, hover
  rows, real polish (was a plain numbered list).

plexapi moveItem/removeItems signatures verified against the installed version.
2026-06-24 13:12:09 -07:00
BoulderBadgeDad
606d1f951d Align playlists: reorder a server playlist to the source order (Navidrome)
Adds the 'Align playlists' action to the out-of-order modal — a dedicated,
order-only write path that does NOT touch the normal sync. Subsonic has no
per-track move, so it overwrites the song list in source order via createPlaylist
+ playlistId (same primitive replace-mode uses; identity/id preserved).

- plan_align_rewrite() (pure, tested): matched server ids in source order; every
  one must already be in the playlist (never injects a track); extras either
  dropped ('Mirror source') or parked at the end ('Keep extras'); returns None on
  stale data so a vanished track can't be written.
- navidrome rewrite_playlist_order() primitive (raw ordered ids).
- /api/server/playlist/<id>/align: validates ids are in the live playlist, then
  rewrites. Navidrome-only for now (Plex/Jellyfin reorder = follow-up).
- modal gets two explained options; missing tracks are NOT added (normal sync's
  job) and that's stated. Metadata-free by design — it only reshuffles existing
  server ids, so there's no sync-parity surface.

Open: confirm createPlaylist+playlistId preserves the playlist comment/image on a
live Navidrome (same risk as replace mode); add a re-apply step if it doesn't.
2026-06-24 12:58:01 -07:00
BoulderBadgeDad
ecd2500c39 Server playlist editor: surface 'accurate but out of order' + read-only server-order view
The editor renders the server column in SOURCE order (reconcile_playlist pairs
each server track to its source row), so a reordered-but-same-membership playlist
read as '5 matched / in sync' when Navidrome's real order actually differed — the
reorder never reaching the server was invisible.

- compute_order_status() (pure, tested): matched tracks' server positions must be
  strictly ascending in source order; uses RELATIVE order so missing/extra tracks
  never false-flag. reconcile entries now carry server_index (additive).
- endpoint returns order_status + server_order (the server's actual sequence).
- editor shows an amber 'out of order' badge on the server column when membership
  matches but sequence differs, opening a read-only modal of the real server order.
  One-way: source order stays the source of truth; no server-side editing.

Tests reproduce the reported 'Real Love Baby moved to #2' case + guard against
false-flagging on missing/extra. The actual 'sync order' WRITE is a separate
follow-up (membership/extra semantics + live identity-preservation test pending).
2026-06-24 12:15:47 -07:00
BoulderBadgeDad
d647fc8ad1 Release 2.7.7: version bump + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION -> 2.7.7; docker-publish default version_tag -> 2.7.7
- pr_description.md rewritten for 2.7.7 (the #915 primary-source parity headline, #913 listening-recs
  foundation, jellyfin atomic-write, and the #905/#908/#909/#910/#911/#912/#914/#916/#917/#918 batch)
- WHATS_NEW: replaced the 2.7.6 block with 2.7.7 (current release + a brief 'earlier versions' summary)
- VERSION_MODAL_SECTIONS: promoted the 2.7.7 highlights, rolled 2.7.6 into the 'Earlier' aggregator
2026-06-23 23:24:39 -07:00
BoulderBadgeDad
fc2c38ad97 #908: get YouTube playlists past the ~100-track cap (yt-dlp #16943 workaround)
YouTube Music 'Liked Music' (and any large playlist) only returned ~104 tracks. Diagnosed it
to a YouTube/yt-dlp regression (upstream #16943): the webpage-based playlist path stops at the
first ~100-item continuation page. Not a SoulSync cap (no limit in the parse path) and not the
user's cookies/IP — reproduced on a fully-served public playlist.

Fix: pass extractor_args youtubetab:skip=webpage so yt-dlp pages via the InnerTube API instead.
Verified live on the reporter's setup: 100 -> 200 entries on a large playlist (the workaround is
itself partial upstream, but a major improvement until yt-dlp PR #16948 lands). Single touch point
— parse_youtube_playlist is the only place that lists a YouTube playlist.
2026-06-23 13:35:04 -07:00
BoulderBadgeDad
cefddd73b7 Release 2.7.6: bump version + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION 2.7.5 -> 2.7.6
- docker-publish.yml default version_tag 2.7.5 -> 2.7.6
- pr_description.md rewritten for 2.7.6 (ListenBrainz export #903, YouTube Liked Music #902,
  Deep Scan data-loss guard #904, dashboard performance, #901/multi-disc/track-number fixes)
- helper.js WHATS_NEW: new 2.7.6 block + earlier-versions summary
- helper.js VERSION_MODAL_SECTIONS: 2.7.6 highlights lead; 2.7.5 rolled down

ruff check . clean app-wide; export/#904/cookie suites green (54).
2026-06-22 22:48:21 -07:00
BoulderBadgeDad
ab8f82af2e #903: re-export updates the same ListenBrainz playlist in place (no duplicates)
Re-running an export created a new LB playlist every time (LB keys on MBID, not name, and
create always mints a new one). Now remember which LB playlist a mirror was pushed to and
update it in place:

- listenbrainz_client: refactor batched-add into _add_tracks_in_batches; add
  get_playlist_track_count, delete_playlist, update_playlist (verify exists -> clear items via
  item/delete -> re-add -> edit title; reports gone=True if deleted on LB), and
  create_or_update_playlist (update when we have a prior MBID, else create; falls back to
  create if the remembered one was deleted). Stable URL/MBID across re-syncs.
- playlist_export_targets table + get/set_playlist_export_target: remember (mirror, target) -> LB MBID.
- export job consults/stores the target so push updates in place.

+6 mocked tests (clear+re-add same mbid, gone-fallback, create-or-update branches, delete). API
endpoints (item/delete, playlist/edit, playlist/delete, GET count) confirmed against LB docs;
live round-trip pending explicit auth.
2026-06-22 22:36:29 -07:00
BoulderBadgeDad
5ca88d96c1 #903: backend endpoints for playlist export to ListenBrainz/JSPF
Phase 5. Three additive routes + an in-memory job registry (new globals, no existing code
touched):
- POST /api/playlists/<id>/export/listenbrainz {mode: download|push} — spawns a background
  thread that loads the mirrored playlist's tracks, resolves each to a recording MBID via
  the waterfall, builds the JSPF, and (push) creates the playlist on ListenBrainz. Returns job_id.
- GET /api/playlists/export/status/<job_id> — live status (phase/done/total/coverage) for the
  card to poll; omits the heavy JSPF blob.
- GET /api/playlists/export/download/<job_id> — downloads the built .jspf.

Reuses the tested cores (build_resolve_fn, resolve_playlist_tracks, build_jspf, create_playlist).
2026-06-22 20:37:18 -07:00
BoulderBadgeDad
01c51a3c0e #904: guard standalone Deep Scan against relocating a desynced library
Standalone _run_soulsync_deep_scan did a path-only diff (untracked = transfer files
not in the soulsync DB) and shutil.move'd EVERY untracked file to Staging — no guard.
When the DB is empty/out of sync with disk (volume swap, DB reset, external Picard
tag edits) but Transfer holds the real library, that flags the whole library as
untracked and relocates all of it; Phase 5 then deletes the rows, and with Staging
cleanup on the files are gone for good. Reporter lost ~1,500 tracks into Staging.

The stale_guard the orphan detector + media-server deep scan already use (#828, #908)
was never wired into this path. Fix:

- core/library/standalone_scan.py (pure, tested): plan_standalone_deep_scan() diffs
  untracked (separator-normalized) and decides whether the move is safe. Blocks when
  the untracked share is implausibly large (>20 files AND >50% of Transfer — the
  desync signature, via is_implausible_orphan_flood) or when the user marked Transfer
  permanent. A normal batch of new arrivals still moves.
- web_server: consult the planner before Phase 4; on block, move NOTHING, leave files
  in place, and surface a loud warning + activity item. Guard Phase 5 deletes too
  (skip on desync-block or implausible stale share).
- 'Transfer is my permanent library — never move files out' toggle
  (import.transfer_is_permanent) in Settings.
- tests/library/test_standalone_scan.py: seam coverage + the #904 regression
  (empty DB + 1,500 files -> blocked, nothing moved).

No behavior change for in-sync libraries; the guard only trips on the desync pattern.
2026-06-22 17:53:49 -07:00
BoulderBadgeDad
49592f898c #902: YouTube Liked Music sync — paste a cookies.txt (server/Docker auth)
Private YT Music playlists (a user's Liked Music, list=LM) need auth, but the
only cookie option was cookiesfrombrowser — a browser on the same machine as
SoulSync, useless on a headless/Docker box (and locked to whatever account that
browser happens to be signed into). Add a 'Paste cookies.txt' mode so users can
supply the exact session they want from any machine.

- core/youtube_cookies.py: pure seam — build_youtube_cookie_opts (cookiefile vs
  cookiesfrombrowser precedence, mutually exclusive, fail-safe on a missing file),
  looks_like_cookiefile (needs a real cookie row; rejects junk/header-only),
  write_pasted_cookiefile (validate + 0600 write; blank/junk never clobbers a saved file).
- _youtube_cookie_opts() delegates to the seam, so every yt-dlp call site gets it.
- /api/settings pops cookies_paste before the generic persist, validates (400 on
  junk), writes config/youtube_cookies.txt, stores only the path (blob never hits config.json).
- Settings dropdown gains 'Paste cookies.txt'; selecting it reveals a textarea.
- tests/test_youtube_cookies.py: precedence, validation, fail-safe write (11 tests).
2026-06-22 14:41:04 -07:00
BoulderBadgeDad
ad0eda3575 Release 2.7.5: bump version + What's New / version modal + PR description + docker-publish default tag
Bumps _SOULSYNC_BASE_VERSION to 2.7.5, defaults the docker-publish workflow's
version_tag to 2.7.5, and refreshes the release notes for the fixes/features
since 2.7.4: deezer real track numbers, special-edition cover art (release-scope),
the leading-'The' dedup, HiFi preview rejection (#895), M3U/M3U8 import (#893),
organize-by-playlist file naming, durable Find & Add match, ignore-list management
+ manual-add unblock (#897), and the Unraid template fixes (#899). WHATS_NEW +
VERSION_MODAL_SECTIONS rolled to 2.7.5 with 2.7.4 folded into the earlier-versions recap.
2026-06-21 22:57:14 -07:00
BoulderBadgeDad
cc7d48b736 Release 2.7.4: bump version + What's New / version modal + PR description
Bumps _SOULSYNC_BASE_VERSION to 2.7.4 and refreshes the What's New panel +
version-modal highlight reel to the 2.7.4 set (re-identify #889 headline; #890
title-strip; #891 residual-folder cleanup; #886 AAC tier; #887 Spotify Free
status; #884 NZBGet; #885 tz; Sokhi import-cleanup batch), with 2.7.3 rolled into
the brief 'earlier versions' summary per the current-release-only convention.
2026-06-18 20:44:53 -07:00
BoulderBadgeDad
9a36d6f70b #889: precise error + diagnostics when a track's file can't be located for re-identify
The apply endpoint silently fell back to the raw stored DB path when the resolver
missed, producing a confusing 'Source file not found: <raw path>'. Now: resolve via
the app's strong _resolve_library_file_path (keeps #833 confusable folding), and on
a miss run the diagnostic resolver to log + report exactly which transfer/download/
library/Plex dirs were searched and whether the raw path existed — so a media-server-
only or stale-path track gives a clear 'SoulSync can't read this file' instead of a
dead end. No mutation happens on this path (fail-safe; nothing staged/deleted).
2026-06-18 16:28:56 -07:00
BoulderBadgeDad
f4c16ecc22 #889 Phase 4: the Re-identify modal + apply backend
The showpiece: a focused 'which release does this track belong to?' chooser.
Source tabs (default active), pre-seeded search, the same song surfaced across
single/EP/album with color-coded type badges, ISRC-ranked, replace-original
toggle (on by default). Glassy panel, blurred hero art, shimmer/spinner states,
hover-lift result cards — matched to the app's modal language.

Backend:
- core/imports/rematch_apply.py: pure staged_destination + build_reidentify_hint,
  injectable stage_file_for_reidentify (COPIES the file, never moves — original
  safe until re-import succeeds). 6 tests.
- POST /api/reidentify/apply (admin-only): resolve_hint_fields → stage file →
  create_hint → nudge the worker. Replace deletes the old row only on success.

Frontend: modal markup (index.html), full stylesheet (style.css), and the
openReidentifyModal/search/select/confirm flow (library.js). Not yet reachable
from a button — Phase 5 wires it.
2026-06-18 15:37:56 -07:00
BoulderBadgeDad
3e554f8274 #889 Phase 3: re-identify search — multi-source track→release lookup + API
Search any configured source (tabs, default active) and surface the SAME song
across its collections (single/EP/album) so the user can pick which release a
track should be filed under.

- core/imports/rematch_search.py: pure normalize + injected client factory.
  search_release_candidates() → lightweight display rows from typed search_tracks
  (title/artist/release/type badge/year/count/art/isrc/track_id); resolve_hint_fields()
  runs ONCE on the picked row via get_track_details to pull the album_id (+ isrc/
  track#/disc) the hint needs. infer_release_type() handles Spotify's missing 'EP'
  (multi-track 'single' → EP badge); filing is driven by real album_id, not the label.
- GET /api/reidentify/sources (tabs) + GET /api/reidentify/search (rows). Graceful
  empty on dead source / blank query / client error — never raises.

14 tests. Inert until the modal (Phase 4) calls it.
2026-06-18 15:31:33 -07:00
BoulderBadgeDad
6c0d79a84a Release 2.7.3: bump version + What's New / version modal + docker-publish default tag
- web_server.py: _SOULSYNC_BASE_VERSION 2.7.2 -> 2.7.3
- helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS rewritten for 2.7.3
  (current release + rolled-down 2.7.2/2.7.1/2.7.0 summary)
- docker-publish.yml: workflow_dispatch default tag -> 2.7.3

2.7.3 = Quality Upgrade Finder + #867 Tidal discovery + #880/#879/#877/
#876/#874/#870/#868 fixes + the 'Track 01' track-number recovery.
2026-06-16 00:03:57 -07:00
BoulderBadgeDad
4bdde1248e #874 fixup: import get_wishlist_service in the ignore-list endpoints
ruff F821 caught a real NameError: the three /api/wishlist/ignore-list*
endpoints called get_wishlist_service() without the local import every
other call site in web_server.py uses, so they'd crash the moment the
Ignored modal queried them. Add the import; ruff check now clean.
2026-06-15 23:52:39 -07:00
BoulderBadgeDad
48e86a1a58 #874: wishlist ignore-list — stop auto-retrying removed/cancelled tracks
A user who removes a wishlist track, or cancels an in-flight wishlist
download, would have it re-added on the next auto cycle (watchlist scan,
failed-track capture, or the cancel handler's own re-add), so the same
release downloaded -> failed/cancelled -> re-queued forever.

Adds a TTL'd skip-gate (30 days), softer than the blocklist: it expires
so the track is reconsidered later, and never blocks a manual
force-download — only the automatic re-queue.

- core/wishlist/ignore.py: pure TTL/normalization/display logic + a
  best-effort orchestrator (no DB handle, caller passes now).
- database/music_database.py: migration-safe wishlist_ignore table +
  add/check/remove/list(+purge)/clear methods, and the gate in
  add_to_wishlist beside the blocklist guard. Fail-open throughout — an
  ignore error can never block a legitimate add; a manual add bypasses
  the gate AND clears the ignore.
- routes.py: user remove (single/album/batch) records an ignore. Hooked
  at the route layer, NOT the DB remove, so success-cleanup never
  ignores (regression-tested).
- web_server.py: cancel now ignores + removes from the wishlist instead
  of re-adding for endless retry; three /api/wishlist/ignore-list*
  endpoints.
- downloads.js: 'Ignored' modal (view / un-ignore / clear all).
- 13 tests: pure logic, DB seam, gate (block/bypass/fail-open),
  route wiring, and the success-cleanup-does-not-ignore regression.
2026-06-15 22:50:39 -07:00
BoulderBadgeDad
46be97b195 #876: group quarantine alternatives by target track-id + auto-clear siblings on approve
Multiple failed source attempts at one song each land in quarantine as
separate entries. Group them by the *intended* target (sidecar context
track_info isrc -> id -> uri, falling back to normalized artist|title for
legacy thin sidecars) — an exact relationship across siblings, since the
bad files' own tags differ but the target track is constant.

- core: quarantine_group_key() + find_quarantine_siblings() seams; list
  entries now carry group_key.
- approve endpoint: remove_siblings flag auto-deletes the other attempts
  once one is accepted (captured BEFORE approve restores the file out of
  quarantine, or the id lookup would resolve nothing). Scoped to the
  quarantine manager; download-modal chooser + version-mismatch fallback
  pass no flag and are unaffected.
- UI: multi-member groups render as a collapsible parent row (album art +
  'N alternatives'); singletons unchanged. Toast reports removed count.
- 11 tests incl. ordering regression for capture-before-approve.
2026-06-15 22:12:06 -07:00
BoulderBadgeDad
e7814e0acf #877: Download Discography filters mirror Artist Detail (fix dead EPs + add Live/Comp/Featured)
The Download Discography modal exposed only Albums/EPs/Singles, its EPs toggle did
nothing, and Live/Compilations/Featured were missing — so you couldn't fine-filter
a bulk download the way Artist Detail lets you browse.

Root cause: the modal's endpoint (/api/artist/<id>/discography) used the base
get_artist_discography, which lumps EPs into singles, and the modal only read
{albums, singles} — so the EPs bucket was always empty (dead toggle). It also had
no content-type (Live/Compilation/Featured) classification at all.

- Backend: the endpoint now uses get_artist_detail_discography — the SAME split
  Artist Detail uses — and returns a separate `eps` list.
- Frontend: read `eps`; tag each card with data-is-live/compilation/featured via a
  new shared _classifyReleaseContent() (also adopted by the Artist Detail cards so
  the two can't drift); add Live/Compilations/Featured filter buttons; combined
  category+content filtering. The download payload is built from VISIBLE checked
  cards, so every toggle now actually changes what downloads.
- Regression test: get_artist_detail_discography splits an EP into the eps bucket.
2026-06-15 21:30:55 -07:00
BoulderBadgeDad
09b97c5f63 #870: Deezer ARL 'resets itself' — test the SAVED token, not the redaction mask
The Deezer ARL field round-trips a redaction sentinel for a saved-but-untouched
secret (shown as dots). The save path already guards against the sentinel
overwriting the real token (ConfigManager.set), so the ARL was never actually
lost — but the connection TEST read the field value and sent the sentinel as the
token, so Deezer returned USER_ID=0 ('Invalid ARL token') after navigating away
and back. That false failure made it look like the ARL kept resetting.

Fix:
- ConfigManager.resolve_secret(key, posted): empty/sentinel posted value -> the
  stored value; a real string -> a genuine new secret. Reusable for any secret
  connection-test (single source of truth).
- /api/deezer-download/test now resolves the effective ARL via resolve_secret, so
  an untouched field tests the stored token.
- testDeezerDownloadConnection() strips the sentinel before sending (untouched ->
  empty -> backend uses the saved token).

Seam/regression tests for resolve_secret (sentinel/empty/none -> stored, real ->
passthrough, nothing stored -> empty). JS integrity 64 green.
2026-06-13 15:37:29 -07:00
BoulderBadgeDad
b393866782 Remove old auto-acting Quality Scanner tool (replaced by Quality Upgrade Finder job)
Phase 2 of the redesign. The tool that judged quality by extension and auto-dumped
matches into the wishlist is gone; quality scanning is now the reviewed
quality_upgrade repair job.

Removed:
- Frontend: Tools-page Quality Scanner card, its JS handlers/poller/socket listener,
  help tooltip + tour entry (webui index.html, core.js, helper.js, wishlist-tools.js).
- Backend: /api/quality-scanner/{start,status,stop} endpoints, the in-memory state +
  executor + 1s socket broadcast, the QualityScannerDeps/run_quality_scanner shim.
- core/discovery/quality_scanner.py: the auto-acting worker + deps class (the shared
  match/normalize helpers stay — the new job imports them).

Rewired:
- Automation 'start_quality_scan' action now triggers the quality_upgrade repair job
  via repair_worker.run_job_now() (AutomationDeps gains run_repair_job_now, drops the
  4 scanner fields). Action block's vestigial scope field removed (scope lives in the
  job's settings now). NOTE: the 'quality_scan_completed' trigger no longer fires (the
  repair job doesn't emit it).
- Updated all automation test _build_deps helpers + conftest tool-progress harness;
  deleted the obsolete worker test. 528 affected tests pass; 6123 collect cleanly.

QUALITY_TIERS / _get_quality_tier_from_extension kept (used elsewhere).
2026-06-13 12:14:45 -07:00
BoulderBadgeDad
c7ca657d56 Release 2.7.2: bump version + What's New / version modal + docker-publish default tag
Single source of truth _SOULSYNC_BASE_VERSION -> 2.7.2 (drives UI, system-info,
update check, backup metadata). docker-publish workflow_dispatch default tag -> 2.7.2.
WHATS_NEW + VERSION_MODAL_SECTIONS rewritten for 2.7.2 (current release + brief
earlier summary, per convention).
2026-06-13 10:16:35 -07:00
BoulderBadgeDad
fb260baa48 HiFi instances: 'Restore Defaults' button (re-adds removed defaults, keeps customs) + bigger tap targets for the ✔/✖ controls (Sokhi) 2026-06-13 09:16:39 -07:00
BoulderBadgeDad
6e7fd3ff5c M3U export: resolve paths via one bulk read instead of a per-artist search loop (fixes 'Export M3U hangs forever' under active enrichment/scan DB writes) 2026-06-13 08:55:46 -07:00
BoulderBadgeDad
651b904e92 Watchlist: per-artist 'auto-download' toggle (follow-only) — off = discover/surface releases but skip the wishlist add; default on 2026-06-13 08:07:20 -07:00
BoulderBadgeDad
c62074d54a #865: resolve pasted SoundCloud links (incl. unlisted/private share URLs) via direct yt-dlp resolve; manual-search forces the SoundCloud source 2026-06-13 00:41:47 -07:00
BoulderBadgeDad
ba5d62946a Mirrored playlists: custom name alias (overrides display + sync name, survives upstream refresh) — card rename button like the source-ref editor 2026-06-13 00:23:56 -07:00
BoulderBadgeDad
c72e83bc2f #863: move YouTube artist recovery out of the (synchronous) parse into the async discovery worker — parse is fast again, no 120s timeout risk 2026-06-12 23:46:29 -07:00
BoulderBadgeDad
0093af89d2 #863: parallelize YouTube artist-recovery (5-worker pool) so the whole playlist resolves within the budget, not just the first ~dozen 2026-06-12 23:36:31 -07:00
BoulderBadgeDad
0a6325a87b #863: recover YouTube track artist via per-video uploader/channel when flat extraction returns title-only (budget-bounded) 2026-06-12 21:38:34 -07:00
BoulderBadgeDad
4f24c2af6d #863: derive YouTube track artist from music fields / -Topic channel / 'Artist - Title' instead of the playlist owner 2026-06-12 20:14:55 -07:00
BoulderBadgeDad
f5787764d4 #859: DB-update stall watchdog + UI self-heal (no more wedged 'Starting...' / frozen bar) 2026-06-12 19:38:30 -07:00
BoulderBadgeDad
6e86bac6eb M3U: skip the auto-save no-op when export is disabled (fixes ~30s analysis jam)
The download modal auto-saves an M3U on every render (save_to_disk, no force).
When m3u_export.enabled is off it writes nothing — but only AFTER ~30s of
per-track DB search + fuzzy matching, which it then discards; fired repeatedly
during analysis it jammed the batch (0 tasks, user cancels). Bail out at the top
of generate_playlist_m3u for exactly that case (save_to_disk and not force and
not enabled). Manual 'Export as M3U' sends force=True and content-only requests
send save_to_disk=False — both unaffected.

Pre-existing bug, unrelated to the playlist-folder feature, but it was blocking
the discovery->download flow.
2026-06-12 16:35:11 -07:00
BoulderBadgeDad
d160c486ec Playlists: mirror-update trigger prunes removed tracks (the other half)
Symmetric to the post-download reconcile (which handles ADDITIONS): when a
playlist's membership is re-synced (the mirror step — scheduled refresh or the
manual mirror endpoint), rebuild its folder from current membership WITH prune
IF it's organize-by-playlist. So a track that just LEFT the playlist has its
symlink cleaned up the instant membership changes, not only on the next download.

Factored a shared _rebuild_one_from_db (used by the manual 'Rebuild' button and
the mirror hook) + rebuild_mirrored_playlist_if_organized. Gated to organized
playlists, non-fatal at both mirror call sites.

Now the invariant 'folder = the playlist's current owned members' holds on every
change: additions caught at download, removals caught at mirror. 2 new tests
(removed track pruned; non-organized skipped). 985 + 277 tests pass.
2026-06-12 15:49:13 -07:00
BoulderBadgeDad
87621b7191 Playlists: Settings UI (path + symlink/copy + rebuild button) + rebuild endpoint
- Settings: 'Playlists Folder' path field (Unlock pattern, separate-root help
  text), a Symlinks/Copies selector, and a 'Rebuild playlist folders now' button
  (standard test-button style). Wired through PATH_INPUT_IDS / load / save, plus
  'playlists' added to the settings save allowlist so it persists.
- POST /api/playlists/materialize/rebuild → rebuild_organized_playlists_from_db:
  rebuilds every organize-by-playlist folder from CURRENT ownership, re-matching
  each track with check_track_exists (name, not IDs) so it self-heals after a
  reorganize / membership change. +1 test.

70 materialize tests + JS integrity pass; settings round-trip wiring verified.
2026-06-12 14:04:34 -07:00
BoulderBadgeDad
a789fb71c0 Library export: export the whole library roster too (corruption's request)
Extends the watchlist export to the full library. The exporter is now general
(core/exports/artist_export.py, renamed from watchlist_export) — adds tidal/qobuz
links and an extra_fields passthrough, so the library export also carries
lastfm/genius URLs + soul_id, and an optional "library counts" toggle adds owned
album/track counts per artist.

- GET /api/library/artists/export?format=&links=&contents= — pulls every artists
  row, normalizes onto the canonical *_artist_id keys, optionally GROUP-BY counts
  for album/track totals.
- The export modal is now openArtistExportModal(scope): "Export Library" button in
  the library header + the existing "Export" on the watchlist bar (a thin wrapper).
  Library mode shows the extra "library counts" toggle.

Tests (11): builder across formats + the new tidal/qobuz links + extra_fields
columns; watchlist + library endpoint wiring. 64 integrity green; ruff clean.
2026-06-11 22:59:21 -07:00
BoulderBadgeDad
f8652c106b Watchlist: export the roster to JSON / CSV / text (corruption's request)
An "Export" button on the watchlist filter bar opens a modal (same aesthetic as the
artist DB-record inspector) to export your whole watchlist roster — each artist's
name + source IDs (spotify / musicbrainz / deezer / discogs / itunes / amazon),
with an optional "external links" toggle that adds the discography URLs built from
those IDs. Live preview, copy, and download in the chosen format.

- core/exports/watchlist_export.py: pure builder (json/csv/txt + links, present-IDs
  only, deterministic columns) — the single source of truth, fully unit-tested.
- GET /api/watchlist/export?format=&links= shapes the roster + returns it (with
  X-Export-Count / X-Export-Ext headers for the modal).
- Frontend reuses the DB-record helpers (_jsonSyntaxHighlight / _arecCopy).

Tests (8): builder across json/csv/txt, links on/off, present-ids-only, empty +
bad-format fallback, mime/ext, and endpoint wiring. ruff clean; 64 integrity green.

Scoped to the watchlist for v1; library-wide export + a "library contents"
(owned albums/tracks) option are natural follow-ups.
2026-06-11 22:48:58 -07:00
BoulderBadgeDad
5b52d579c5 Login mode: enforce "every profile has a password" at every write-point (no gaps)
Invariant: while security.require_login is on, every profile must have a login
password or it's locked out. Previously only the admin's own anti-lockout existed,
so members could be stranded (created without a password, or login flipped on while
passwordless members existed). Closed all the write-points:

core/security/login_provisioning.py (pure policy, single source of truth):
- members_without_password(profiles) — non-admin profiles that can't sign in
- create_needs_password(require_login) / removing_password_strands(require_login)

Wired into web_server:
- create_profile: while login is on, a new member must be given a password (400
  otherwise) and it's set on creation.
- enable-login (settings save): refuses to turn login on while any member lacks a
  password — lists them — same shape as the existing admin anti-lockout.
- set-password: refuses to CLEAR a password while login is on (would strand them).

UI: Create Profile form gains a login-password field (alongside the optional PIN);
the Manage Profiles per-member password button (prior commit) covers existing
members + changes.

Tests: pure policy seam + endpoint enforcement (create blocked w/o password when
on, allowed w/ password, no friction when off, clear blocked when on). 442
profile/settings/auth tests green; ruff clean.
2026-06-11 19:48:50 -07:00
BoulderBadgeDad
123eb6139f Artist detail: "DB Record" inspector — everything the DB knows about an artist
A small glowing button at the bottom-right of the artist hero (library artists
only) opens a programmer-style modal showing the COMPLETE artists DB row — every
source id + match status, cached bios / tags / similar / urls, soul_id, timestamps,
the lot (62 columns) — plus owned album/track counts.

- Backend: GET /api/artist/<id>/record returns the full row with JSON-text columns
  (genres, aliases, lastfm_tags/similar, discogs_urls, …) decoded into real
  arrays/objects, + album/track counts. 404 for non-library artists.
- Frontend: editor-themed modal (Tokyo-night tokens) with a Fields tab (copyable,
  filterable key/value rows) and a syntax-highlighted JSON tab. Copy-all-as-JSON,
  per-value copy (HTTP/Docker clipboard fallback), and Save .json. Esc / click-out
  to close. Helpers namespaced (_arecEsc) so they can't clobber the shared globals.

Tests: endpoint returns the full row with decoded JSON + counts; 404 for a missing
artist. 64 script-split integrity tests still green; ruff clean.
2026-06-11 16:57:48 -07:00
BoulderBadgeDad
ee4d514d60 CI: silence S110 on three intentional best-effort swallows (unblocks dev build)
The dev-nightly build runs `ruff check .` before "Build and push to GHCR" in the
same job, so the three S110 (try/except/pass) errors introduced since the last
green build (ce6ce4d) failed the lint step and SKIPPED the image push entirely —
every dev-nightly since #704 went red, so the dev image was never rebuilt and none
of the recent fixes (incl. the #852 WebSocket login-bypass fix) ever shipped to
the image users pull.

All three are deliberate best-effort swallows; annotate them with the repo's
existing `# noqa: S110 — <reason>` convention rather than adding dead logging:
- relocate.py: tag write is best-effort (re-import re-derives tags)
- acoustid_scanner.py: verification-status tag is optional context
- web_server.py: audio-duration probe falls through to 0

ruff check . + compileall now clean; pytest already passed in CI at ce6ce4d.
2026-06-11 16:00:07 -07:00
BoulderBadgeDad
572a7c05f0 Release: bump version to 2.7.1 + default the docker publish tag to 2.7.1
- _SOULSYNC_BASE_VERSION 2.7.0 → 2.7.1 (single source of truth; drives the UI
  version + commit-suffixed build string).
- docker-publish.yml workflow_dispatch default version_tag 2.7.0 → 2.7.1 (and the
  description example), so the manual tagged-release publish defaults to 2.7.1.
2026-06-11 15:45:39 -07:00
BoulderBadgeDad
46eccbb237 #852: gate the WebSocket handshake — close the launch-PIN/login bypass
The #832 fix enforces the launch PIN / login via a Flask before_request hook, but
that hook does NOT run for the socketio handshake — empirically a normal endpoint
401s while /socket.io/ returns 200 with the gate on. So removing the client overlay
(Safari "Hide Distracting Items", devtools) + opening a socket streams live data
(downloads, logs, dashboard, notifications) completely unauthenticated.

Fix: the socketio connect handler now enforces the same check and returns False
(rejects the connection) when a gate is active and the session isn't verified.
Rejecting connect blocks every downstream WS event (subscribe/join), so all live
data is covered. core/security/ws_gate.is_ws_connection_blocked is the pure seam:
login mode (when on) > launch PIN > open, mirroring the HTTP gate exactly. Fails
OPEN on a config-read error, same as the HTTP gate.

Audited every other surface empirically with the gate on + unauthenticated: SSE
streams, catch-all pages, library/dashboard data, admin endpoints, search,
image-proxy, audio-stream (incl. a /etc/passwd traversal probe) all 401; /api/v1
key-gated. The WebSocket was the only hole.

Tests (10): pure gate logic (login>pin precedence, all on/off combos) + real
socketio.test_client integration — connect rejected when gate on + unauthenticated,
allowed when gate off or PIN verified.
2026-06-11 13:27:03 -07:00
BoulderBadgeDad
87e5e1fa23 #702: make mirrored-playlist cancel/reset/delete idempotent (un-wedge LB weekly sync)
Root cause (from the reporter's app.log): a ListenBrainz weekly playlist syncs
through the in-memory youtube_playlist_states discovery machine. When that live
state is lost — a Docker restart, or the discovery process ending while the user
waits for the media-server scan — the DB discover-download snapshot survives but
the live state is gone. Every recovery action (Cancel/Reset/Delete) then hit
`key not in states` and returned 404 "YouTube playlist not found" (hence the
confusing "Youtube" on a ListenBrainz playlist), leaving the playlist permanently
wedged with no way to dismiss or re-sync. Works for the maintainer because a
single session with no restart keeps the live state alive.

Fix — these are cleanup ops, so "the thing is already gone" is SUCCESS, not 404:
- cancel_sync core (shared by YouTube + ListenBrainz + Tidal/Deezer/Qobuz/...) →
  missing key returns idempotent success.
- reset_youtube_playlist / delete_youtube_playlist → same.
The playlist becomes recoverable: Cancel/Reset clears the dead state and the user
re-syncs fresh.

Tests: cancel_sync core (missing key = idempotent 200 not 404; present key still
cancels + clears the worker + reverts phase); endpoint-level idempotency for
cancel/reset/delete; updated the old test that locked the 404 wedge. 834 sync/
discovery tests green.
2026-06-11 12:55:55 -07:00
BoulderBadgeDad
a207bd943b #845 tests: lift history-path resolver to core/ + seam-test the delete-safety
resolve_history_audio_path drives a DESTRUCTIVE delete (os.remove), but lived
endpoint-bound in web_server with zero tests. Lifted to core/matching/history_paths
with injected effects (exists / resolve_library_path / lookup_titled_paths) so the
fallback chain — and the collision-safety that stops delete() from removing the
wrong same-title file — is a clean importable seam. web_server now wraps it (DB
lookup + os.path.exists + prefix resolver injected); behavior preserved.

9 tests lock it: recorded-path hit, prefix-resolve fallback, single tracks-table
candidate, and the safety rules — multiple same-title candidates with NO artist ->
None (refuse to guess), artist filter picks only the matching path, artist named
but unmatched -> None, no-title/empty-lookup -> None. Full suite green (5906).
2026-06-11 11:07:25 -07:00
BoulderBadgeDad
17440329c1 #845 follow-up: admin-gate the mutating verification-review endpoints
The merged PR left the review-queue's mutating endpoints ungated. Both now require
admin, matching the Phase 3 destructive-endpoint convention:

- /api/verification/<id>/delete (os.remove + drops the history row) — @admin_only,
  so a non-admin on a login/multi-profile instance can't delete library files.
- /api/verification/<id>/approve (flips verification_status + writes the tag) —
  @admin_only; also wrapped its DB writes in `with db._get_connection()` for
  rollback-on-error + codebase consistency (was a bare conn).

Read/playback endpoints (stream/play/compare/entry/config) stay open — the app's
LAN-read model. Tests: non-admin gets 403 on delete + approve; admin isn't blocked.
2026-06-11 10:43:40 -07:00
BoulderBadgeDad
eb35ba86fb
Merge pull request #845 from nick2000713/fix/import-folder-artist-override-optin
feat: import folder-artist override opt-in + verification pipeline review queue
2026-06-11 10:39:59 -07:00
nick2000713
bf5affd03c resolve merge conflict in style.css 2026-06-11 18:21:04 +02:00