Skip to content

feat(media-server): opt-in author → playlist season TV library export - #423

Open
franklioxygen wants to merge 2 commits into
masterfrom
feat/issue411-media-library-export
Open

feat(media-server): opt-in author → playlist season TV library export#423
franklioxygen wants to merge 2 commits into
masterfrom
feat/issue411-media-library-export

Conversation

@franklioxygen

Copy link
Copy Markdown
Owner

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:

media-library/
└── Kurzgesagt/
    ├── tvshow.nfo
    ├── poster.jpg
    ├── Season 01/
    │   ├── season.nfo
    │   ├── S01E001 - Human Origins.mp4
    │   ├── S01E001 - Human Origins.nfo
    │   └── S01E001 - Human Origins-thumb.jpg
    └── Season 02/
        ├── season.nfo
        └── S02E001 - Ants.mp4

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.

MyTube Media server
Source channel / author one show
Source-backed playlist collection one numbered season
(playlist, video) membership one episode occurrence
Video in no source playlist Season 00, shown as Specials

Invariants

  • Originals are never touched. The mirror is derived; filename settings do not control
    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.
  • Numbering is allocated once and never changes. A playlist keeps its season number
    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.
  • One video in several playlists becomes one occurrence per season, each with its own
    media link, NFO, and uniqueid, so a media server does not collapse them.
  • Cleanup can only ever delete what MyTube generated. Every generated path is recorded in
    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 is
pure and testable without a filesystem or a database:

Module Responsibility
identity.ts Platform/URL/author normalization, identity precedence, mirror-safe naming
catalogRepository.ts / artifactLedger.ts Narrow DB access; no Drizzle tables escape
catalogReconciler.ts Converges shows, season attachments, and occurrences in one transaction
hierarchyPlanner.ts Pure catalog snapshot → the exact expected file set
mediaMaterializer.ts / hierarchyMaterializer.ts Ledger-gated filesystem work, per-show failure isolation, sweeping
playlistTvSync.ts The three entry points (video, collection, full rebuild)

syncService.ts dispatches by layout — the historical implementation moved into clearly
named internal functions with the exported names unchanged. jobService.ts gained phases and
counts 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.

pendingCollectionLink is threaded through the downloaders so a playlist download is
exported straight into its real season and never appears briefly under Specials.

Migration

0028 creates the three catalog tables. Its collections columns and the two indexes built
on them are added by ensureMediaServerExportTables() at startup instead — SQLite cannot add
a 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_id columns already
use. 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

Check Result
backend typecheck / tests / build pass — 228 files, 3063 tests
frontend typecheck / lint / tests / build pass — 181 files, 1806 tests
runMigrations() against a real existing database pass (0028 recorded, self-heal applied)

playlistTv.integration.test.ts builds the design's fixture — one author, two playlists, one
video 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:

  • Clean-library Jellyfin import: one show, Seasons 00/01/02, the duplicated video
    playable from both seasons, episode order, plots, poster.
  • Plex NFO Agent smoke test: seasons and episode placement must come out right from
    the directory names and SxxExxx tokens even though season.nfo is largely ignored.

Docs

README storage table, documents/{en,zh}/media-storage-and-naming.md §9, and
documents/{en,zh}/docker-guide.md (how the existing ./uploads bind mount already exposes
the library, a read-only media-server mount example, and the two failure modes: double-importing
videos/, and splitting uploads/ across filesystems and losing hard links). Changelog entry
included; 24 new keys across all ten locales.

🤖 Generated with Claude Code

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>
@codacy-production

codacy-production Bot commented Aug 28, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 237 complexity

Metric Results
Complexity 237

View in Codacy

🟢 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

View coverage diff in Codacy

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.

@franklioxygen
franklioxygen marked this pull request as ready for review August 28, 2026 20:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread backend/src/services/mediaServerExport/catalogReconciler.ts Outdated
Comment thread backend/src/services/mediaServerExport/hierarchyPlanner.ts
…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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-28T21:02:57.025590Z 23cc460 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +414 to +419
if (!options.pendingCollectionLink) {
syncPlaylistTvForVideo(video, {
mode,
copyFallback: getMediaServerCopyFallback(),
rawSourceInfo: options.rawSourceInfo,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +206 to +213
if (
tracked &&
tracked.sourceAbsolutePath === sourceAbsolutePath &&
tracked.sourceSize === sourceStats.size &&
tracked.sourceMtimeMs === Math.floor(sourceStats.mtimeMs) &&
mirrorPathExists(absolutePath)
) {
return { changed: false, materialization: tracked.materialization };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +270 to +276
for (const artifact of scopedArtifacts) {
if (artifact.assignmentId && assignmentIds.has(artifact.assignmentId)) {
removeTrackedArtifact(artifact.relativePath);
}
}
for (const assignment of assignments) {
deleteEpisodeAssignment(assignment.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

nfo入库媒体库问题

1 participant