feat(media-server): opt-in author → playlist season TV library export - #423
feat(media-server): opt-in author → playlist season TV library export#423franklioxygen wants to merge 2 commits into
Conversation
Adds a second media-server export layout that builds a managed TV library
at backend/uploads/media-library, where each author is a show and each
source-backed playlist is a numbered season:
media-library/Kurzgesagt/tvshow.nfo
/poster.jpg
/Season 01/season.nfo
/S01E001 - Human Origins.mp4
/S01E001 - Human Origins.nfo
The existing adjacent-sidecar layout stays the default; nothing changes
until the user opts in.
Design invariants:
- Originals are never renamed or moved, and filename settings do not
control the mirror. Episodes are hard links, with an optional copy
fallback for filesystems without them.
- Season and episode numbers are allocated once and are immutable. An
upstream playlist reorder only records a new sourcePosition.
- A video in several playlists gets one occurrence per season, each with
its own media link, NFO, and uniqueid. Videos in no playlist land in
Season 00 as Specials.
- Every generated file is recorded in an ownership ledger. Cleanup and
stale-file sweeping delete only ledger-owned paths, so an original can
never be reached and a user-placed file is preserved and reported as a
collision instead of overwritten.
Migration 0028 creates the catalog tables; its collection columns and
their indexes are added by ensureMediaServerExportTables() at startup,
because SQLite cannot add a column idempotently and one failed statement
rolls the whole migration back. Everything the SQL file emits is
idempotent, so it applies to a fresh database and to one that already
carries these tables.
Refs #411
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 237 |
🟢 Coverage 89.60% diff coverage · +0.19% coverage variation
Metric Results Coverage variation ✅ +0.19% coverage variation (-1.00%) Diff coverage ✅ 89.60% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (54229ef) 30139 25881 85.87% Head commit (23cc460) 31001 (+862) 26679 (+798) 86.06% (+0.19%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#423) 904 810 89.60% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3714a04998
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…nto the mirror Two review findings on the playlist-TV export: Episode allocation rebuilt its per-season state from the live assignment rows alone, so a number freed in one reconciliation was handed to different content in the next — exactly what the never-recycled contract forbids, and enough to make a media server graft a new episode onto a removed one's metadata. Deleting an assignment now tombstones its number in `media_server_retired_episodes` (migration 0029, plus the same startup self-heal the rest of the catalog uses), and the allocator seeds itself from those tombstones. `nfo_and_source_json` planning called `buildSourceInfoEnvelope(video)` without the raw yt-dlp object the caller had already supplied, so a fresh download's `.info.json` set `rawSourcePreserved: false` and dropped every extractor field the adjacent layout keeps. The raw info now travels on the catalog snapshot into the planner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23cc46016f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!options.pendingCollectionLink) { | ||
| syncPlaylistTvForVideo(video, { | ||
| mode, | ||
| copyFallback: getMediaServerCopyFallback(), | ||
| rawSourceInfo: options.rawSourceInfo, | ||
| }); |
There was a problem hiding this comment.
Forward raw metadata through the deferred collection sync
When pendingCollectionLink is true, this branch intentionally skips the only sync that still has options.rawSourceInfo; the subsequent collection hook accepts only collectionId and videoId, so that metadata is irretrievably lost. Fresh evidence in the current revision is that the planner now reads rawInfoByVideoId, but the deferred path never populates that map. Consequently, fresh playlist downloads in nfo_and_source_json mode still generate synthesized .info.json files without extractor-specific fields; pass the raw object through the collection-link hook.
Useful? React with 👍 / 👎.
| if ( | ||
| tracked && | ||
| tracked.sourceAbsolutePath === sourceAbsolutePath && | ||
| tracked.sourceSize === sourceStats.size && | ||
| tracked.sourceMtimeMs === Math.floor(sourceStats.mtimeMs) && | ||
| mirrorPathExists(absolutePath) | ||
| ) { | ||
| return { changed: false, materialization: tracked.materialization }; |
There was a problem hiding this comment.
Verify tracked destination files before declaring them unchanged
If a tracked mirror file is truncated, corrupted, or replaced with another regular file while its source remains unchanged, this condition checks only the source fingerprint and destination existence, then reports the artifact unchanged without inspecting the destination. Rebuilds therefore cannot repair damaged copied media, artwork, subtitles, or broken hard links; validate the destination size/content or hard-link inode as appropriate before taking this fast path.
Useful? React with 👍 / 👎.
| for (const artifact of scopedArtifacts) { | ||
| if (artifact.assignmentId && assignmentIds.has(artifact.assignmentId)) { | ||
| removeTrackedArtifact(artifact.relativePath); | ||
| } | ||
| } | ||
| for (const assignment of assignments) { | ||
| deleteEpisodeAssignment(assignment.id); |
There was a problem hiding this comment.
Retire episode assignments despite artifact cleanup failures
If removing any artifact fails—for example because the destination was replaced by a symlink or became undeletable—this call throws before any assignment reaches deleteEpisodeAssignment. The outer removal wrapper logs and swallows the error, after which deleteVideo deletes the video row and SQLite cascades the assignments away without writing their tombstones; a later reconciliation can then reuse those episode numbers for different content. Accumulate cleanup failures while ensuring assignment retirement still runs before the video deletion continues.
Useful? React with 👍 / 👎.
Closes #411 (pending the manual media-server import — see Not done yet below).
What this adds
An opt-in second media-server export layout. Settings → Media server export layout →
Author → playlist seasons makes MyTube build a managed TV library at
backend/uploads/media-library:Add that folder to Jellyfin/Plex/Emby as a Shows library. The existing adjacent-sidecar
layout remains the default — nothing changes for anyone who does not opt in.
(playlist, video)membershipInvariants
it. Episodes are hard links, so the library normally costs no extra disk space. A copy
fallback (on by default) keeps it working without hard links, and the rebuild summary
reports linked vs. copied.
forever; deleting one never frees the number. Episode numbers come from the position a
membership had at first import — an upstream reorder only records a new
sourcePosition.media link, NFO, and
uniqueid, so a media server does not collapse them.an ownership ledger; cleanup and stale-file sweeping consult it, never a filename pattern
or an XML marker. A file the user placed in the mirror is preserved and reported as a
collision; a symlink is refused rather than followed.
Architecture
New modules under
backend/src/services/mediaServerExport/, kept separable so planning ispure and testable without a filesystem or a database:
identity.tscatalogRepository.ts/artifactLedger.tscatalogReconciler.tshierarchyPlanner.tsmediaMaterializer.ts/hierarchyMaterializer.tsplaylistTvSync.tssyncService.tsdispatches by layout — the historical implementation moved into clearlynamed internal functions with the exported names unchanged.
jobService.tsgained phases andcounts while keeping every pre-existing field for API compatibility.
Lifecycle hooks converge the mirror automatically: collection link/unlink/rename (after any
file move, so a hard link is never made to a source about to relocate), video
delete (before the row is deleted, because the cascade would strand the ledger's proof of
ownership), and original-file relocation.
pendingCollectionLinkis threaded through the downloaders so a playlist download isexported straight into its real season and never appears briefly under Specials.
Migration
0028creates the three catalog tables. Itscollectionscolumns and the two indexes builton them are added by
ensureMediaServerExportTables()at startup instead — SQLite cannot adda column idempotently, and one failing statement rolls a Drizzle migration back and leaves it
unrecorded, retrying on every boot. This is the same pattern the pre-existing
collections.source_platform/source_type/source_mid/source_idcolumns alreadyuse. Everything the SQL file does emit is idempotent, so it applies cleanly to a fresh
database and to one that already carries these tables from an earlier build.
Verification
runMigrations()against a real existing databaseplaylistTv.integration.test.tsbuilds the design's fixture — one author, two playlists, onevideo duplicated across both, one unassigned — and drives the real reconciler, planner,
and materializer against a real temporary filesystem and a real migrated SQLite database. It
asserts the complete directory listing, parses every NFO with an XML parser, and covers
hard-link inode sharing, idempotence (no changed inode or mtime on a second run), relink
after a source replacement, NFO rewrite without media churn on a title edit, sweeping an
occurrence a video left, untracked-destination refusal, copy fallback both enabled and
disabled, symlink refusal, and cleanup preserving originals while keeping the numbering.
Not done yet
Per the design's caution #18 the issue is not closeable on automated evidence alone. Still
outstanding, because they need running instances:
playable from both seasons, episode order, plots, poster.
the directory names and
SxxExxxtokens even thoughseason.nfois largely ignored.Docs
README storage table,
documents/{en,zh}/media-storage-and-naming.md§9, anddocuments/{en,zh}/docker-guide.md(how the existing./uploadsbind mount already exposesthe library, a read-only media-server mount example, and the two failure modes: double-importing
videos/, and splittinguploads/across filesystems and losing hard links). Changelog entryincluded; 24 new keys across all ten locales.
🤖 Generated with Claude Code