Relocating a title must rewrite subtitle_files paths #237
Closed
opened 2026-08-25 10:02:30 +01:00 by naps62-yolo
·
6 comments
No Branch/Tag Specified
main
translation
qbit-2
docker
bugs
blitz/subtitles
fix/255-provider-reorder
blitz/feedback-3
subtitles/251-icon-vocab
subtitles/237-relocate-subs
fix/248-title-move-creates-root
blitz/feedback-2
issue/227-abandoned-pack-visible
issue/245-backoff-anchor
issue/240-design-coherence
issue/211-waived-rule
issue/232-plainer-words
issue/246-policy-reclassify
issue/244-root-slash-stored
issue/231-settings-rows
issue/241-reclassify-on-root-change
issue/239-failure-window
issue/230-icon-only
issue/243-root-path-edges
issue/238-attention-liveness
issue/229-back-button
issue/236-root-path-move
issue/226-attention-threshold
issue/228-root-move
blitz/size-bands
size-bands/209-runtime-scale
size-bands/210-size-waiver
size-bands/208-runtime-design
blitz/reliability
reliability/155-sqlx-migrations
reliability/176-refresh-on-add
removal-nav/175-removal-controls
removal-nav/174-season-file-removal
removal-nav/172-search-titles
removal-nav/173-homepage-library
removal-nav/171-untrack-clears
removal-nav/170-chip-align
removal-nav/169-design-amendment
feedback/167-deck-autosearch
feedback/165-chip-colour
feedback/166-header-rail
feedback/163-drop-cast
feedback/164-control-geometry
feedback/162-season-ordering
feedback/161-search-episodes
feedback/160-autotrack-seed
feedback/159-design-amend
blitz/rich-metadata
rich/151-grid
rich/148-search-rows
rich/150-series-meta
rich/149-movie-page
rich/147-search-art
rich/156-rating-null
rich/146-meta-api
rich/145-poster-cols
rich/144-trailer
rich/143-meta-detail
rich/142-design-amend
blitz/153-empty-episode-titles
blitz/152-deck-routes
blitz/141-season-vanished-api
blitz/129-series-detail-view
blitz/140-attention-episode-numbers
blitz/137-season-removals
blitz/133-attention-tv-lanes
blitz/139-movie-id-episode-guard
blitz/134-stale-attention-queue
blitz/124-rss-episode-matching
blitz/136-imdb-series-lookup
blitz/132-manual-commands
blitz/128-series-delete-files
blitz/131-status-seasons-input
blitz/138-plural-season-range
blitz/135-pack-name-parse
blitz/122-upstream-removals
blitz/130-add-series-search
blitz/121-series-refresh
blitz/127-unified-search-tv
blitz/123-episode-matching
blitz/126-tv-attention
blitz/119-tracked-rule
blitz/125-season-deck
blitz/120-tvdb-id
blitz/118-season-zero
blitz/117-design-amend
issue/115-avail-search
issue/112-name-truncate
issue/111-score-colors
No results found.
Labels
Clear labels
area/api
area/ci
area/compat
area/core
area/daemon
area/db
area/dl
area/indexer
area/infra
area/meta
area/parse
area/probe
area/subs
area/web
difficulty/easy
difficulty/hard
difficulty/moderate
difficulty/trivial
phase/1-skeleton
phase/2-logic
phase/3-sourcing
phase/4-movies
phase/5-ui
phase/6-tv
phase/7-people
phase/8-compat
phase/9-subtitles
type/bug
type/chore
type/feature
type/test
arr-api
CI and test harness
arr-compat
arr-core
arr-daemon
arr-db
arr-dl
arr-indexer
workspace and tooling
arr-meta
arr-parse
arr-probe
arr-subs crate: providers, extraction, translation, sync
web/
bounded, obvious approach, few files
subtle correctness or cross-cutting
multiple files, judgement, an interface to design
one file, mechanical, no design decisions
workspace, CI, config, database, empty API and SPA
parsing and policy engine, pure, no network
TMDB and Prowlarr, read-only
movies end to end
search, buckets, library views, queues
seasons, episodes, tracking, derived status
owner tags and notifications
Jellyseerr shim
build order: subtitles, DESIGN.md §15
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: yolo/arr#237
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
crates/arr-api/src/relocate.rs, added by #228, rewritesmedia_files.pathwhen a title's folder is renamed into a new root. It does not touch
subtitle_files.path, because that table does not exist on this branch — thesubtitles work lives on
blitz/subtitlesand has not merged yet.The sidecars themselves survive a move: §15 puts them inside the §7.4 title
folder, so the directory rename carries them. It is the rows that go stale, and
they go stale silently — nothing fails, the paths just stop describing the disk.
This is a merge-order hazard, not a bug on either branch today. Whichever of
blitz/subtitlesandblitz/feedback-2lands second is the one that has toclose this, and neither branch's tests will catch it, since each is green in
isolation.
Fix:
title_filesinrelocate.rsgathersmedia_filesrows for the title;it needs to gather the
subtitle_filesrows under the same folder too, andrewrite_rowsneeds to update them in the same transaction. The rename planningdoes not change — the sidecars are already inside the folder being renamed.
Add a test that a movie with a subtitle sidecar moves and both the
media_filesand
subtitle_filespaths follow.From the Subtitles milestone driver. Verified against both branches; not taking this issue while it sits in Feedback pass 2.
Confirmed on
origin/blitz/feedback-2:relocate.rsmentionssubtitle_fileszero times,title_files()selects onlymedia_files,rewrite_rows()updates onlymedia_files, and that branch's migrations stop at 0026 — so the table genuinely is not there yet. The hazard is real.One correction to something I said on #228, in case it inflated the scope here. I claimed
claim_pathand #222's one-sidecar invariant both reason aboutsubtitle_files.path. The invariant does not:It is keyed on
(media_file_id, language); the path only has to be non-null. A relocate cannot break it, and it masks the staleclaim_path— a second grab for an already-satisfied language is refused by the index regardless of what the path says.What actually breaks with stale rows, all of it silent:
DELETE /api/subtitles/{id}(#218) unlinks the old path, finds nothing, reports success. The sidecar stays on disk and its row is gone. Worst of the three because it looks like it worked.subtitle_paths()incrates/arr-api/src/series.rs— season and episode deletion resolves sidecars from this column, so the same no-op at folder scope.sidecar_for()incrates/arr-db/src/subtitles.rs— the reconcile loop's translation-source lookup. A stale hit reads a file that is not there; a miss re-extracts an embedded track it already had.All three read the same column, so the fix as described covers them.
The Jellyfin half is already done —
relocate.rscalls the refresh.JellyfinClientlives inarr_api::jellyfinsince #195 moved it out ofarr-daemon.Subtitles is 39/39 closed at
blitz/subtitlesd7e9f71, but not merging yet — the operator is running another round of fixes — so assume feedback-2 may land first.Confirmed against
blitz/subtitlesby the session that owns it. Three concretethings break with stale
subtitle_files.pathrows, all silently, and all readthe same column this issue rewrites:
DELETE /api/subtitles/{id}(#218) unlinks the old path, finds nothing, andreports success. The real sidecar stays on disk and its row is gone. Worst of
the three, because it looks like it worked.
subtitle_paths()incrates/arr-api/src/series.rs— season and episodedeletion resolves sidecars to unlink from this column, so the same silent
no-op at folder scope.
sidecar_for()incrates/arr-db/src/subtitles.rs— the reconcile loop'stranslation-source lookup. A stale hit means translate reads a file that is
not there; a miss means it re-extracts an embedded track it already had.
Not affected, contrary to what was folded into #228: the
#222one-sidecarinvariant does not reason about
path. Its index is keyed on(media_file_id, language)and only requirespath IS NOT NULL, so a relocatecannot violate it. It also masks the stale
claim_path— a second grab for analready-satisfied language is refused by the index whatever the path says.
Also already handled and not part of this issue:
relocate.rscalls the §7.5Jellyfin refresh on a successful move.
Migration numbering when both branches land: 0024, 0025 and 0027-0029 belong to
the subtitles branch, 0026 to this one. No collision.
Merge recipe for whoever lands second, agreed between the two blitz sessions
and verified with
git merge-tree --write-treeon both heads (merge base405358b). Recorded here because this issue is the one that spans bothbranches.
Exactly two files conflict. Everything else auto-merges, including
arr-core/src/lib.rs,arr-core/src/policy.rs,arr-daemon/src/import.rs,web/src/main.ts,web/src/releases.tsandDESIGN.md.crates/arr-api/src/state.rs— take the subtitles side wholesale. It is astrict superset: every line
blitz/feedback-2adds overmainis alreadypresent there, checked line by line, plus the subtitle state on top. Do not
hand-merge it.
crates/arr-daemon/src/main.rs— the only genuine resolution. Keepapi_state(&config, &database, api_jellyfin, &translators)and theseparate
jellyfin_client(config): the reconcile loop'sImportActionneedsone client and
AppStateneeds the other. Keep theapi_statehelper —it was extracted because
run()hits clippy'stoo_many_linesonce twobuilder calls land on it, so inlining the chain back fails
cargo clippy -- -D warnings.Justfile— take the subtitles side. It adds--all-featuresto bothlintand
test; without it the gate never compiles the feature-gated translationbackends at all.
crates/arr-api/src/jellyfin.rs— both branches moved it fromarr-daemonindependently and the blob is identical (
e064d9a), so the add/add resolvesitself.
DESIGN.md— auto-merges. The hunks are at line 488 on one side and 666, 748and 768 on the other (§11, §13, §15). Leave it alone.
Taking this, per the protocol agreed with the Feedback pass 2 driver: feedback-2 merged to main first (
90efeaa), so #237 moves to Subtitles rather than being duplicated.Confirmed it is now a live bug rather than a hypothetical: after merging main into
blitz/subtitles(34e5663),crates/arr-api/src/relocate.rsis present and still mentionssubtitle_fileszero times, whilesubtitle_filesnow exists on the same branch. A relocate rewritesmedia_files.pathand leaves the sidecar rows pointing at the old folder.The three readers that go silently wrong, repeated here so the implementer does not have to dig them out of the thread:
DELETE /api/subtitles/{id}(#218) unlinks the old path, finds nothing, reports success — the sidecar stays on disk and its row is gone.subtitle_paths()incrates/arr-api/src/series.rs— season and episode deletion resolves sidecars from the same column.sidecar_for()incrates/arr-db/src/subtitles.rs— the reconcile loop translation-source lookup.Not affected: #222's one-sidecar index is keyed
(media_file_id, language) WHERE path IS NOT NULL, so a relocate cannot violate it.Scope clarification from the operator, after reading
relocate.rson the merged branch: this is rows-only. Do not move any files.relocate_fileslooks per-file but is not.title_target(root, path)returns the first path component under the root — the §7.4 title folder — and the loop dedupes on it:So twelve episodes in one season folder produce one
fs::renameof the directory, not twelve file moves. Sidecars live inside that folder (§15), so the rename already carries them. After a relocate the disk is correct and only the database is wrong.Two things that follow for the implementation:
Adding subtitle rows to the existing
fileslist creates no extra renames. A sidecar path resolves throughtitle_targetto the same title folder, so the dedupe swallows it. That is safe, but it is not sufficient on its own — see 2.rewritescannot be reused as-is. It isVec<(i64, String)>consumed byrewrite_rows, which runsUPDATE media_files SET path = ? WHERE id = ?. A subtitle row id fed into that would update the wrong table, or nothing. The rewrite set needs to carry which table a row belongs to — a second vec, or a tagged enum — andrewrite_rowsneeds the matchingUPDATE subtitle_files SET path = ? WHERE id = ?in the same transaction.The path arithmetic is identical to the media_files case:
strip_prefix(old_root)then joinnew_root. Sidecars are always inside the title folder, so there is no case where a subtitle path sits outside the root and needs the "not ours to move" warning branch.Also worth keeping: the undo path. If the transaction fails after the rename,
Relocation::undoputs the directory back — which restores the sidecars too, since they moved with it. Nothing extra to undo for subtitles.Implemented in
crates/arr-api/src/relocate.rs:title_filesandroot_filesnow also gathersubtitle_filesrows for the moving title/root (joined throughmedia_file_id, filtered topath IS NOT NULLso embedded tracks with no on-disk file are skipped), and each entry in the rewrite set is tagged with which table it belongs to (FileTable::Media/FileTable::Subtitle) sorewrite_rowsissues the matchingUPDATE media_filesorUPDATE subtitle_filesinside the same transaction as the row change. No filesystem behavior changed — sidecars already rode along in the folder rename, only the rows were stale. Added a test inmovies.rs,changing_root_moves_the_subtitle_row_with_its_sidecar, that inserts asubtitle_filesrow alongside the existing sidecar fixture, moves the movie's root, and asserts the subtitle row's path follows the file and describes the disk. Regenerated.sqlxoffline query cache for the new/changed queries viajust db-prepare.Branch:
subtitles/237-relocate-subs.just cipassed: 778/778 tests, fmt/clippy/machete/web-check all green.Findings
None outside this issue's scope. Series and root-path relocations share the same
title_files/root_files/rewrite_rowscode paths as the movie case tested here, so they're covered by the same fix without separate per-caller changes.