'No search results' was masking the case where slskd never accepted the search
(not configured / errored / rate-limited) — that returns instantly, which is why
some show up as no-results FAST instead of after the search window.
_search_for_retry now flags started=False (+ the slskd error) when there's no
search id; the processor logs "Search didn't run for 'X' — slskd not responding?"
(warning) and tallies it separately. so the next run tells us if the fast
no-results are really slskd refusing searches vs the source genuinely being empty.
two issues:
1. slskd search gave up at 22s (then stopped the search), but slskd gathers peers
over its full ~60s window — so most movie searches were killed before results
arrived. now waits up to 55s but returns early once results SETTLE (12+ hits, or
no new hits for ~12s) so fast searches don't burn the whole window. matches the
music side's longer wait.
2. the automations page re-rendered the WHOLE system section every 8s poll
(replaceChild) → blink + wiped the socket's live progress. now it skips the
rebuild unless something structural changed (added/removed/toggled/ran); live
progress comes via socket, the countdown ticks locally.
bug: start_search tells slskd to keep searching its full timeout (~60s), but
_search_for_retry only polled then walked away — never stopping the search. for
movies (popular → 12+ hits in <1s → worker early-breaks → fires the next search
immediately) this piled up dozens of 60s-long slskd searches at once. tv episodes
return fewer hits → workers block the full 22s → searches issue slowly → no pileup
(why tv looked fine and movies flooded).
fix: stop_search(id) (DELETE /api/v0/searches/{id}); _search_for_retry stops its
search in a finally, even on early break. concurrent slskd searches now ≈ the
worker pool (3). retry worker benefits too. tested.
new 'Download YouTube Wishlist' automation: pushes a polite batch of wished
youtube videos into the shared video_downloads queue + spawns the yt-dlp worker
per video. skips in-flight ones (no double-grab); big backlogs drain over
several scheduled runs (batch_size, default 3). needs the youtube library folder
set.
- download_monitor: SKIP source='youtube' rows (owned by their worker thread, no
slskd transfer to match) — surgical, slskd path untouched.
- db.youtube_wishlist_to_download(): flat newest-first list of wished videos with
channel/title/date/thumb for organising.
block + registration + icon/label + drift test. all seam-tested.
video_downloads is a transient queue (hard-deleted on cleanup), so there was no record
of what SoulSync actually grabbed. Add a permanent video_download_history table +
capture: the monitor snapshots every terminal download (completed/import_failed/
cancelled/failed) into it, with rich metadata (title, year, S/E from search_ctx,
release, source, size, quality + parsed resolution/codec, dest path, poster, outcome,
timestamps). Idempotent per (download_id, outcome, dest_path).
DB methods: record_download_history, query_download_history (paged/kind/search),
download_history_detail, download_history_counts, latest_completed_download(media_type)
— the last is the probe target for the upcoming smart post-download scan. Schema v17.
End-to-end import for video grabs, mirroring the music side's rigor and the
Radarr/Sonarr standard, fully isolated in core/video + api/video.
- Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/
samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or
move, carry subtitles, upgrade-replace a worse copy.
- Library Organization settings: editable $token path templates + toggles
(transfer mode, verify, replace, carry subs, save artwork, write NFO,
download subtitles + langs). Stored in video.db; matches the music File
Organization section's look.
- Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart,
clearlogo, season posters) from on-demand TMDB detail, and external .srt from
OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail
bypasses the owned->library redirect so they enrich too.
- Import page: surfaces import_failed downloads, resolve by hand (library-first
-> TMDB picker -> force-place) or dismiss; fires a library refresh on place.
- "Grab whole season": episode-level batch (reuses searchInto + _autoPick).
- Brutalist redesign of the download modal sources + result cards.
All new logic has seam-level tests (pure parsers/planners + injected I/O);
sidecars/subtitles are best-effort and never break an import.
Mirrors the music flow: a finished download batch → refresh the media server → (after
it indexes) pull the new media into the DB — so a downloaded movie/episode shows as
owned without waiting for the 6h scheduled scan.
Two event-based system automations (owned_by='video'):
- 'Auto-Scan Video After Downloads' (video_batch_complete → video_scan_server)
- 'Auto-Update Video Database After Scan' (video_library_scan_completed → video_update_database)
Pieces:
- core/video/download_events.py: a callback registry (core/video can't import the
engine — isolation). The monitor publishes batch-complete; web_server bridges it to
automation_engine.emit('video_batch_complete', …), like music's web_scan_manager.
- download_monitor: fires the batch-complete event once, when the last in-flight
download finishes (none queued/downloading/searching left).
- video_scan_server handler (stage 1): refresh server, wait a debounce for indexing,
then emit 'video_library_scan_completed' (mirrors music's time-based completion).
- video_update_database handler (stage 2): incremental read (newest-first, stop after
25 consecutive known — same as music).
- blocks: 2 video triggers + 2 video actions (scope='video'); registration; seeds.
kettui: seam tests for both handlers (refresh/wait/emit, incremental read, error paths),
the event registry (idempotent register, isolated failures), and the monitor (fires once
on last completion, never while work remains). 298 automation tests + isolation green.
When a grabbed release fails (transfer error / peer-cancel / never lands), the engine
now retries instead of giving up — the music-style depth:
- Grab stores the OTHER accepted results as a retry pool + the search context (schema
v16: candidates / search_ctx / tried_queries / tried_files / attempts).
- core/video/retry.py (pure, tested): plan_retry() → try the next-best candidate; when
the pool is dry, next_query() generates an ALTERNATE query (movie: drop the year; TV:
numbering variants) to re-search; budget MAX_ATTEMPTS=6. merge_candidates dedupes
against already-tried releases.
- Monitor: on failure, _fail_or_retry hops to the next candidate inline; if none, flips
the row to a new 'searching' state and a background requery thread re-searches the
alternate query, evaluates against the profile, and starts the best fresh hit — or
marks failed once truly exhausted. 'searching' rows are owned by their thread.
- Page: 'Searching' status (Trying another release…) + a 'Nx' attempt badge.
16 tests (retry engine + candidate-retry transition); ruff clean on touched files.
Fixes the 'cancelled but still shows running' stuck bug and adds real depth:
- classify_state now distinguishes 'cancelled' from 'failed'.
- Monitor is robust to slskd forgetting a transfer: if it's gone, it first tries to
complete from the FILE on disk (survives the music 'Clean Completed Downloads'
auto-clear), else counts misses and fails the row after ~8 polls instead of hanging
on 'downloading' forever. Cancelled transfers → cancelled status.
- POST /downloads/cancel (slskd DELETE transfer + mark cancelled) and /downloads/retry
(re-grab the same release). get_video_download(id) added; clear includes cancelled.
process_download stays pure (fs/slskd injected); 15 tests, ruff + guard clean.
Phase B (page: tabs/queue/history/cancel+retry buttons) next.
Phase 2 — the engine:
- core/video/download_monitor.py: a daemon thread polls slskd for active video
downloads, updates progress, and on completion MOVES the file from the shared
download folder into the per-type library folder + marks it completed. The
per-download decision (process_download) is pure (fs + slskd injected) — 6 tests.
- POST /downloads/grab: validates (Soulseek-only v1), resolves the target library by
kind, starts the slskd download, records the row, lazily starts the monitor.
- GET /downloads/active (list + ensure monitor running) · POST /downloads/clear
(drop finished).
14 tests, isolation guard + ruff clean. Grab button + Downloads page next.