From 38a1ccfe090835626515e0e2f390c01aa6bccf1b Mon Sep 17 00:00:00 2001 From: dialmaster Date: Fri, 2 Oct 2026 12:47:42 -0700 Subject: [PATCH 01/12] feat(tv): add groundwork for TV show layouts First phase of saving channels as TV shows. Nothing changes for users yet: every library folder still reports the videos layout, so the TV code paths only run in tests. - Video deletion and the orphan sweep choose their cleanup by the library folder's layout. In a TV folder they remove an emptied season folder, then the show folder once only metadata and art remain, and skip a folder they cannot list. - The post-processor writes the channel poster and backdrop to the folder the move wrote to, instead of working it out from folder depth. - Add sanitizeFilenameLikeYtDlp, a port of yt-dlp's title sanitization that cuts to a byte limit between whole characters, with fixtures generated from the dev image's yt-dlp. - Add tvShows modules for library layouts, episode file names, date numbering (UTC MMDDHHMM, bumped on collision, adopting "Plex TV Series" preset names) and routing, plus a corpus of real channel titles for title-pattern shows. Refs: #175 --- CLAUDE.md | 3 +- docs/DEVELOPMENT.md | 14 + scripts/generate-sanitizer-fixtures.js | 137 +++ .../__tests__/videoDeletionModule.test.js | 188 +++- .../videoDownloadPostProcessFiles.test.js | 20 + .../fixtures/ytdlpTitleSanitizer.json | 186 ++++ .../filesystem/__tests__/sanitizer.test.js | 70 +- .../__tests__/showFolderCleanup.test.js | 204 ++++ server/modules/filesystem/constants.js | 32 +- server/modules/filesystem/index.js | 7 +- server/modules/filesystem/sanitizer.js | 86 +- .../modules/filesystem/showFolderCleanup.js | 182 ++++ .../__tests__/channelTitleCorpus.test.js | 51 + .../tvShows/__tests__/dateNumbering.test.js | 212 +++++ .../tvShows/__tests__/episodeNaming.test.js | 93 ++ .../fixtures/channelTitleCorpus.json | 899 ++++++++++++++++++ .../tvShows/__tests__/libraryLayouts.test.js | 8 + .../modules/tvShows/__tests__/routing.test.js | 100 ++ server/modules/tvShows/dateNumbering.js | 179 ++++ server/modules/tvShows/episodeNaming.js | 80 ++ server/modules/tvShows/index.js | 21 + server/modules/tvShows/libraryLayouts.js | 24 + server/modules/tvShows/routing.js | 67 ++ server/modules/videoDeletionModule.js | 59 +- .../modules/videoDownloadPostProcessFiles.js | 10 +- 25 files changed, 2913 insertions(+), 19 deletions(-) create mode 100644 scripts/generate-sanitizer-fixtures.js create mode 100644 server/modules/filesystem/__tests__/fixtures/ytdlpTitleSanitizer.json create mode 100644 server/modules/filesystem/__tests__/showFolderCleanup.test.js create mode 100644 server/modules/filesystem/showFolderCleanup.js create mode 100644 server/modules/tvShows/__tests__/channelTitleCorpus.test.js create mode 100644 server/modules/tvShows/__tests__/dateNumbering.test.js create mode 100644 server/modules/tvShows/__tests__/episodeNaming.test.js create mode 100644 server/modules/tvShows/__tests__/fixtures/channelTitleCorpus.json create mode 100644 server/modules/tvShows/__tests__/libraryLayouts.test.js create mode 100644 server/modules/tvShows/__tests__/routing.test.js create mode 100644 server/modules/tvShows/dateNumbering.js create mode 100644 server/modules/tvShows/episodeNaming.js create mode 100644 server/modules/tvShows/index.js create mode 100644 server/modules/tvShows/libraryLayouts.js create mode 100644 server/modules/tvShows/routing.js diff --git a/CLAUDE.md b/CLAUDE.md index 3910fcb32..e78895246 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - Playlist batch/following endpoints: `GET /api/playlists/:playlistId/download-preview` previews publication/position choices; `POST /api/playlists/:playlistId/download-batch` queues explicit ids; `POST /api/playlists/:playlistId/following` sets up/resumes following or resets the starting point while preserving pause state. All require auth and an enabled playlist. Swagger annotations in `routes/playlists.js` describe validation and errors. - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). -- `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`). Good example of the sub-module aggregator pattern. +- `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`//Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. +- `modules/tvShows/` (TV show support, in progress; nothing user-visible yet): `libraryLayouts` (layout of each library folder, the main folder `''` or a subfolder name; every folder resolves to `videos` until layouts are stored), `episodeNaming` (`Season NN` folders, `SxxEyy - [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. - `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 722c052ee..80c4b64b6 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -482,6 +482,20 @@ release and runs this suite in the **yt-dlp Cookie Loader Tests** job, required by **All Checks**. The helper imports that executable so the parser follows yt-dlp updates; it does not maintain a separate installation or format parser. +### Filename Sanitizer Fixtures + +`sanitizeFilenameLikeYtDlp` (in `server/modules/filesystem/sanitizer.js`) ports +yt-dlp's title sanitization to JavaScript. Its expected outputs in +`server/modules/filesystem/__tests__/fixtures/ytdlpTitleSanitizer.json` come +from the yt-dlp in the development image, rendered offline from fake info files. +Regenerate them after a yt-dlp update or when adding cases to the corpus in the +script: + +```bash +./scripts/build-dev.sh # if the image is not built yet +node scripts/generate-sanitizer-fixtures.js # optional: pass another image name +``` + ### Frontend Tests ```bash diff --git a/scripts/generate-sanitizer-fixtures.js b/scripts/generate-sanitizer-fixtures.js new file mode 100644 index 000000000..34d3fd439 --- /dev/null +++ b/scripts/generate-sanitizer-fixtures.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Regenerates server/modules/filesystem/__tests__/fixtures/ytdlpTitleSanitizer.json, + * the expected output of sanitizeFilenameLikeYtDlp, by rendering every corpus + * title through the yt-dlp inside the Youtarr image (offline, from fake info + * files). Each title is rendered the way a movie-style filename renders it, + * `%(title)s [%(id)s].%(ext)s` with --windows-filenames, so the fixture holds + * exactly the text yt-dlp puts in front of the ` [id]` suffix. + * + * Usage: node scripts/generate-sanitizer-fixtures.js [image] + * image defaults to youtarr-dev:latest (build it with ./scripts/build-dev.sh) + */ + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_IMAGE = 'youtarr-dev:latest'; +const OUTPUT_PATH = path.join( + __dirname, '..', 'server', 'modules', 'filesystem', '__tests__', 'fixtures', 'ytdlpTitleSanitizer.json' +); +const ID_PREFIX = 'fixture'; +const OUTPUT_TEMPLATE = '%(title)s [%(id)s].%(ext)s'; + +const CORPUS = [ + 'Simple title', + 'AC/DC - Back In Black', + 'Back\\slash title', + 'Title: Subtitle', + 'Live at 12:30:45 tonight', + 'Score 3:2 final', + 'Ratio 16:9:4', + 'Ends with colon:', + '1:2:3', + 'He said "hello"', + '"Fully quoted"', + 'What? Why?!', + '??', + 'Wild*card | pipe <angle> brackets', + 'Mixed /\\:*?"<>| all', + 'Trailing dot.', + 'Trailing dots...', + 'Trailing space ', + ' Leading spaces', + '.hidden start', + '-dash start', + '__double__underscore__', + 'Line one\nLine two', + 'Repeated \n\n\n newlines', + '\nLeading newline', + 'Trailing newline\n', + 'Tab\tseparated', + 'Bell\u0007char', + 'Delete\u007fchar', + 'Zero\u{200b}width space', + 'Emoji 🎮 gaming 🔥🔥', + 'Flag 🇺🇸 and family 👨‍👩‍👧', + 'Math 𝓗𝓮𝓵𝓵𝓸 bold', + 'Fullwidth ABC 123 :?', + 'CJK 日本語のタイトル 中文标题 한국어', + 'Accents café naïve Ærøskøbing', + 'RTL עברית العربية', + 'Percent %(title)s literal', + 'BEYBLADE | Ep.19 Under the Microscope | Ep.20 It\'s All Relative', + 'Hermitcraft 10: Episode 43 - THE LAST DAY! (the end of an era, thanks for watching)', + `${'a'.repeat(62)}??::`, + 'あ'.repeat(30), + `${'a'.repeat(63)}😀tail`, + `${'a'.repeat(62)}éb`, + `${'b'.repeat(61)}日本`, +]; + +function buildEntries() { + return CORPUS.map((title, index) => { + const id = `${ID_PREFIX}${String(index).padStart(4, '0')}`; + return { + id, + title, + extractor: 'youtube', + extractor_key: 'Youtube', + webpage_url: `https://www.youtube.com/watch?v=${id}`, + ext: 'mp4', + duration: 60, + is_live: false, + live_status: 'not_live', + availability: 'public', + formats: [{ format_id: '18', ext: 'mp4', url: 'https://example.invalid/video.mp4', vcodec: 'avc1', acodec: 'mp4a' }], + }; + }); +} + +function runYtDlp(image, entries) { + const script = [ + 'cat > /tmp/entries.json', + `yt-dlp --load-info-json /tmp/entries.json --skip-download --windows-filenames --print filename -o '${OUTPUT_TEMPLATE}'`, + ].join(' && '); + const stdout = execFileSync( + 'docker', + ['run', '--rm', '-i', '--network', 'none', '-e', 'PYTHONIOENCODING=utf-8', '-e', 'LC_ALL=C.UTF-8', + '--entrypoint', 'sh', image, '-c', script], + { input: JSON.stringify(entries), encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'] } + ); + const version = execFileSync( + 'docker', ['run', '--rm', '--network', 'none', '--entrypoint', 'yt-dlp', image, '--version'], + { encoding: 'utf8' } + ).trim(); + return { lines: stdout.split('\n').filter((line) => line.length > 0), version }; +} + +function parseLines(lines, entries) { + if (lines.length !== entries.length) { + throw new Error(`Expected ${entries.length} filenames from yt-dlp, got ${lines.length}`); + } + return lines.map((line, index) => { + const suffix = ` [${entries[index].id}].mp4`; + if (!line.endsWith(suffix)) { + throw new Error(`Line ${index} does not end with ${suffix}: ${line}`); + } + return { input: entries[index].title, expected: line.slice(0, -suffix.length) }; + }); +} + +function main() { + const image = process.argv[2] || DEFAULT_IMAGE; + const entries = buildEntries(); + const { lines, version } = runYtDlp(image, entries); + const fixture = { + description: `Titles rendered by yt-dlp ${version} as "${OUTPUT_TEMPLATE}" with --windows-filenames; ` + + 'expected is the text before the " [id]" suffix. Regenerate with scripts/generate-sanitizer-fixtures.js.', + ytDlpVersion: version, + cases: parseLines(lines, entries), + }; + fs.writeFileSync(OUTPUT_PATH, `${JSON.stringify(fixture, null, 2)}\n`); + console.log(`Wrote ${fixture.cases.length} cases to ${path.relative(process.cwd(), OUTPUT_PATH)}`); +} + +main(); diff --git a/server/modules/__tests__/videoDeletionModule.test.js b/server/modules/__tests__/videoDeletionModule.test.js index d105207cd..96082654b 100644 --- a/server/modules/__tests__/videoDeletionModule.test.js +++ b/server/modules/__tests__/videoDeletionModule.test.js @@ -17,6 +17,8 @@ jest.mock('../storageGuard', () => ({ // beforeEach installs persists across resetModules and only has `promises`, // which fs-extra (required by directoryManager) cannot load against. const { isVideoDirectoryFor } = jest.requireActual('../filesystem/directoryManager'); +const { resolveLibraryFolder, locateEpisodeFolders } = jest.requireActual('../filesystem/showFolderCleanup'); +const { extractSubfolderName } = jest.requireActual('../filesystem/pathBuilder'); describe('VideoDeletionModule', () => { let VideoDeletionModule; @@ -24,6 +26,7 @@ describe('VideoDeletionModule', () => { let mockFs; let mockLogger; let mockFilesystem; + let mockLibraryLayouts; let m3uGenerator; beforeEach(() => { @@ -58,8 +61,20 @@ describe('VideoDeletionModule', () => { cleanupEmptyChannelDirectory: jest.fn().mockResolvedValue(false), cleanupEmptyParents: jest.fn().mockResolvedValue(), isSubfolderDir: jest.fn((name) => name.startsWith('__')), + extractSubfolderName, listSubdirectories: jest.fn().mockResolvedValue([]), - removeDirectoryResilient: jest.fn().mockResolvedValue() + removeDirectoryResilient: jest.fn().mockResolvedValue(), + resolveLibraryFolder, + locateEpisodeFolders, + cleanupEmptyShowFolders: jest.fn().mockResolvedValue({ removedSeason: false, removedShow: false }), + cleanupOrphanShowFolder: jest.fn().mockResolvedValue([]) + }; + + // Every library folder uses the videos layout unless a test says otherwise. + mockLibraryLayouts = { + LAYOUT_VIDEOS: 'videos', + LAYOUT_TV: 'tv', + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos') }; // Mock the models @@ -73,6 +88,7 @@ describe('VideoDeletionModule', () => { })); jest.doMock('../filesystem', () => mockFilesystem); + jest.doMock('../tvShows/libraryLayouts', () => mockLibraryLayouts); // Mock configModule for _tryCleanupChannelDirectory jest.doMock('../configModule', () => ({ @@ -681,6 +697,124 @@ describe('VideoDeletionModule', () => { }); }); + describe('episodes in TV library folders', () => { + const tvIn = (...tvFolders) => mockLibraryLayouts.getLayoutResolver + .mockResolvedValue((libraryFolder) => (tvFolders.includes(libraryFolder) ? 'tv' : 'videos')); + + const episodeRecord = (filePath) => ({ + id: 1, + youtubeId: 'abc123', + filePath, + removed: false, + update: jest.fn().mockResolvedValue() + }); + + test('deletes only the episode files from its season folder', async () => { + tvIn(''); + mockVideo.findByPk.mockResolvedValue( + episodeRecord('/test/output/Show/Season 2024/S2024E01151200 - Title [abc123].mp4') + ); + mockFs.readdir.mockResolvedValue([ + 'S2024E01151200 - Title [abc123].mp4', + 'S2024E01151200 - Title [abc123].nfo', + 'S2024E01151200 - Title [abc123].en.srt', + 'S2024E01151300 - Other [zzz999].mp4', + 'season.nfo' + ]); + + await VideoDeletionModule.deleteVideoById(1); + + expect(mockFs.unlink.mock.calls.map(([filePath]) => filePath)).toEqual([ + '/test/output/Show/Season 2024/S2024E01151200 - Title [abc123].mp4', + '/test/output/Show/Season 2024/S2024E01151200 - Title [abc123].nfo', + '/test/output/Show/Season 2024/S2024E01151200 - Title [abc123].en.srt' + ]); + }); + + test('cleans up the season and show folders in a TV main folder instead of a channel folder', async () => { + tvIn(''); + mockVideo.findByPk.mockResolvedValue( + episodeRecord('/test/output/Show/Season 2024/S2024E01151200 - Title [abc123].mp4') + ); + + await VideoDeletionModule.deleteVideoById(1); + + expect({ + showCleanup: mockFilesystem.cleanupEmptyShowFolders.mock.calls, + channelCleanup: mockFilesystem.cleanupEmptyChannelDirectory.mock.calls + }).toEqual({ + showCleanup: [[{ showDir: '/test/output/Show', seasonDir: '/test/output/Show/Season 2024' }]], + channelCleanup: [] + }); + }); + + test('cleans up the season and show folders in a TV subfolder', async () => { + tvIn('TV Shows'); + mockVideo.findByPk.mockResolvedValue( + episodeRecord('/test/output/__TV Shows/Show/Season 01/S01E20 - Title [abc123].mp4') + ); + + await VideoDeletionModule.deleteVideoById(1); + + expect(mockFilesystem.cleanupEmptyShowFolders).toHaveBeenCalledWith({ + showDir: '/test/output/__TV Shows/Show', + seasonDir: '/test/output/__TV Shows/Show/Season 01' + }); + }); + + test('removes an emptied TV subfolder after removing its last show', async () => { + tvIn('TV Shows'); + mockFilesystem.cleanupEmptyShowFolders.mockResolvedValueOnce({ removedSeason: true, removedShow: true }); + mockVideo.findByPk.mockResolvedValue( + episodeRecord('/test/output/__TV Shows/Show/Season 01/S01E20 - Title [abc123].mp4') + ); + + await VideoDeletionModule.deleteVideoById(1); + + expect(mockFilesystem.cleanupEmptyParents).toHaveBeenCalledWith('/test/output/__TV Shows', '/test/output'); + }); + + test('leaves the TV subfolder alone while the show folder remains', async () => { + tvIn('TV Shows'); + mockFilesystem.cleanupEmptyShowFolders.mockResolvedValueOnce({ removedSeason: true, removedShow: false }); + mockVideo.findByPk.mockResolvedValue( + episodeRecord('/test/output/__TV Shows/Show/Season 01/S01E20 - Title [abc123].mp4') + ); + + await VideoDeletionModule.deleteVideoById(1); + + expect(mockFilesystem.cleanupEmptyParents).not.toHaveBeenCalled(); + }); + + test('uses channel folder cleanup for a movie-style file left in a TV folder', async () => { + tvIn('TV Shows'); + mockVideo.findByPk.mockResolvedValue( + episodeRecord('/test/output/__TV Shows/Channel/Channel - Title [abc123].mp4') + ); + + await VideoDeletionModule.deleteVideoById(1); + + expect({ + showCleanup: mockFilesystem.cleanupEmptyShowFolders.mock.calls.length, + channelCleanup: mockFilesystem.cleanupEmptyChannelDirectory.mock.calls + }).toEqual({ + showCleanup: 0, + channelCleanup: [['/test/output/__TV Shows/Channel', '/test/output', { includeIgnorableFiles: true }]] + }); + }); + + test('uses channel folder cleanup for a season-shaped path in a videos folder', async () => { + tvIn('TV Shows'); + mockVideo.findByPk.mockResolvedValue( + episodeRecord('/test/output/__Kids/Show/Season 01/S01E20 - Title [abc123].mp4') + ); + + await VideoDeletionModule.deleteVideoById(1); + + expect(mockFilesystem.cleanupEmptyShowFolders).not.toHaveBeenCalled(); + }); + }); + describe('deleteVideos', () => { test('should successfully delete multiple videos', async () => { const mockVideo1 = { @@ -2328,8 +2462,13 @@ describe('VideoDeletionModule', () => { cleanupEmptyChannelDirectory: jest.fn().mockResolvedValue(false), cleanupEmptyParents: jest.fn().mockResolvedValue(), isSubfolderDir: jest.fn((name) => name.startsWith('__')), + extractSubfolderName, listSubdirectories: jest.fn().mockResolvedValue([]), - removeDirectoryResilient: jest.fn().mockResolvedValue() + removeDirectoryResilient: jest.fn().mockResolvedValue(), + resolveLibraryFolder, + locateEpisodeFolders, + cleanupEmptyShowFolders: jest.fn(), + cleanupOrphanShowFolder: jest.fn() })); jest.doMock('../configModule', () => ({ directoryPath: null @@ -2452,6 +2591,51 @@ describe('VideoDeletionModule', () => { expect(result.removed).toEqual(['/test/output/RootChannel']); expect(mockFilesystem.cleanupEmptyChannelDirectory).toHaveBeenCalledTimes(2); }); + + describe('TV library folders', () => { + test('sweeps root-level folders as shows when the main folder is TV, and subfolders by their own layout', async () => { + mockLibraryLayouts.getLayoutResolver.mockResolvedValue((folder) => (folder === '' ? 'tv' : 'videos')); + mockFilesystem.listSubdirectories + .mockResolvedValueOnce(['/test/output/Show', '/test/output/__Music']) + .mockResolvedValueOnce(['/test/output/__Music/Empty']); + mockFilesystem.cleanupOrphanShowFolder.mockResolvedValueOnce(['/test/output/Show/Season 2023']); + mockFilesystem.cleanupEmptyChannelDirectory.mockResolvedValueOnce(true); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect({ + removed: result.removed, + showSweeps: mockFilesystem.cleanupOrphanShowFolder.mock.calls, + channelSweeps: mockFilesystem.cleanupEmptyChannelDirectory.mock.calls.map(([dir]) => dir) + }).toEqual({ + removed: ['/test/output/Show/Season 2023', '/test/output/__Music/Empty'], + showSweeps: [['/test/output/Show']], + channelSweeps: ['/test/output/__Music/Empty'] + }); + }); + + test('sweeps the children of a TV subfolder as shows and then the subfolder itself', async () => { + mockLibraryLayouts.getLayoutResolver.mockResolvedValue((folder) => (folder === 'TV Shows' ? 'tv' : 'videos')); + mockFilesystem.listSubdirectories + .mockResolvedValueOnce(['/test/output/__TV Shows']) + .mockResolvedValueOnce(['/test/output/__TV Shows/Show A', '/test/output/__TV Shows/Show B']); + mockFilesystem.cleanupOrphanShowFolder + .mockResolvedValueOnce(['/test/output/__TV Shows/Show A/Season 01', '/test/output/__TV Shows/Show A']) + .mockResolvedValueOnce([]); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect({ + removed: result.removed, + channelSweeps: mockFilesystem.cleanupEmptyChannelDirectory.mock.calls.length, + parentCleanup: mockFilesystem.cleanupEmptyParents.mock.calls + }).toEqual({ + removed: ['/test/output/__TV Shows/Show A/Season 01', '/test/output/__TV Shows/Show A'], + channelSweeps: 0, + parentCleanup: [['/test/output/__TV Shows', '/test/output']] + }); + }); + }); }); describe('module export', () => { diff --git a/server/modules/__tests__/videoDownloadPostProcessFiles.test.js b/server/modules/__tests__/videoDownloadPostProcessFiles.test.js index 9153efd1c..28d3a5fcb 100644 --- a/server/modules/__tests__/videoDownloadPostProcessFiles.test.js +++ b/server/modules/__tests__/videoDownloadPostProcessFiles.test.js @@ -1840,6 +1840,26 @@ describe('videoDownloadPostProcessFiles', () => { expect(movedFiles).toEqual(['Video Title [abc123].mp4']); }); + it.each([ + ['hoisted flat', true], + ['kept in its video folder', false], + ])('writes the channel backdrop into the channel folder the video was %s in', async (_label, skipVideoFolder) => { + const channelBannerCachePath = '/mock/images/channelbanner-channel123.jpg'; + configModule.__setConfig({ + writeChannelPosters: false, + writeVideoNfoFiles: true, + writeBackdropImages: true, + }); + Channel.findOne.mockResolvedValue({ ...trackedChannel, skip_video_folder: skipVideoFolder }); + fs.existsSync.mockImplementation((p) => p === tempJsonPath || p === channelBannerCachePath + || (p.startsWith('/library/') && !p.endsWith('/backdrop.jpg'))); + + await loadModule(); + await settleAsync(); + + expect(fs.copySync).toHaveBeenCalledWith(channelBannerCachePath, '/library/Channel/backdrop.jpg', { overwrite: true }); + }); + it('explicit structure override beats the channel setting', async () => { process.env.YOUTARR_SKIP_VIDEO_FOLDER_OVERRIDE = 'true'; Channel.findOne.mockResolvedValue({ ...trackedChannel, skip_video_folder: false }); diff --git a/server/modules/filesystem/__tests__/fixtures/ytdlpTitleSanitizer.json b/server/modules/filesystem/__tests__/fixtures/ytdlpTitleSanitizer.json new file mode 100644 index 000000000..21fcb5a5b --- /dev/null +++ b/server/modules/filesystem/__tests__/fixtures/ytdlpTitleSanitizer.json @@ -0,0 +1,186 @@ +{ + "description": "Titles rendered by yt-dlp 2026.07.04 as \"%(title)s [%(id)s].%(ext)s\" with --windows-filenames; expected is the text before the \" [id]\" suffix. Regenerate with scripts/generate-sanitizer-fixtures.js.", + "ytDlpVersion": "2026.07.04", + "cases": [ + { + "input": "Simple title", + "expected": "Simple title" + }, + { + "input": "AC/DC - Back In Black", + "expected": "AC⧸DC - Back In Black" + }, + { + "input": "Back\\slash title", + "expected": "Back⧹slash title" + }, + { + "input": "Title: Subtitle", + "expected": "Title: Subtitle" + }, + { + "input": "Live at 12:30:45 tonight", + "expected": "Live at 12_30_45 tonight" + }, + { + "input": "Score 3:2 final", + "expected": "Score 3_2 final" + }, + { + "input": "Ratio 16:9:4", + "expected": "Ratio 16_9_4" + }, + { + "input": "Ends with colon:", + "expected": "Ends with colon:" + }, + { + "input": "1:2:3", + "expected": "1_2_3" + }, + { + "input": "He said \"hello\"", + "expected": "He said "hello"" + }, + { + "input": "\"Fully quoted\"", + "expected": ""Fully quoted"" + }, + { + "input": "What? Why?!", + "expected": "What? Why?!" + }, + { + "input": "??", + "expected": "??" + }, + { + "input": "Wild*card | pipe <angle> brackets", + "expected": "Wild*card | pipe <angle> brackets" + }, + { + "input": "Mixed /\\:*?\"<>| all", + "expected": "Mixed ⧸⧹:*?"<>| all" + }, + { + "input": "Trailing dot.", + "expected": "Trailing dot." + }, + { + "input": "Trailing dots...", + "expected": "Trailing dots..." + }, + { + "input": "Trailing space ", + "expected": "Trailing space " + }, + { + "input": " Leading spaces", + "expected": " Leading spaces" + }, + { + "input": ".hidden start", + "expected": ".hidden start" + }, + { + "input": "-dash start", + "expected": "-dash start" + }, + { + "input": "__double__underscore__", + "expected": "__double__underscore__" + }, + { + "input": "Line one\nLine two", + "expected": "Line one Line two" + }, + { + "input": "Repeated \n\n\n newlines", + "expected": "Repeated newlines" + }, + { + "input": "\nLeading newline", + "expected": "Leading newline" + }, + { + "input": "Trailing newline\n", + "expected": "Trailing newline" + }, + { + "input": "Tab\tseparated", + "expected": "Tabseparated" + }, + { + "input": "Bell\u0007char", + "expected": "Bellchar" + }, + { + "input": "Deletechar", + "expected": "Deletechar" + }, + { + "input": "Zero​width space", + "expected": "Zero​width space" + }, + { + "input": "Emoji 🎮 gaming 🔥🔥", + "expected": "Emoji 🎮 gaming 🔥🔥" + }, + { + "input": "Flag 🇺🇸 and family 👨‍👩‍👧", + "expected": "Flag 🇺🇸 and family 👨‍👩‍👧" + }, + { + "input": "Math 𝓗𝓮𝓵𝓵𝓸 bold", + "expected": "Math 𝓗𝓮𝓵𝓵𝓸 bold" + }, + { + "input": "Fullwidth ABC 123 :?", + "expected": "Fullwidth ABC 123 :?" + }, + { + "input": "CJK 日本語のタイトル 中文标题 한국어", + "expected": "CJK 日本語のタイトル 中文标题 한국어" + }, + { + "input": "Accents café naïve Ærøskøbing", + "expected": "Accents café naïve Ærøskøbing" + }, + { + "input": "RTL עברית العربية", + "expected": "RTL עברית العربية" + }, + { + "input": "Percent %(title)s literal", + "expected": "Percent %(title)s literal" + }, + { + "input": "BEYBLADE | Ep.19 Under the Microscope | Ep.20 It's All Relative", + "expected": "BEYBLADE | Ep.19 Under the Microscope | Ep.20 It's All Relative" + }, + { + "input": "Hermitcraft 10: Episode 43 - THE LAST DAY! (the end of an era, thanks for watching)", + "expected": "Hermitcraft 10: Episode 43 - THE LAST DAY! (the end of an era, thanks for watching)" + }, + { + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa??::", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa??::" + }, + { + "input": "ああああああああああああああああああああああああああああああ", + "expected": "ああああああああああああああああああああああああああああああ" + }, + { + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa😀tail", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa😀tail" + }, + { + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaéb", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaéb" + }, + { + "input": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb日本", + "expected": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb日本" + } + ] +} diff --git a/server/modules/filesystem/__tests__/sanitizer.test.js b/server/modules/filesystem/__tests__/sanitizer.test.js index 544e42026..c5a794e5b 100644 --- a/server/modules/filesystem/__tests__/sanitizer.test.js +++ b/server/modules/filesystem/__tests__/sanitizer.test.js @@ -1,7 +1,9 @@ const { sanitizeNameLikeYtDlp, - sanitizePathParts + sanitizePathParts, + sanitizeFilenameLikeYtDlp } = require('../sanitizer'); +const ytdlpFixture = require('./fixtures/ytdlpTitleSanitizer.json'); describe('sanitizer', () => { describe('sanitizePathParts', () => { @@ -96,4 +98,70 @@ describe('sanitizer', () => { expect(sanitizeNameLikeYtDlp('Fred again . .')).toBe('Fred again . #'); }); }); + + describe('sanitizeFilenameLikeYtDlp', () => { + const MAX_BYTES = 64; + const byteLength = (text) => Buffer.byteLength(text, 'utf8'); + + describe('matches yt-dlp output for the fixture corpus', () => { + it.each(ytdlpFixture.cases.map((fixtureCase) => [JSON.stringify(fixtureCase.input), fixtureCase]))( + '%s', + (_label, { input, expected }) => { + expect(sanitizeFilenameLikeYtDlp(input)).toBe(expected); + } + ); + }); + + it('returns an empty string for empty or non-string input', () => { + expect([sanitizeFilenameLikeYtDlp(''), sanitizeFilenameLikeYtDlp(null), sanitizeFilenameLikeYtDlp(undefined)]) + .toEqual(['', '', '']); + }); + + it('returns an underscore when every character is dropped', () => { + expect(sanitizeFilenameLikeYtDlp('\u0001\u0002')).toBe('_'); + }); + + it('leaves text within maxBytes unchanged', () => { + expect(sanitizeFilenameLikeYtDlp('Short title', { maxBytes: MAX_BYTES })).toBe('Short title'); + }); + + it('never exceeds maxBytes, unlike yt-dlp .64B which cuts before substituting', () => { + const result = sanitizeFilenameLikeYtDlp(`${'a'.repeat(62)}??::`, { maxBytes: MAX_BYTES }); + expect(result).toBe(`${'a'.repeat(62)}`); + }); + + it.each( + ytdlpFixture.cases + .filter((fixtureCase) => byteLength(fixtureCase.expected) > MAX_BYTES) + .map((fixtureCase) => [JSON.stringify(fixtureCase.input), fixtureCase]) + )('cuts %s to a whole-character prefix of the full result', (_label, { input, expected }) => { + const result = sanitizeFilenameLikeYtDlp(input, { maxBytes: MAX_BYTES }); + expect({ + withinLimit: byteLength(result) <= MAX_BYTES, + isPrefix: expected.startsWith(result), + wholeCharacters: Buffer.from(result, 'utf8').toString('utf8') === result && !/[\ud800-\udfff]$/.test(result), + }).toEqual({ withinLimit: true, isPrefix: true, wholeCharacters: true }); + }); + + it('drops a multi-byte character that would cross the limit', () => { + expect(sanitizeFilenameLikeYtDlp(`${'a'.repeat(63)}😀tail`, { maxBytes: MAX_BYTES })).toBe('a'.repeat(63)); + }); + + it('drops a whole emoji sequence rather than leaving a joiner at the end', () => { + expect(sanitizeFilenameLikeYtDlp(`${'a'.repeat(55)}👨‍👩‍👧`, { maxBytes: MAX_BYTES })).toBe('a'.repeat(55)); + }); + + it('drops a whole flag rather than leaving half of it', () => { + expect(sanitizeFilenameLikeYtDlp(`${'a'.repeat(60)}🇺🇸`, { maxBytes: MAX_BYTES })).toBe('a'.repeat(60)); + }); + + it('drops whitespace left at the end by the cut', () => { + expect(sanitizeFilenameLikeYtDlp(`${'a'.repeat(61)} bcdef`, { maxBytes: MAX_BYTES })).toBe('a'.repeat(61)); + }); + + it('ignores a maxBytes that is not a positive integer', () => { + const long = 'a'.repeat(100); + expect(sanitizeFilenameLikeYtDlp(long, { maxBytes: 0 })).toBe(long); + }); + }); }); diff --git a/server/modules/filesystem/__tests__/showFolderCleanup.test.js b/server/modules/filesystem/__tests__/showFolderCleanup.test.js new file mode 100644 index 000000000..88f339106 --- /dev/null +++ b/server/modules/filesystem/__tests__/showFolderCleanup.test.js @@ -0,0 +1,204 @@ +jest.mock('../../../logger'); + +const fs = require('fs-extra'); +const os = require('os'); +const path = require('path'); +const logger = require('../../../logger'); +const { + resolveLibraryFolder, + locateEpisodeFolders, + cleanupEmptyShowFolders, + cleanupOrphanShowFolder +} = require('../showFolderCleanup'); + +describe('showFolderCleanup', () => { + const baseDir = '/downloads'; + + describe('resolveLibraryFolder', () => { + it('places a path outside any subfolder in the main folder', () => { + expect(resolveLibraryFolder('/downloads/Show/Season 2024/ep.mp4', baseDir)) + .toEqual({ libraryFolder: '', libraryRoot: '/downloads' }); + }); + + it('places a path under a __subfolder in that subfolder, named without the prefix', () => { + expect(resolveLibraryFolder('/downloads/__TV Shows/Show/Season 01/ep.mp4', baseDir)) + .toEqual({ libraryFolder: 'TV Shows', libraryRoot: '/downloads/__TV Shows' }); + }); + + it.each([ + ['the downloads folder itself', '/downloads'], + ['a subfolder itself', '/downloads/__kids'], + ['a path outside the downloads folder', '/elsewhere/Show/Season 01/ep.mp4'], + ])('returns null for %s', (_label, targetPath) => { + expect(resolveLibraryFolder(targetPath, baseDir)).toBeNull(); + }); + }); + + describe('locateEpisodeFolders', () => { + it('returns the season and show folders of an episode in a season folder', () => { + expect(locateEpisodeFolders('/downloads/__tv/Show/Season 2024/S2024E01151200 - T [abcdefghijk].mp4', '/downloads/__tv')) + .toEqual({ showDir: '/downloads/__tv/Show', seasonDir: '/downloads/__tv/Show/Season 2024' }); + }); + + it('accepts Season 00', () => { + expect(locateEpisodeFolders('/downloads/Show/Season 00/S00E03 - T [abcdefghijk].mp4', baseDir)) + .toEqual({ showDir: '/downloads/Show', seasonDir: '/downloads/Show/Season 00' }); + }); + + it.each([ + ['a per-video folder', '/downloads/Channel/Channel - Title - abcdefghijk/Channel - Title [abcdefghijk].mp4'], + ['a flat channel folder', '/downloads/Channel/Channel - Title [abcdefghijk].mp4'], + ['a deeper folder', '/downloads/Show/Season 01/extra/ep [abcdefghijk].mp4'], + ['a show folder named like a subfolder', '/downloads/__kids/Season 01/ep [abcdefghijk].mp4'], + ['a hidden show folder', '/downloads/.trash/Season 01/ep [abcdefghijk].mp4'], + ['a folder that is only almost a season folder', '/downloads/Show/Season one/ep [abcdefghijk].mp4'], + ])('returns null for %s', (_label, filePath) => { + expect(locateEpisodeFolders(filePath, baseDir)).toBeNull(); + }); + }); + + describe('on disk', () => { + let tmp; + let showDir; + let seasonDir; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tvclean-')); + showDir = path.join(tmp, 'Show'); + seasonDir = path.join(showDir, 'Season 2024'); + await fs.ensureDir(seasonDir); + await fs.writeFile(path.join(showDir, 'tvshow.nfo'), '<tvshow/>'); + await fs.writeFile(path.join(showDir, 'poster.jpg'), 'x'); + await fs.writeFile(path.join(showDir, 'backdrop.jpg'), 'x'); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + await fs.remove(tmp); + }); + + // chmod 000 does not stop root, so unreadable folders are simulated. + const failFsCall = (method, failingPath, code) => { + const fsPromises = require('fs').promises; + const original = fsPromises[method].bind(fsPromises); + jest.spyOn(fsPromises, method).mockImplementation((target, ...rest) => { + if (target === failingPath) { + return Promise.reject(Object.assign(new Error(`${code}: ${method} ${target}`), { code })); + } + return original(target, ...rest); + }); + }; + + describe('cleanupEmptyShowFolders', () => { + it('removes a season folder holding only season metadata, then the show folder', async () => { + await fs.writeFile(path.join(seasonDir, 'season.nfo'), '<season/>'); + await fs.writeFile(path.join(seasonDir, '.DS_Store'), 'x'); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect({ result, showExists: await fs.pathExists(showDir) }) + .toEqual({ result: { removedSeason: true, removedShow: true }, showExists: false }); + }); + + it('keeps a season folder that still holds another episode', async () => { + await fs.writeFile(path.join(seasonDir, 'S2024E02011200 - Other [bbbbbbbbbbb].mp4'), 'x'); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect(result).toEqual({ removedSeason: false, removedShow: false }); + }); + + it('keeps the show folder while another season folder exists', async () => { + await fs.ensureDir(path.join(showDir, 'Season 2023')); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect({ result, showExists: await fs.pathExists(path.join(showDir, 'tvshow.nfo')) }) + .toEqual({ result: { removedSeason: true, removedShow: false }, showExists: true }); + }); + + it('keeps the show folder when it holds a file that is not show metadata', async () => { + await fs.writeFile(path.join(showDir, 'notes.txt'), 'x'); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect(result).toEqual({ removedSeason: true, removedShow: false }); + }); + + it('does not treat tvshow.nfo as removable inside a season folder', async () => { + await fs.writeFile(path.join(seasonDir, 'tvshow.nfo'), '<tvshow/>'); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect(result).toEqual({ removedSeason: false, removedShow: false }); + }); + + it('reports nothing removed when the season folder is already gone', async () => { + await fs.remove(seasonDir); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect(result).toEqual({ removedSeason: false, removedShow: false }); + }); + + it('warns when the show folder cannot be removed after its metadata was deleted', async () => { + failFsCall('rmdir', showDir, 'EBUSY'); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect({ result, warned: logger.warn.mock.calls.map(([context]) => [context.dirPath, context.deleted.sort()]) }) + .toEqual({ + result: { removedSeason: true, removedShow: false }, + warned: [[showDir, ['backdrop.jpg', 'poster.jpg', 'tvshow.nfo']]] + }); + }); + + it('does not warn about a season folder it could not read', async () => { + failFsCall('readdir', seasonDir, 'EACCES'); + + const result = await cleanupEmptyShowFolders({ showDir, seasonDir }); + + expect({ result, warnings: logger.warn.mock.calls.length }) + .toEqual({ result: { removedSeason: false, removedShow: false }, warnings: 0 }); + }); + }); + + describe('cleanupOrphanShowFolder', () => { + it('removes empty season folders and then the emptied show folder', async () => { + await fs.ensureDir(path.join(showDir, 'Season 2023')); + + const removed = await cleanupOrphanShowFolder(showDir); + + expect(removed.sort()).toEqual([showDir, path.join(showDir, 'Season 2023'), seasonDir].sort()); + }); + + it('keeps season folders with episodes and the show that holds them', async () => { + await fs.writeFile(path.join(seasonDir, 'S2024E01151200 - T [abcdefghijk].mp4'), 'x'); + await fs.ensureDir(path.join(showDir, 'Season 2023')); + + const removed = await cleanupOrphanShowFolder(showDir); + + expect(removed).toEqual([path.join(showDir, 'Season 2023')]); + }); + + it('never removes a folder that is not a season folder, and keeps the show for it', async () => { + await fs.remove(seasonDir); + await fs.ensureDir(path.join(showDir, 'Extras')); + + const removed = await cleanupOrphanShowFolder(showDir); + + expect({ removed, extrasExists: await fs.pathExists(path.join(showDir, 'Extras')) }) + .toEqual({ removed: [], extrasExists: true }); + }); + + it('skips a folder it cannot list instead of throwing, like lost+found', async () => { + const lostFound = path.join(tmp, 'lost+found'); + await fs.ensureDir(lostFound); + failFsCall('readdir', lostFound, 'EACCES'); + + await expect(cleanupOrphanShowFolder(lostFound)).resolves.toEqual([]); + }); + }); + }); +}); diff --git a/server/modules/filesystem/constants.js b/server/modules/filesystem/constants.js index 1e83796e5..fead1be6c 100644 --- a/server/modules/filesystem/constants.js +++ b/server/modules/filesystem/constants.js @@ -173,6 +173,32 @@ const M3U_FILE_PATTERN = /\.m3u(\.tmp)?$/i; */ const APPLEDOUBLE_FILE_PATTERN = /^\._/; +/** + * Library folder name of the downloads folder itself; subfolders are named + * without their __ prefix. + */ +const MAIN_LIBRARY_FOLDER = ''; + +/** + * Season folder inside a TV show folder: "Season 00", "Season 01", "Season 2024". + */ +const SEASON_FOLDER_PATTERN = /^Season (\d+)$/; + +/** + * Season-level metadata and art that cannot keep an emptied season folder + * alive in a TV library folder: season.nfo plus the season poster names Plex, + * Jellyfin, Emby and Kodi read. Episode files and their sidecars carry the + * video's [id] and are deleted with it. + */ +const TV_SEASON_IGNORABLE_FILE_PATTERN = /^(season\.nfo|(poster|folder|banner|fanart|backdrop|landscape|thumb)\.(jpe?g|png|webp|tbn)|season(\d+|-specials)(-(poster|banner|fanart|landscape))?\.(jpe?g|png|webp|tbn))$/i; + +/** + * Show-level metadata and art that cannot keep a TV show folder alive once its + * season folders are gone: tvshow.nfo, show art, season posters stored at the + * show level, and .plexignore. Only applied inside TV library folders. + */ +const TV_SHOW_IGNORABLE_FILE_PATTERN = /^(tvshow\.nfo|\.plexignore|(poster|folder|banner|fanart|backdrop|landscape|logo|clearlogo|clearart|thumb)\.(jpe?g|png|webp|tbn)|season(\d+|-specials|-all)(-(poster|banner|fanart|landscape))?\.(jpe?g|png|webp|tbn))$/i; + module.exports = { SUBFOLDER_PREFIX, GLOBAL_DEFAULT_SENTINEL, @@ -193,5 +219,9 @@ module.exports = { FRAGMENT_FILE_PATTERN, CHANNEL_CLEANUP_IGNORABLE_FILES, M3U_FILE_PATTERN, - APPLEDOUBLE_FILE_PATTERN + APPLEDOUBLE_FILE_PATTERN, + MAIN_LIBRARY_FOLDER, + SEASON_FOLDER_PATTERN, + TV_SEASON_IGNORABLE_FILE_PATTERN, + TV_SHOW_IGNORABLE_FILE_PATTERN }; diff --git a/server/modules/filesystem/index.js b/server/modules/filesystem/index.js index 5193a737f..82ca64883 100644 --- a/server/modules/filesystem/index.js +++ b/server/modules/filesystem/index.js @@ -18,6 +18,7 @@ const pathBuilder = require('./pathBuilder'); const fileOperations = require('./fileOperations'); const directoryManager = require('./directoryManager'); const sanitizer = require('./sanitizer'); +const showFolderCleanup = require('./showFolderCleanup'); module.exports = { // Re-export all constants @@ -35,10 +36,14 @@ module.exports = { // Re-export all sanitizer functions ...sanitizer, + // Re-export all TV-layout cleanup functions + ...showFolderCleanup, + // Also export as namespaced modules for explicit imports constants, pathBuilder, fileOperations, directoryManager, - sanitizer + sanitizer, + showFolderCleanup }; diff --git a/server/modules/filesystem/sanitizer.js b/server/modules/filesystem/sanitizer.js index abd6d75ae..456794aeb 100644 --- a/server/modules/filesystem/sanitizer.js +++ b/server/modules/filesystem/sanitizer.js @@ -71,7 +71,91 @@ function sanitizeNameLikeYtDlp(name) { return sanitized || '_'; } +const SUBSTITUTE_MARKER = '\0'; +const FULLWIDTH_OFFSET = 0xfee0; +const FULLWIDTH_CHARS = '"*:<>?|'; +const SLASH_REPLACEMENTS = { '/': '⧸', '\\': '⧹' }; +const DIGIT_COLON_RUN_PATTERN = /[0-9]+(?::[0-9]+)+/g; +const REPEATED_SUBSTITUTE_PATTERN = /(\0.)(?:(?=\1)..)+/gsu; +const EDGE_SUBSTITUTE_PATTERN = /^\0.(?:\0.|[ _-])*|(?:\0.|[ _-])*\0.$/gsu; + +/** + * Port of yt-dlp's per-character replacement in sanitize_filename() for its + * default mode (restricted=False, is_id=NO_DEFAULT), the mode used for every + * output template field such as %(title)s. + */ +function replaceInsaneChar(char) { + if (char === '\n') { + return `${SUBSTITUTE_MARKER} `; + } + if (SLASH_REPLACEMENTS[char]) { + return SLASH_REPLACEMENTS[char]; + } + if (FULLWIDTH_CHARS.includes(char)) { + return String.fromCodePoint(char.codePointAt(0) + FULLWIDTH_OFFSET); + } + const code = char.codePointAt(0); + if (code < 32 || code === 127) { + return ''; + } + return char; +} + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + +/** + * Cut text to at most maxBytes of UTF-8 between whole characters as a reader + * sees them (grapheme clusters), so an emoji sequence, flag or accented letter + * is never split, and drop whitespace the cut leaves at the end. + */ +function truncateUtf8(text, maxBytes) { + if (Buffer.byteLength(text, 'utf8') <= maxBytes) { + return text; + } + let bytes = 0; + let truncated = ''; + for (const { segment } of graphemeSegmenter.segment(text)) { + const size = Buffer.byteLength(segment, 'utf8'); + if (bytes + size > maxBytes) { + break; + } + truncated += segment; + bytes += size; + } + return truncated.trimEnd() || '_'; +} + +/** + * Sanitize text for use inside a filename the way yt-dlp sanitizes an output + * template field (port of sanitize_filename() in its default mode): digit runs + * joined by colons become underscores, "*:<>?| become their fullwidth forms, + * / and \ become U+29F8 and U+29F9, newlines become spaces, and other control + * characters are dropped. With maxBytes, the result is then cut to that many + * UTF-8 bytes at a character boundary. yt-dlp's %(title).64B cuts before it + * sanitizes and can exceed the limit; this cuts after, so it never does. + * + * The --windows-filenames step (sanitizeNameLikeYtDlp) only changes a trailing + * dot or space of a whole path segment; apply it when the result ends one. + * + * @param {string} name - Text to sanitize (e.g. a video title) + * @param {Object} [options] + * @param {number} [options.maxBytes] - Maximum UTF-8 length of the result + * @returns {string} - '' for empty input, otherwise a non-empty string + */ +function sanitizeFilenameLikeYtDlp(name, { maxBytes } = {}) { + if (typeof name !== 'string' || name === '') { + return ''; + } + const withoutColonRuns = name.replace(DIGIT_COLON_RUN_PATTERN, (run) => run.replace(/:/g, '_')); + const sanitized = Array.from(withoutColonRuns, replaceInsaneChar).join('') + .replace(REPEATED_SUBSTITUTE_PATTERN, '$1') + .replace(EDGE_SUBSTITUTE_PATTERN, '') + .replace(/\0/g, '') || '_'; + return Number.isInteger(maxBytes) && maxBytes > 0 ? truncateUtf8(sanitized, maxBytes) : sanitized; +} + module.exports = { sanitizeNameLikeYtDlp, - sanitizePathParts + sanitizePathParts, + sanitizeFilenameLikeYtDlp }; diff --git a/server/modules/filesystem/showFolderCleanup.js b/server/modules/filesystem/showFolderCleanup.js new file mode 100644 index 000000000..7935e653c --- /dev/null +++ b/server/modules/filesystem/showFolderCleanup.js @@ -0,0 +1,182 @@ +/** + * Cleanup for TV-layout library folders, where episodes sit directly in season + * folders: <library folder>/<show>/Season NN/<episode files>. A library folder + * is the downloads folder itself or a __subfolder directly under it. Callers + * decide whether a library folder uses the TV layout; these helpers only + * understand the folder shape. + */ + +const fsPromises = require('fs').promises; +const path = require('path'); +const logger = require('../../logger'); +const { + SUBFOLDER_PREFIX, + MAIN_LIBRARY_FOLDER, + SEASON_FOLDER_PATTERN, + TV_SEASON_IGNORABLE_FILE_PATTERN, + TV_SHOW_IGNORABLE_FILE_PATTERN +} = require('./constants'); +const { isIgnorableEntry, listSubdirectories } = require('./directoryManager'); + +const isSeasonIgnorable = (name) => isIgnorableEntry(name) || TV_SEASON_IGNORABLE_FILE_PATTERN.test(name); +const isShowIgnorable = (name) => isIgnorableEntry(name) || TV_SHOW_IGNORABLE_FILE_PATTERN.test(name); + +/** + * Find the library folder a path lives in. + * + * @param {string} targetPath - A path inside baseDir + * @param {string} baseDir - The downloads folder + * @returns {{libraryFolder: string, libraryRoot: string}|null} - libraryFolder is + * '' for the main folder, else the subfolder name without the __ prefix; null + * when the path is baseDir itself, a subfolder itself, or outside baseDir + */ +function resolveLibraryFolder(targetPath, baseDir) { + if (!targetPath || !baseDir) { + return null; + } + const root = path.resolve(baseDir); + const relativePath = path.relative(root, path.resolve(targetPath)); + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + const [firstSegment, ...rest] = relativePath.split(path.sep); + if (!firstSegment.startsWith(SUBFOLDER_PREFIX)) { + return { libraryFolder: MAIN_LIBRARY_FOLDER, libraryRoot: root }; + } + if (rest.length === 0) { + return null; + } + return { + libraryFolder: firstSegment.slice(SUBFOLDER_PREFIX.length), + libraryRoot: path.join(root, firstSegment) + }; +} + +/** + * Locate the season and show folders of an episode file. The file must sit + * directly in a season folder of a show folder of the library root; show + * folders never start with "__" (those are library folders) or ".". + * + * @param {string} filePath - Episode file path + * @param {string} libraryRoot - The TV library folder holding the show + * @returns {{showDir: string, seasonDir: string}|null} + */ +function locateEpisodeFolders(filePath, libraryRoot) { + if (!filePath || !libraryRoot) { + return null; + } + const root = path.resolve(libraryRoot); + const seasonDir = path.dirname(path.resolve(filePath)); + const relativePath = path.relative(root, seasonDir); + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + const segments = relativePath.split(path.sep); + if (segments.length !== 2) { + return null; + } + const [showName, seasonName] = segments; + if (showName.startsWith(SUBFOLDER_PREFIX) || showName.startsWith('.') || !SEASON_FOLDER_PATTERN.test(seasonName)) { + return null; + } + return { showDir: path.join(root, showName), seasonDir }; +} + +/** + * Remove a directory when it holds no subdirectories and only files the + * predicate accepts, deleting those files first. The second listing is + * filtered again so a real file appearing between the two reads is kept. + * A folder that cannot be read is skipped quietly; a failure after files + * were deleted is a warning, because the folder is left without them. + * + * @param {string} dirPath - Directory to remove + * @param {(name: string) => boolean} isRemovable - Files that may be deleted + * @returns {Promise<boolean>} - True if the directory was removed + */ +async function removeIfOnlyRemovableFiles(dirPath, isRemovable) { + const deleted = []; + try { + const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }); + if (entries.some((entry) => entry.isDirectory() || !isRemovable(entry.name))) { + return false; + } + for (const name of await fsPromises.readdir(dirPath)) { + if (!isRemovable(name)) { + continue; + } + try { + await fsPromises.unlink(path.join(dirPath, name)); + deleted.push(name); + } catch (unlinkErr) { + if (unlinkErr.code !== 'ENOENT') { + throw unlinkErr; + } + } + } + await fsPromises.rmdir(dirPath); + logger.info({ dirPath }, 'Removed empty TV folder'); + return true; + } catch (error) { + if (deleted.length > 0) { + logger.warn({ err: error, dirPath, deleted }, 'Deleted metadata from a TV folder but could not remove the folder'); + } else if (error.code !== 'ENOENT') { + logger.debug({ err: error, dirPath }, 'Could not remove TV folder'); + } + return false; + } +} + +/** + * After an episode's files are deleted: remove its season folder once only + * season metadata and art remain, then the show folder once it has no + * folders left and only show metadata and art remain. + * + * @param {{showDir: string, seasonDir: string}} folders - From locateEpisodeFolders + * @returns {Promise<{removedSeason: boolean, removedShow: boolean}>} + */ +async function cleanupEmptyShowFolders({ showDir, seasonDir }) { + const removedSeason = await removeIfOnlyRemovableFiles(seasonDir, isSeasonIgnorable); + if (!removedSeason) { + return { removedSeason, removedShow: false }; + } + const removedShow = await removeIfOnlyRemovableFiles(showDir, isShowIgnorable); + return { removedSeason, removedShow }; +} + +/** + * Sweep one show folder: remove its empty season folders, then the show + * folder itself if nothing but show metadata and art is left. Folders that + * are not season folders are never removed and keep the show alive. Like the + * channel folder cleanup, a folder that cannot be listed (lost+found, a NAS + * recycle bin) is skipped rather than ending the sweep. + * + * @param {string} showDir - A show folder directly inside a TV library folder + * @returns {Promise<string[]>} - Removed directories + */ +async function cleanupOrphanShowFolder(showDir) { + let childDirs; + try { + childDirs = await listSubdirectories(showDir); + } catch (error) { + logger.debug({ err: error, showDir }, 'Could not list TV show folder, skipping it'); + return []; + } + const removed = []; + for (const childDir of childDirs) { + if (SEASON_FOLDER_PATTERN.test(path.basename(childDir)) + && await removeIfOnlyRemovableFiles(childDir, isSeasonIgnorable)) { + removed.push(childDir); + } + } + if (await removeIfOnlyRemovableFiles(showDir, isShowIgnorable)) { + removed.push(showDir); + } + return removed; +} + +module.exports = { + resolveLibraryFolder, + locateEpisodeFolders, + cleanupEmptyShowFolders, + cleanupOrphanShowFolder +}; diff --git a/server/modules/tvShows/__tests__/channelTitleCorpus.test.js b/server/modules/tvShows/__tests__/channelTitleCorpus.test.js new file mode 100644 index 000000000..4b78e0d9a --- /dev/null +++ b/server/modules/tvShows/__tests__/channelTitleCorpus.test.js @@ -0,0 +1,51 @@ +const corpus = require('./fixtures/channelTitleCorpus.json'); + +const STATUSES = ['assigned', 'duplicate', 'unsupported', 'unmatched']; +const videos = corpus.channels.flatMap((channel) => channel.videos.map((video) => ({ channel, video }))); + +describe('channel title corpus fixture', () => { + it('lists every video once', () => { + const ids = videos.map(({ video }) => video.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('uses only known statuses', () => { + expect(videos.filter(({ video }) => !STATUSES.includes(video.expected.status))).toEqual([]); + }); + + it('refers only to shows defined for the same channel', () => { + const unknown = videos.filter(({ channel, video }) => { + const shows = channel.shows.map((show) => show.key); + const target = video.expected.show || (video.expected.intended && video.expected.intended.show); + return target !== undefined && !shows.includes(target); + }); + expect(unknown).toEqual([]); + }); + + it('gives every assigned or duplicate video a season and an episode or episode source', () => { + const incomplete = videos.filter(({ video: { expected } }) => ['assigned', 'duplicate'].includes(expected.status) + && !(Number.isInteger(expected.season) && (Number.isInteger(expected.episode) || expected.episodeSource))); + expect(incomplete).toEqual([]); + }); + + it('points each duplicate at an older upload in the same channel that holds the number', () => { + const wrong = videos.filter(({ channel, video }) => { + if (video.expected.status !== 'duplicate') { + return false; + } + const winner = channel.videos.find((other) => other.id === video.expected.duplicateOf); + return !winner + || winner.listingIndex <= video.listingIndex + || winner.expected.status !== 'assigned' + || winner.expected.season !== video.expected.season + || winner.expected.episode !== video.expected.episode; + }); + expect(wrong).toEqual([]); + }); + + it('keeps each channel in listing order', () => { + const outOfOrder = corpus.channels.filter((channel) => channel.videos + .some((video, index) => index > 0 && channel.videos[index - 1].listingIndex >= video.listingIndex)); + expect(outOfOrder).toEqual([]); + }); +}); diff --git a/server/modules/tvShows/__tests__/dateNumbering.test.js b/server/modules/tvShows/__tests__/dateNumbering.test.js new file mode 100644 index 000000000..78d55c201 --- /dev/null +++ b/server/modules/tvShows/__tests__/dateNumbering.test.js @@ -0,0 +1,212 @@ +const { + releaseTime, + dateEpisodeFor, + allocateEpisode, + parseDateEpisodeCode, + assignDateEpisodes +} = require('../dateNumbering'); +const { buildEpisodeStem } = require('../episodeNaming'); + +const epoch = (iso) => Date.parse(iso) / 1000; + +describe('dateNumbering', () => { + describe('releaseTime', () => { + it('prefers the exact timestamp', () => { + expect(releaseTime({ timestamp: 1114313460, upload_date: '20050424' })) + .toEqual({ epochSeconds: 1114313460, source: 'timestamp' }); + }); + + it('falls back to the upload date at 00:00 UTC', () => { + expect(releaseTime({ upload_date: '20240315' })) + .toEqual({ epochSeconds: epoch('2024-03-15T00:00:00Z'), source: 'upload_date' }); + }); + + it.each([ + ['no info', null], + ['neither field', { title: 'x' }], + ['a malformed upload date', { upload_date: '2024-03-15' }], + ['an impossible upload date', { upload_date: '20240231' }], + ])('returns null for %s', (_label, info) => { + expect(releaseTime(info)).toBeNull(); + }); + }); + + describe('dateEpisodeFor', () => { + it('numbers by UTC year and MMDDHHMM', () => { + expect(dateEpisodeFor(epoch('2024-03-15T12:00:00Z'))).toEqual({ season: 2024, episode: 3151200 }); + }); + + it('uses UTC, not local time, at a year boundary', () => { + expect(dateEpisodeFor(epoch('2024-12-31T23:59:59Z'))).toEqual({ season: 2024, episode: 12312359 }); + }); + + it('matches the "Plex TV Series" preset for the first YouTube video', () => { + expect(dateEpisodeFor(1114313460)).toEqual({ season: 2005, episode: 4240331 }); + }); + }); + + describe('allocateEpisode', () => { + it('keeps a free number', () => { + expect(allocateEpisode(1151200, new Set())).toBe(1151200); + }); + + it('bumps past taken numbers to the next free integer', () => { + expect(allocateEpisode(1151200, new Set([1151200, 1151201]))).toBe(1151202); + }); + + it('stops after a bounded number of bumps', () => { + expect(() => allocateEpisode(1, { has: () => true })).toThrow('No free episode number'); + }); + }); + + describe('parseDateEpisodeCode', () => { + it('reads a "Plex TV Series" preset filename', () => { + expect(parseDateEpisodeCode('S2019E04050000 Video Title [abcdefghijk].mp4')) + .toEqual({ season: 2019, episode: 4050000 }); + }); + + it('reads a date-numbered TV layout filename', () => { + expect(parseDateEpisodeCode('S2024E12312359 - Title [abcdefghijk].mp4')) + .toEqual({ season: 2024, episode: 12312359 }); + }); + + it('reads back a number bumped past minute 59 from the name Youtarr built for it', () => { + const episode = allocateEpisode(1151259, new Set([1151259])); + const stem = buildEpisodeStem({ season: 2024, episode, dateNumbered: true, videoTitle: 'Second upload', youtubeId: 'abcdefghijk' }); + expect({ stem, parsed: parseDateEpisodeCode(`${stem}.mp4`) }).toEqual({ + stem: 'S2024E01151260 - Second upload [abcdefghijk]', + parsed: { season: 2024, episode: 1151260 } + }); + }); + + it('reads back the 61st same-day video numbered from an upload date', () => { + const { assigned } = assignDateEpisodes(Array.from({ length: 61 }, (_unused, index) => ({ + youtubeId: `vid${String(index).padStart(8, '0')}`, + info: { upload_date: '20220617' } + }))); + const last = assigned[assigned.length - 1]; + const stem = buildEpisodeStem({ ...last, dateNumbered: true, videoTitle: 'T' }); + expect({ episode: last.episode, parsed: parseDateEpisodeCode(stem) }) + .toEqual({ episode: 6170060, parsed: { season: 2022, episode: 6170060 } }); + }); + + it.each([ + ['a title-numbered name', 'S01E20 - Title [abcdefghijk].mp4'], + ['a movie-style name', 'Channel - Title [abcdefghijk].mp4'], + ['too few digits', 'S2024E0115120 Title [abcdefghijk].mp4'], + ['too many digits', 'S2024E011512001 Title [abcdefghijk].mp4'], + ])('ignores %s', (_label, fileName) => { + expect(parseDateEpisodeCode(fileName)).toBeNull(); + }); + }); + + describe('assignDateEpisodes', () => { + const at = (iso) => ({ timestamp: epoch(iso) }); + const numbersOf = (result) => Object.fromEntries( + result.assigned.map(({ youtubeId, season, episode, source }) => [youtubeId, `${season}/${episode}/${source}`]) + ); + + it('numbers videos from their upload time', () => { + const result = assignDateEpisodes([{ youtubeId: 'aaaaaaaaaaa', info: at('2024-01-15T12:00:00Z') }]); + expect(result).toEqual({ + assigned: [{ youtubeId: 'aaaaaaaaaaa', season: 2024, episode: 1151200, source: 'date', timestampSource: 'timestamp' }], + unnumbered: [] + }); + }); + + it('bumps the later of two uploads in the same minute', () => { + const result = assignDateEpisodes([ + { youtubeId: 'bbbbbbbbbbb', info: at('2024-01-15T12:00:50Z') }, + { youtubeId: 'aaaaaaaaaaa', info: at('2024-01-15T12:00:10Z') }, + ]); + expect(numbersOf(result)).toEqual({ + aaaaaaaaaaa: '2024/1151200/date', + bbbbbbbbbbb: '2024/1151201/date' + }); + }); + + it('orders same-day uploads that only have an upload date by video id', () => { + const result = assignDateEpisodes([ + { youtubeId: 'ccccccccccc', info: { upload_date: '20220617' } }, + { youtubeId: 'Bbbbbbbbbbb', info: { upload_date: '20220617' } }, + { youtubeId: 'aaaaaaaaaaa', info: { upload_date: '20220617' } }, + ]); + expect(result.assigned.map(({ youtubeId, episode, timestampSource }) => [youtubeId, episode, timestampSource])) + .toEqual([ + ['Bbbbbbbbbbb', 6170000, 'upload_date'], + ['aaaaaaaaaaa', 6170001, 'upload_date'], + ['ccccccccccc', 6170002, 'upload_date'], + ]); + }); + + it('never reuses a number the show already holds', () => { + const result = assignDateEpisodes( + [{ youtubeId: 'aaaaaaaaaaa', info: at('2024-01-15T12:00:00Z') }], + new Map([[2024, [1151200]]]) + ); + expect(numbersOf(result)).toEqual({ aaaaaaaaaaa: '2024/1151201/date' }); + }); + + it('does not modify the numbers passed in', () => { + const taken = new Map([[2024, new Set([1151200])]]); + assignDateEpisodes([{ youtubeId: 'aaaaaaaaaaa', info: at('2024-01-15T12:00:00Z') }], taken); + expect([...taken.get(2024)]).toEqual([1151200]); + }); + + it('adopts a preset code that is free', () => { + const result = assignDateEpisodes([ + { youtubeId: 'aaaaaaaaaaa', info: at('2019-04-05T00:00:00Z'), adoptedCode: { season: 2019, episode: 4050000 } }, + ]); + expect(numbersOf(result)).toEqual({ aaaaaaaaaaa: '2019/4050000/adopted' }); + }); + + it('keeps adopted codes ahead of new date numbers that would collide', () => { + const result = assignDateEpisodes([ + { youtubeId: 'zzzzzzzzzzz', info: at('2019-04-05T00:00:00Z') }, + { youtubeId: 'aaaaaaaaaaa', info: at('2019-04-05T00:00:30Z'), adoptedCode: { season: 2019, episode: 4050000 } }, + ]); + expect(numbersOf(result)).toEqual({ + aaaaaaaaaaa: '2019/4050000/adopted', + zzzzzzzzzzz: '2019/4050001/date' + }); + }); + + it('lets the earlier upload keep a shared preset code and bumps the later one', () => { + const result = assignDateEpisodes([ + { youtubeId: 'aaaaaaaaaaa', info: at('2019-04-05T00:00:40Z'), adoptedCode: { season: 2019, episode: 4050000 } }, + { youtubeId: 'bbbbbbbbbbb', info: at('2019-04-05T00:00:20Z'), adoptedCode: { season: 2019, episode: 4050000 } }, + ]); + expect(numbersOf(result)).toEqual({ + bbbbbbbbbbb: '2019/4050000/adopted', + aaaaaaaaaaa: '2019/4050001/date' + }); + }); + + it('bumps a displaced adopted code from the code, not from an upload date at midnight', () => { + const result = assignDateEpisodes([ + { youtubeId: 'aaaaaaaaaaa', info: { upload_date: '20190405' }, adoptedCode: { season: 2019, episode: 4051530 } }, + { youtubeId: 'bbbbbbbbbbb', info: { upload_date: '20190405' }, adoptedCode: { season: 2019, episode: 4051530 } }, + ]); + expect(numbersOf(result)).toEqual({ + aaaaaaaaaaa: '2019/4051530/adopted', + bbbbbbbbbbb: '2019/4051531/date' + }); + }); + + it('bumps a colliding adopted code that has no release time from the code itself', () => { + const result = assignDateEpisodes([ + { youtubeId: 'aaaaaaaaaaa', info: {}, adoptedCode: { season: 2019, episode: 4050000 } }, + { youtubeId: 'bbbbbbbbbbb', info: {}, adoptedCode: { season: 2019, episode: 4050000 } }, + ]); + expect(numbersOf(result)).toEqual({ + aaaaaaaaaaa: '2019/4050000/adopted', + bbbbbbbbbbb: '2019/4050001/date' + }); + }); + + it('reports videos it cannot number', () => { + const result = assignDateEpisodes([{ youtubeId: 'aaaaaaaaaaa', info: { title: 'no dates' } }]); + expect(result).toEqual({ assigned: [], unnumbered: ['aaaaaaaaaaa'] }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/episodeNaming.test.js b/server/modules/tvShows/__tests__/episodeNaming.test.js new file mode 100644 index 000000000..bef162ab1 --- /dev/null +++ b/server/modules/tvShows/__tests__/episodeNaming.test.js @@ -0,0 +1,93 @@ +const { seasonFolderName, episodeCode, buildEpisodeStem, EPISODE_TITLE_MAX_BYTES } = require('../episodeNaming'); +const { SEASON_FOLDER_PATTERN } = require('../../filesystem/constants'); + +describe('episodeNaming', () => { + describe('seasonFolderName', () => { + it.each([ + [0, 'Season 00'], + [1, 'Season 01'], + [12, 'Season 12'], + [150, 'Season 150'], + [2024, 'Season 2024'], + ])('names season %i "%s"', (season, expected) => { + expect(seasonFolderName(season)).toBe(expected); + }); + + it('produces names the cleanup recognizes as season folders', () => { + expect([0, 7, 2024].map(seasonFolderName).every((name) => SEASON_FOLDER_PATTERN.test(name))).toBe(true); + }); + + it.each([[-1], [1.5], [NaN], ['1']])('rejects %p', (season) => { + expect(() => seasonFolderName(season)).toThrow(TypeError); + }); + }); + + describe('episodeCode', () => { + it('pads date-numbered episodes to 8 digits', () => { + expect(episodeCode({ season: 2024, episode: 1151200, dateNumbered: true })).toBe('S2024E01151200'); + }); + + it('keeps 8 digits for a December date', () => { + expect(episodeCode({ season: 2024, episode: 12312359, dateNumbered: true })).toBe('S2024E12312359'); + }); + + it('pads title and order numbers to 2 digits', () => { + expect(episodeCode({ season: 1, episode: 5 })).toBe('S01E05'); + }); + + it('does not cut numbers wider than the padding', () => { + expect(episodeCode({ season: 3, episode: 120 })).toBe('S03E120'); + }); + + it('writes season 0 as S00', () => { + expect(episodeCode({ season: 0, episode: 3 })).toBe('S00E03'); + }); + + it('rejects a missing episode number', () => { + expect(() => episodeCode({ season: 1 })).toThrow(TypeError); + }); + }); + + describe('buildEpisodeStem', () => { + const base = { season: 1, episode: 20, youtubeId: 'y7xVT7DTt2k' }; + + it('puts the episode code first and the video id last', () => { + expect(buildEpisodeStem({ ...base, episodeTitle: 'It\'s All Relative' })) + .toBe('S01E20 - It\'s All Relative [y7xVT7DTt2k]'); + }); + + it('uses the date-numbered code for date shows', () => { + expect(buildEpisodeStem({ season: 2024, episode: 3151200, dateNumbered: true, videoTitle: 'Vlog', youtubeId: 'abcdefghijk' })) + .toBe('S2024E03151200 - Vlog [abcdefghijk]'); + }); + + it('falls back to the video title when the episode title is empty', () => { + expect(buildEpisodeStem({ ...base, episodeTitle: ' ', videoTitle: 'BEYBLADE EN Episode 20: It\'s All Relative' })) + .toBe('S01E20 - BEYBLADE EN Episode 20: It\'s All Relative [y7xVT7DTt2k]'); + }); + + it('trims whitespace around a captured title', () => { + expect(buildEpisodeStem({ ...base, episodeTitle: ' Under the Microscope ' })) + .toBe('S01E20 - Under the Microscope [y7xVT7DTt2k]'); + }); + + it('sanitizes the title like a yt-dlp title field', () => { + expect(buildEpisodeStem({ ...base, episodeTitle: 'AC/DC: Live? 12:30' })) + .toBe('S01E20 - AC⧸DC: Live? 12_30 [y7xVT7DTt2k]'); + }); + + it(`cuts the title to ${EPISODE_TITLE_MAX_BYTES} bytes`, () => { + const stem = buildEpisodeStem({ ...base, episodeTitle: 'あ'.repeat(40) }); + const title = stem.slice('S01E20 - '.length, -' [y7xVT7DTt2k]'.length); + expect({ bytes: Buffer.byteLength(title, 'utf8'), title }).toEqual({ bytes: 63, title: 'あ'.repeat(21) }); + }); + + it('omits the title separator when there is no title at all', () => { + expect(buildEpisodeStem({ ...base })).toBe('S01E20 [y7xVT7DTt2k]'); + }); + + it('rejects a value that is not a video id', () => { + expect(() => buildEpisodeStem({ ...base, youtubeId: '../escape' })).toThrow(TypeError); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/fixtures/channelTitleCorpus.json b/server/modules/tvShows/__tests__/fixtures/channelTitleCorpus.json new file mode 100644 index 000000000..a82a95496 --- /dev/null +++ b/server/modules/tvShows/__tests__/fixtures/channelTitleCorpus.json @@ -0,0 +1,899 @@ +{ + "description": "Real channel titles with the classification Youtarr title shows should produce, for testing title patterns. Titles come from flat channel listings fetched on 2026-10-02 with yt-dlp 2026.09.27; listingIndex is the position in that listing (0 = newest), the only upload order a never-downloaded video has. Patterns use the simple title-pattern syntax. status: assigned, duplicate (another upload holds the number; earliest available upload wins), unsupported (compilation or part, not placed yet), unmatched (no show; intended = the manual assignment that fixes format drift). An order-numbered episode has episodeSource instead of a number.", + "channels": [ + { + "channelId": "UCDrqiuwNRbEahL1UEB0hkKQ", + "name": "BEYBLADE Official", + "url": "https://www.youtube.com/channel/UCDrqiuwNRbEahL1UEB0hkKQ/videos", + "listingSize": 522, + "shows": [ + { + "key": "beyblade", + "name": "Beyblade", + "patterns": [ + { + "pattern": "BEYBLADE EN Episode {episode}: {title}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + }, + { + "pattern": "BEYBLADE VFORCE EN Episode {episode}: {title}", + "season": "fixed", + "seasonFixed": 2, + "episode": "title" + }, + { + "pattern": "BEYBLADE GREVOLUTION EN Episode {episode}: {title}", + "season": "fixed", + "seasonFixed": 3, + "episode": "title" + } + ], + "excludeTerms": [], + "seasonNames": { + "1": "Beyblade", + "2": "V-Force", + "3": "G-Revolution" + } + } + ], + "videos": [ + { + "id": "gMiqfnp96ps", + "title": "BEYBLADE BPO EPISÓDIO 20: IT’S ALL RELATIVE / TUDO É RELATIVO", + "duration": 1319, + "listingIndex": 0, + "expected": { + "status": "unmatched", + "note": "BPO dub, title also carries the English episode title" + } + }, + { + "id": "JN49nXLOqQI", + "title": "BEYBLADE EN Episode 20: It's All Relative", + "duration": 1285, + "listingIndex": 1, + "expected": { + "status": "duplicate", + "show": "beyblade", + "season": 1, + "episode": 20, + "duplicateOf": "y7xVT7DTt2k", + "note": "2022 re-upload of the 2020 upload" + } + }, + { + "id": "wSsbSX4njYs", + "title": "BEYBLADE PT-BR EPISÓDIO 6: TEMPESTADE DRAGOON", + "duration": 1319, + "listingIndex": 43, + "expected": { + "status": "unmatched", + "note": "Brazilian Portuguese dub" + } + }, + { + "id": "J88TiNAqAbc", + "title": "BEYBLADE EN Episode 2: Day of the Dragoon", + "duration": 1285, + "listingIndex": 54, + "expected": { + "status": "duplicate", + "show": "beyblade", + "season": 1, + "episode": 2, + "duplicateOf": "3RroYKe_-2s", + "note": "2022 re-upload of the 2020 upload" + } + }, + { + "id": "9VVBR3_LRak", + "title": "BEYBLADE EN Episode 1: The Blade Raider", + "duration": 1285, + "listingIndex": 59, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 1, + "episode": 1, + "episodeTitle": "The Blade Raider" + } + }, + { + "id": "L8mK8TARwl4", + "title": "BEYBLADE G REVOLUTION PT-BR EPISÓDIO 52: BEYLUTA PARA SEMPRE", + "duration": 1318, + "listingIndex": 60, + "expected": { + "status": "unmatched", + "note": "G Revolution Brazilian Portuguese dub" + } + }, + { + "id": "bZZVbrJFEtQ", + "title": "BEYBLADE VFORCE PT-BR EPISÓDIO 51: O DESTINO DA ÚLTIMA LUTA.", + "duration": 1318, + "listingIndex": 112, + "expected": { + "status": "unmatched", + "note": "V-Force Brazilian Portuguese dub" + } + }, + { + "id": "soQjUiSQef4", + "title": "BEYBLADE GREVOLUTION EN Episode 52: BEYBATTLE FOR THE AGES", + "duration": 1317, + "listingIndex": 128, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 3, + "episode": 52, + "episodeTitle": "BEYBATTLE FOR THE AGES" + } + }, + { + "id": "ZR53V61W4lk", + "title": "BEYBLADE GREVOLUTION ES-LA Episodio 52: BEYBATALLA PARA TODAS LAS ERAS", + "duration": 1318, + "listingIndex": 129, + "expected": { + "status": "unmatched", + "note": "G-Revolution Latin American Spanish dub" + } + }, + { + "id": "Om0XY6vX_Hw", + "title": "BEYBLADE GREVOLUTION ES-LA Episodio 20: LA CARGA DE UN CAMPEÓN", + "duration": 1318, + "listingIndex": 226, + "expected": { + "status": "unmatched", + "note": "G-Revolution Latin American Spanish dub, same episode numbers" + } + }, + { + "id": "T5buNkXwy1Q", + "title": "BEYBLADE GREVOLUTION EN Episode 1: New Kid in Town", + "duration": 1317, + "listingIndex": 283, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 3, + "episode": 1, + "episodeTitle": "New Kid in Town" + } + }, + { + "id": "W3sL5CTa8AY", + "title": "BEYBLADE VFORCE EN Episode 51: Destiny of the Final Battle", + "duration": 1317, + "listingIndex": 290, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 2, + "episode": 51, + "episodeTitle": "Destiny of the Final Battle" + } + }, + { + "id": "qEiNl1VBlr4", + "title": "BEYBLADE VFORCE ES-LA Episodio 20: La media hora de poder", + "duration": 1318, + "listingIndex": 380, + "expected": { + "status": "unmatched", + "note": "V-Force Latin American Spanish dub" + } + }, + { + "id": "IRiIXyVSFfQ", + "title": "BEYBLADE VFORCE EN Episode 20: The Power Half Hour!!", + "duration": 1317, + "listingIndex": 381, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 2, + "episode": 20, + "episodeTitle": "The Power Half Hour!!" + } + }, + { + "id": "6fYg2nW3yAo", + "title": "BEYBLADE VFORCE EN Episode 1: Shot Down in Flames!", + "duration": 1318, + "listingIndex": 419, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 2, + "episode": 1, + "episodeTitle": "Shot Down in Flames!" + } + }, + { + "id": "zvOkST_xRQQ", + "title": "BEYBLADE EN Episode 51: Final Showdown", + "duration": 1318, + "listingIndex": 420, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 1, + "episode": 51, + "episodeTitle": "Final Showdown" + } + }, + { + "id": "y7xVT7DTt2k", + "title": "BEYBLADE EN Episode 20: It's All Relative", + "duration": 1285, + "listingIndex": 482, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 1, + "episode": 20, + "episodeTitle": "It's All Relative" + } + }, + { + "id": "3RroYKe_-2s", + "title": "BEYBLADE EN Episode 2: Day of the Dragoon", + "duration": 1285, + "listingIndex": 519, + "expected": { + "status": "assigned", + "show": "beyblade", + "season": 1, + "episode": 2, + "episodeTitle": "Day of the Dragoon" + } + } + ] + }, + { + "channelId": "UCktgoAFaL39_rYfiMZiD9jw", + "name": "BEYBLADE English - Official Channel", + "url": "https://www.youtube.com/@BeybladeOfficial/videos", + "listingSize": 1161, + "shows": [ + { + "key": "quadstrike", + "name": "Beyblade Burst QuadStrike", + "patterns": [ + { + "pattern": "BEYBLADE BURST QUADSTRIKE Episode {episode}: {title}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + }, + { + "pattern": "BEYBLADE BURST QUADSTRIKE Episode {episode} Part {part}: {title}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + }, + { + "pattern": "BEYBLADE BURST QUADSTRIKE EP{episode}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + } + ], + "excludeTerms": [ + "Official Clip" + ], + "seasonNames": {} + }, + { + "key": "quadstrike-clips", + "name": "Beyblade Burst QuadStrike Clips", + "patterns": [ + { + "pattern": "{title} | BEYBLADE BURST QUADSTRIKE EP* | Official Clip", + "season": "fixed", + "seasonFixed": 1, + "episode": "order" + } + ], + "excludeTerms": [], + "seasonNames": {} + }, + { + "key": "burst-evolution", + "name": "Beyblade Burst Evolution", + "patterns": [ + { + "pattern": "BEYBLADE BURST EVOLUTION Episode {episode}: {title}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + }, + { + "pattern": "BEYBLADE BURST EVOLUTION | Ep.{episode} * | Ep.{episode_end} *", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + } + ], + "excludeTerms": [], + "seasonNames": {} + }, + { + "key": "metal-fusion", + "name": "Beyblade Metal Fusion", + "patterns": [ + { + "pattern": "BEYBLADE METAL FUSION | Ep.{episode} {title}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + } + ], + "excludeTerms": [], + "seasonNames": {} + } + ], + "videos": [ + { + "id": "zq7eaJqcloo", + "title": "Find the ODD One Out 🌀 BEYBLADE X Challenge! 💥", + "duration": 188, + "listingIndex": 1, + "expected": { + "status": "unmatched", + "note": "loose video" + } + }, + { + "id": "AYTF71Kjy88", + "title": "BEYBLADE METAL FUSION | Ep.31 The Twin Gemios", + "duration": 1324, + "listingIndex": 2, + "expected": { + "status": "assigned", + "show": "metal-fusion", + "season": 1, + "episode": 31, + "episodeTitle": "The Twin Gemios" + } + }, + { + "id": "YA12xsZKqwQ", + "title": "BEYBLADE METAL FUSION | Ep.30 The Bewitching Pisces", + "duration": 1324, + "listingIndex": 7, + "expected": { + "status": "assigned", + "show": "metal-fusion", + "season": 1, + "episode": 30, + "episodeTitle": "The Bewitching Pisces" + } + }, + { + "id": "ZYHLxq3DlF0", + "title": "TV Anime “BEYBLADE X”: TOMORROW X TOGETHER “Rise” Non-credit opening [90 sec] 🎶", + "duration": 90, + "listingIndex": 9, + "expected": { + "status": "unmatched", + "note": "opening theme" + } + }, + { + "id": "q8l9UGsmvWs", + "title": "BEYBLADE METAL FUSION | Ep.1 Pegasus Has Landed!", + "duration": 1324, + "listingIndex": 129, + "expected": { + "status": "assigned", + "show": "metal-fusion", + "season": 1, + "episode": 1, + "episodeTitle": "Pegasus Has Landed!" + } + }, + { + "id": "EWnfVnzPy5I", + "title": "BEYBLADE BURST EVOLUTION | Ep.19 Secret Fire! Red Eye! | Ep.20 New Teammates! New Rivals!", + "duration": 2416, + "listingIndex": 220, + "expected": { + "status": "unsupported", + "show": "burst-evolution", + "reason": "compilation", + "season": 1, + "episode": 19, + "episodeEnd": 20, + "note": "both episodes also uploaded singly" + } + }, + { + "id": "3kH46KffELs", + "title": "BEYBLADE GREVOLUTION | Ep.19 One for All...Free for All! | Ep.20 Burdens of a Champion", + "duration": 2634, + "listingIndex": 356, + "expected": { + "status": "unmatched", + "note": "G-Revolution compilation; no show for that series here" + } + }, + { + "id": "WsznwjmoqAs", + "title": "Belfyre faces Pandora in a fierce battle | BEYBLADE BURST QUADSTRIKE EP1 | Official Clip", + "duration": 145, + "listingIndex": 651, + "expected": { + "status": "assigned", + "show": "quadstrike-clips", + "season": 1, + "episodeSource": "order", + "episodeTitle": "Belfyre faces Pandora in a fierce battle" + } + }, + { + "id": "2fVmOfAAWnM", + "title": "Bel battles Quadra | BEYBLADE BURST QUADSTRIKE EP1 | Official Clip", + "duration": 75, + "listingIndex": 655, + "expected": { + "status": "assigned", + "show": "quadstrike-clips", + "season": 1, + "episodeSource": "order", + "episodeTitle": "Bel battles Quadra", + "note": "matches the quadstrike EP pattern but its exclude term passes it on" + } + }, + { + "id": "3FuWJj8suI4", + "title": "The secrets of the hidden cave | BEYBLADE BURST QUADSTRIKE EP1 | Official Clip", + "duration": 152, + "listingIndex": 657, + "expected": { + "status": "assigned", + "show": "quadstrike-clips", + "season": 1, + "episodeSource": "order", + "episodeTitle": "The secrets of the hidden cave" + } + }, + { + "id": "M-mZ8j1xzf4", + "title": "BEYBLADE BURST QUADSTRIKE Episode 1 Part 2: Thunder and Lightning! Elemental Power!", + "duration": 570, + "listingIndex": 775, + "expected": { + "status": "unsupported", + "show": "quadstrike", + "reason": "part", + "season": 1, + "episode": 1, + "part": 2 + } + }, + { + "id": "ae1ge_MqAxs", + "title": "BEYBLADE BURST QUADSTRIKE Episode 1 Part 1: Thunder and Lightning! Elemental Power!", + "duration": 879, + "listingIndex": 776, + "expected": { + "status": "unsupported", + "show": "quadstrike", + "reason": "part", + "season": 1, + "episode": 1, + "part": 1 + } + }, + { + "id": "Ux53C6pjlvE", + "title": "BEYBLADE BURST EVOLUTION Episode 20: New Teammates! New Rivals!", + "duration": 1365, + "listingIndex": 1096, + "expected": { + "status": "assigned", + "show": "burst-evolution", + "season": 1, + "episode": 20, + "episodeTitle": "New Teammates! New Rivals!" + } + }, + { + "id": "EdOi3V3ufx8", + "title": "BEYBLADE BURST EVOLUTION Episode 19: Secret Fire! Red Eye!", + "duration": 1367, + "listingIndex": 1097, + "expected": { + "status": "assigned", + "show": "burst-evolution", + "season": 1, + "episode": 19, + "episodeTitle": "Secret Fire! Red Eye!" + } + }, + { + "id": "1R1YrGoqKXQ", + "title": "BEYBLADE BURST EVOLUTION Episode 1: Fresh Start! Valtryek's Evolution!", + "duration": 1340, + "listingIndex": 1122, + "expected": { + "status": "assigned", + "show": "burst-evolution", + "season": 1, + "episode": 1, + "episodeTitle": "Fresh Start! Valtryek's Evolution!" + } + } + ] + }, + { + "channelId": "UCR9Gcq0CMm6YgTzsDxAxjOQ", + "name": "Grian", + "url": "https://www.youtube.com/@grian/videos", + "listingSize": 836, + "shows": [ + { + "key": "hermitcraft", + "name": "Hermitcraft", + "patterns": [ + { + "pattern": "Hermitcraft {season}: Episode {episode} - {title}", + "season": "title", + "episode": "title" + } + ], + "excludeTerms": [], + "seasonNames": {} + }, + { + "key": "life-series", + "name": "Life Series", + "patterns": [ + { + "pattern": "3rd Life: Episode {episode} - {title}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + }, + { + "pattern": "Last Life: Episode {episode} - {title}", + "season": "fixed", + "seasonFixed": 2, + "episode": "title" + }, + { + "pattern": "Double Life: Episode {episode} - {title}", + "season": "fixed", + "seasonFixed": 3, + "episode": "title" + }, + { + "pattern": "Limited Life: Episode {episode} - {title}", + "season": "fixed", + "seasonFixed": 4, + "episode": "title" + }, + { + "pattern": "Secret Life: Episode {episode} - {title}", + "season": "fixed", + "seasonFixed": 5, + "episode": "title" + }, + { + "pattern": "Wild Life: Episode {episode} - {title}", + "season": "fixed", + "seasonFixed": 6, + "episode": "title" + } + ], + "excludeTerms": [], + "seasonNames": { + "1": "3rd Life", + "2": "Last Life", + "3": "Double Life", + "4": "Limited Life", + "5": "Secret Life", + "6": "Wild Life" + } + }, + { + "key": "evolution", + "name": "Minecraft Evolution", + "patterns": [ + { + "pattern": "Minecraft Evo: Episode {episode} - {title}", + "season": "fixed", + "seasonFixed": 1, + "episode": "title" + } + ], + "excludeTerms": [], + "seasonNames": {} + } + ], + "videos": [ + { + "id": "X3NqE6rj_AY", + "title": "Playing Minecraft Dungeons II For the FIRST TIME!", + "duration": 3208, + "listingIndex": 0, + "expected": { + "status": "unmatched", + "note": "loose video" + } + }, + { + "id": "E_RXOSNzztg", + "title": "Hermitcraft 11: Episode 28 - TANGO'S GAME!", + "duration": 2288, + "listingIndex": 1, + "expected": { + "status": "assigned", + "show": "hermitcraft", + "season": 11, + "episode": 28, + "episodeTitle": "TANGO'S GAME!" + } + }, + { + "id": "k6kvRUbPp_0", + "title": "Hermit Master in REAL LIFE ", + "duration": 3986, + "listingIndex": 17, + "expected": { + "status": "unmatched", + "note": "loose video with \"LIFE\" in the title" + } + }, + { + "id": "9O8wxI1VAs0", + "title": "Hermitcraft 10: Episode 43 - THE LAST DAY!", + "duration": 5416, + "listingIndex": 36, + "expected": { + "status": "assigned", + "show": "hermitcraft", + "season": 10, + "episode": 43, + "episodeTitle": "THE LAST DAY!" + } + }, + { + "id": "4G5seww-ATY", + "title": "Wild Life: Episode 1 - ANYTHING COULD HAPPEN!", + "duration": 2736, + "listingIndex": 81, + "expected": { + "status": "assigned", + "show": "life-series", + "season": 6, + "episode": 1, + "episodeTitle": "ANYTHING COULD HAPPEN!" + } + }, + { + "id": "8XWhMFbxwVk", + "title": "Secret Life: Episode 9 - THE FINALE", + "duration": 1421, + "listingIndex": 118, + "expected": { + "status": "assigned", + "show": "life-series", + "season": 5, + "episode": 9, + "episodeTitle": "THE FINALE" + } + }, + { + "id": "6zMdLcEAenc", + "title": "Secret Life: Episode 8 - A GHOSTLY TASK", + "duration": 1951, + "listingIndex": 120, + "expected": { + "status": "assigned", + "show": "life-series", + "season": 5, + "episode": 8, + "episodeTitle": "A GHOSTLY TASK" + } + }, + { + "id": "gzaIHdjLIyM", + "title": "Limited Life: Episode 1 - 24 HOURS TO LIVE", + "duration": 1485, + "listingIndex": 156, + "expected": { + "status": "assigned", + "show": "life-series", + "season": 4, + "episode": 1, + "episodeTitle": "24 HOURS TO LIVE" + } + }, + { + "id": "UwFbtE4YS7g", + "title": "Double Life: Episode 1 - DOUBLE TROUBLE!", + "duration": 2585, + "listingIndex": 188, + "expected": { + "status": "assigned", + "show": "life-series", + "season": 3, + "episode": 1, + "episodeTitle": "DOUBLE TROUBLE!" + } + }, + { + "id": "mup3zqmXObc", + "title": "Hermitcraft 9: Episode 1 - LET'S BEGIN!", + "duration": 2022, + "listingIndex": 202, + "expected": { + "status": "assigned", + "show": "hermitcraft", + "season": 9, + "episode": 1, + "episodeTitle": "LET'S BEGIN!" + } + }, + { + "id": "tXoc3OHxW9w", + "title": "Last Life: Episode 9 - THE FINALE!", + "duration": 1028, + "listingIndex": 218, + "expected": { + "status": "assigned", + "show": "life-series", + "season": 2, + "episode": 9, + "episodeTitle": "THE FINALE!" + } + }, + { + "id": "jft49jbtbxU", + "title": "3rd Life: Episode 8 - THE FINAL LIFE", + "duration": 1864, + "listingIndex": 249, + "expected": { + "status": "assigned", + "show": "life-series", + "season": 1, + "episode": 8, + "episodeTitle": "THE FINAL LIFE" + } + }, + { + "id": "LtZBJbkDETc", + "title": "The Next Evolution of Minecraft", + "duration": 1287, + "listingIndex": 328, + "expected": { + "status": "unmatched", + "note": "loose video that mentions Evolution" + } + }, + { + "id": "bYDDH8rbWhQ", + "title": "Hermitcraft 7: Episode 1 - HERE WE GO!", + "duration": 1805, + "listingIndex": 342, + "expected": { + "status": "assigned", + "show": "hermitcraft", + "season": 7, + "episode": 1, + "episodeTitle": "HERE WE GO!" + } + }, + { + "id": "neDQk6wrltc", + "title": "Hermitcraft 6: Episode 110 - END OF THE SEASON!", + "duration": 4175, + "listingIndex": 343, + "expected": { + "status": "assigned", + "show": "hermitcraft", + "season": 6, + "episode": 110, + "episodeTitle": "END OF THE SEASON!" + } + }, + { + "id": "fd8oecwIFxs", + "title": "Minecraft Evolution SMP FINALE (Episode 51)", + "duration": 1396, + "listingIndex": 479, + "expected": { + "status": "unmatched", + "note": "format drift", + "intended": { + "show": "evolution", + "season": 1, + "episode": 51 + } + } + }, + { + "id": "vQZuS7SGFk0", + "title": "Minecraft Evo: Episode 50 - THROUGH THE PORTAL!", + "duration": 1187, + "listingIndex": 488, + "expected": { + "status": "assigned", + "show": "evolution", + "season": 1, + "episode": 50, + "episodeTitle": "THROUGH THE PORTAL!" + } + }, + { + "id": "enYrsxLisC8", + "title": "SECRET Staircase Room! - Minecraft Evo Ep. 49", + "duration": 1300, + "listingIndex": 496, + "expected": { + "status": "unmatched", + "note": "format drift", + "intended": { + "show": "evolution", + "season": 1, + "episode": 49 + } + } + }, + { + "id": "D24PS7BJuuQ", + "title": "ONE HOUR OF EVO! - Minecraft Evo 48", + "duration": 3624, + "listingIndex": 497, + "expected": { + "status": "unmatched", + "note": "format drift", + "intended": { + "show": "evolution", + "season": 1, + "episode": 48 + } + } + }, + { + "id": "2M2rM4msjgI", + "title": "SERVER TOUR! - Minecraft Evo SMP #47", + "duration": 1309, + "listingIndex": 500, + "expected": { + "status": "unmatched", + "note": "format drift", + "intended": { + "show": "evolution", + "season": 1, + "episode": 47 + } + } + }, + { + "id": "LWUzXe8cqcY", + "title": "TREASURE HUNT CHOICE! - Minecraft Evo #46", + "duration": 1487, + "listingIndex": 505, + "expected": { + "status": "unmatched", + "note": "format drift", + "intended": { + "show": "evolution", + "season": 1, + "episode": 46 + } + } + } + ] + } + ] +} diff --git a/server/modules/tvShows/__tests__/libraryLayouts.test.js b/server/modules/tvShows/__tests__/libraryLayouts.test.js new file mode 100644 index 000000000..589edadd0 --- /dev/null +++ b/server/modules/tvShows/__tests__/libraryLayouts.test.js @@ -0,0 +1,8 @@ +const { getLayoutResolver, LAYOUT_VIDEOS } = require('../libraryLayouts'); + +describe('libraryLayouts', () => { + it('resolves the main folder and every subfolder to the videos layout', async () => { + const layoutOf = await getLayoutResolver(); + expect(['', 'TV Shows', 'kids'].map(layoutOf)).toEqual([LAYOUT_VIDEOS, LAYOUT_VIDEOS, LAYOUT_VIDEOS]); + }); +}); diff --git a/server/modules/tvShows/__tests__/routing.test.js b/server/modules/tvShows/__tests__/routing.test.js new file mode 100644 index 000000000..4a7c781c3 --- /dev/null +++ b/server/modules/tvShows/__tests__/routing.test.js @@ -0,0 +1,100 @@ +const { resolveDestination } = require('../routing'); + +describe('routing.resolveDestination', () => { + const trackedOwner = { channelId: 'UCowner', folderName: 'Owner Channel', tracked: true, enabled: true }; + const untrackedOwner = { channelId: 'UCother', folderName: 'Other Channel', tracked: false, enabled: false }; + const titleShow = { id: 7, libraryFolder: 'TV Shows', folderName: 'Beyblade' }; + const channelShow = { id: 3, libraryFolder: 'TV Shows', folderName: 'Owner Channel' }; + const tvFolders = (...folders) => (libraryFolder) => (folders.includes(libraryFolder) ? 'tv' : 'videos'); + + it('sends a classified video of a tracked, enabled channel to its title show', () => { + expect(resolveDestination({ + ownerChannel: trackedOwner, + titleShow, + resolvedSubfolder: null, + layoutOf: tvFolders('TV Shows') + })).toEqual({ + layout: 'tv', + kind: 'title', + showId: 7, + channelId: 'UCowner', + libraryFolder: 'TV Shows', + folderName: 'Beyblade' + }); + }); + + it('ignores a dialog override to a videos folder for a title show episode', () => { + const destination = resolveDestination({ + ownerChannel: trackedOwner, + titleShow, + resolvedSubfolder: 'Kids', + layoutOf: tvFolders('TV Shows') + }); + expect(destination.showId).toBe(7); + }); + + it('does not use title shows of a disabled channel', () => { + expect(resolveDestination({ + ownerChannel: { ...trackedOwner, enabled: false }, + titleShow, + resolvedSubfolder: 'Kids', + layoutOf: tvFolders('TV Shows') + })).toEqual({ layout: 'videos', libraryFolder: 'Kids' }); + }); + + it('saves movie-style into the main folder when it uses the videos layout', () => { + expect(resolveDestination({ ownerChannel: trackedOwner, resolvedSubfolder: null, layoutOf: tvFolders() })) + .toEqual({ layout: 'videos', libraryFolder: '' }); + }); + + it('saves movie-style into a videos subfolder even when the channel has a channel show', () => { + expect(resolveDestination({ + ownerChannel: trackedOwner, + channelShow, + resolvedSubfolder: 'Kids', + layoutOf: tvFolders('TV Shows') + })).toEqual({ layout: 'videos', libraryFolder: 'Kids' }); + }); + + it('creates a channel show in the resolved TV folder on first use', () => { + expect(resolveDestination({ ownerChannel: trackedOwner, resolvedSubfolder: 'TV Shows', layoutOf: tvFolders('TV Shows') })) + .toEqual({ + layout: 'tv', + kind: 'channel', + showId: null, + channelId: 'UCowner', + libraryFolder: 'TV Shows', + folderName: 'Owner Channel' + }); + }); + + it('creates a channel show for an untracked channel under a TV global default', () => { + expect(resolveDestination({ ownerChannel: untrackedOwner, resolvedSubfolder: 'TV Shows', layoutOf: tvFolders('TV Shows') })) + .toMatchObject({ kind: 'channel', showId: null, channelId: 'UCother', folderName: 'Other Channel' }); + }); + + it('creates a channel show in the main folder when it is the TV folder', () => { + expect(resolveDestination({ ownerChannel: trackedOwner, resolvedSubfolder: null, layoutOf: tvFolders('') })) + .toMatchObject({ kind: 'channel', libraryFolder: '' }); + }); + + it('keeps an existing channel show where it is when the resolved TV folder differs', () => { + expect(resolveDestination({ + ownerChannel: trackedOwner, + channelShow, + resolvedSubfolder: 'Other TV', + layoutOf: tvFolders('TV Shows', 'Other TV') + })).toMatchObject({ showId: 3, libraryFolder: 'TV Shows', folderName: 'Owner Channel' }); + }); + + it('names a new channel show after the owner channel, not the uploader (VEVO/Topic)', () => { + const owner = { channelId: 'UCartist', folderName: 'Artist', tracked: true, enabled: true }; + expect(resolveDestination({ ownerChannel: owner, resolvedSubfolder: 'TV Shows', layoutOf: tvFolders('TV Shows') })) + .toMatchObject({ channelId: 'UCartist', folderName: 'Artist' }); + }); + + it('refuses to route to a channel show without the owner channel', () => { + expect(() => resolveDestination({ ownerChannel: null, resolvedSubfolder: 'TV Shows', layoutOf: tvFolders('TV Shows') })) + .toThrow(TypeError); + }); +}); diff --git a/server/modules/tvShows/dateNumbering.js b/server/modules/tvShows/dateNumbering.js new file mode 100644 index 000000000..4e2691407 --- /dev/null +++ b/server/modules/tvShows/dateNumbering.js @@ -0,0 +1,179 @@ +/** + * Date numbering for TV shows: season = UTC upload year, episode = MMDDHHMM + * (UTC). This is the numbering the "Plex TV Series" filename preset produces + * (yt-dlp formats %(timestamp>...)s in UTC), so files it named can be adopted + * without renumbering. Two files must never share a number within a show, so + * a taken number is bumped to the next free integer, and numbers are stored + * once assigned rather than recomputed. + */ + +const SOURCE_TIMESTAMP = 'timestamp'; +const SOURCE_UPLOAD_DATE = 'upload_date'; +const MAX_EPISODE_BUMPS = 10000; + +const UPLOAD_DATE_PATTERN = /^(\d{4})(\d{2})(\d{2})$/; +const DATE_EPISODE_CODE_PATTERN = /^S(\d{4})E(\d{8})(?!\d)/; + +/** + * The time a video was uploaded, from its yt-dlp info. `timestamp` is exact; + * info files written before yt-dlp 2024.05.26 lack it for YouTube, so + * `upload_date` (a UTC day) is the fallback, read as 00:00 UTC. + * + * @param {Object} info - yt-dlp info dict + * @returns {{epochSeconds: number, source: string}|null} + */ +function releaseTime(info) { + if (!info) { + return null; + } + if (Number.isFinite(info.timestamp)) { + return { epochSeconds: Math.floor(info.timestamp), source: SOURCE_TIMESTAMP }; + } + const match = typeof info.upload_date === 'string' ? UPLOAD_DATE_PATTERN.exec(info.upload_date) : null; + if (!match) { + return null; + } + const [, year, month, day] = match.map(Number); + const epochMs = Date.UTC(year, month - 1, day); + const date = new Date(epochMs); + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + return null; + } + return { epochSeconds: epochMs / 1000, source: SOURCE_UPLOAD_DATE }; +} + +/** + * @param {number} epochSeconds + * @returns {{season: number, episode: number}} - e.g. 2024-03-15 12:00 UTC is + * season 2024, episode 3151200 (written E03151200 in filenames) + */ +function dateEpisodeFor(epochSeconds) { + const date = new Date(epochSeconds * 1000); + return { + season: date.getUTCFullYear(), + episode: (date.getUTCMonth() + 1) * 1000000 + date.getUTCDate() * 10000 + + date.getUTCHours() * 100 + date.getUTCMinutes() + }; +} + +/** + * First episode number at or after the desired one that is not taken. + * + * @param {number} desiredEpisode + * @param {{has: (episode: number) => boolean}} takenEpisodes - Numbers already used in the season + * @returns {number} + */ +function allocateEpisode(desiredEpisode, takenEpisodes) { + for (let episode = desiredEpisode; episode <= desiredEpisode + MAX_EPISODE_BUMPS; episode++) { + if (!takenEpisodes.has(episode)) { + return episode; + } + } + throw new Error(`No free episode number within ${MAX_EPISODE_BUMPS} of ${desiredEpisode}`); +} + +/** + * Read the date episode code at the start of a filename named by the + * "Plex TV Series" preset ("S2019E04050000 Title [id].mp4") or by Youtarr's + * own date-numbered TV naming. The 8 digits are read as a plain integer, not + * as a time, because a bumped number need not be one: two uploads at 12:59 + * are numbered 01151259 and 01151260. + * + * @param {string} fileName + * @returns {{season: number, episode: number}|null} + */ +function parseDateEpisodeCode(fileName) { + const match = typeof fileName === 'string' ? DATE_EPISODE_CODE_PATTERN.exec(fileName) : null; + if (!match) { + return null; + } + return { season: Number(match[1]), episode: Number(match[2]) }; +} + +function compareIds(a, b) { + if (a === b) { + return 0; + } + return a < b ? -1 : 1; +} + +function takenSetFor(takenBySeason, season) { + if (!takenBySeason.has(season)) { + takenBySeason.set(season, new Set()); + } + return takenBySeason.get(season); +} + +/** + * Number a batch of videos for one date-numbered show, e.g. when converting + * existing downloads. Adopted codes are kept when free; when two files share + * a code, the earlier upload keeps it and the other is bumped from that code. + * Every other video is numbered from its release time, in time then video id + * order, so same-day videos that only have an upload date get consecutive + * numbers in a stable order. A taken number is bumped. + * + * @param {Array<{youtubeId: string, info: Object, adoptedCode?: {season: number, episode: number}}>} videos + * @param {Map<number, Iterable<number>>} [takenBySeason] - Numbers the show already uses; not modified + * @returns {{assigned: Array<{youtubeId: string, season: number, episode: number, source: string, timestampSource: string|null}>, unnumbered: string[]}} + * source is 'adopted' (kept its code) or 'date'; unnumbered lists videos + * with neither an adopted code nor a release time + */ +function assignDateEpisodes(videos, takenBySeason = new Map()) { + const taken = new Map([...takenBySeason].map(([season, episodes]) => [season, new Set(episodes)])); + const assigned = []; + const unnumbered = []; + const toNumber = []; + + const secondsOf = (item) => (item.time ? item.time.epochSeconds : 0); + const byNumbersThenTime = (a, b) => a.numbers.season - b.numbers.season + || a.numbers.episode - b.numbers.episode + || secondsOf(a) - secondsOf(b) + || compareIds(a.video.youtubeId, b.video.youtubeId); + const record = (item, episode, source) => { + takenSetFor(taken, item.numbers.season).add(episode); + assigned.push({ + youtubeId: item.video.youtubeId, + season: item.numbers.season, + episode, + source, + timestampSource: item.time ? item.time.source : null + }); + }; + + const items = videos.map((video) => ({ video, time: releaseTime(video.info) })); + const adopted = items + .filter((item) => item.video.adoptedCode) + .map((item) => ({ ...item, numbers: item.video.adoptedCode })) + .sort(byNumbersThenTime); + for (const item of adopted) { + if (takenSetFor(taken, item.numbers.season).has(item.numbers.episode)) { + toNumber.push(item); + } else { + record(item, item.numbers.episode, 'adopted'); + } + } + + for (const item of items.filter((candidate) => !candidate.video.adoptedCode)) { + if (item.time) { + toNumber.push({ ...item, numbers: dateEpisodeFor(item.time.epochSeconds) }); + } else { + unnumbered.push(item.video.youtubeId); + } + } + + for (const item of toNumber.sort(byNumbersThenTime)) { + record(item, allocateEpisode(item.numbers.episode, takenSetFor(taken, item.numbers.season)), 'date'); + } + + return { assigned, unnumbered }; +} + +module.exports = { + SOURCE_TIMESTAMP, + SOURCE_UPLOAD_DATE, + releaseTime, + dateEpisodeFor, + allocateEpisode, + parseDateEpisodeCode, + assignDateEpisodes +}; diff --git a/server/modules/tvShows/episodeNaming.js b/server/modules/tvShows/episodeNaming.js new file mode 100644 index 000000000..ade8d61a9 --- /dev/null +++ b/server/modules/tvShows/episodeNaming.js @@ -0,0 +1,80 @@ +/** + * Names for TV-layout files and folders: + * <show>/Season 2024/S2024E03151200 - <title> [<id>].mp4 (date-numbered) + * <show>/Season 01/S01E20 - <title> [<id>].mp4 (title or order numbers) + * <show>/Season 00/S00E03 - <title> [<id>].mp4 (season 0, never "Specials") + * SxxEyy comes first so media servers parse it before anything in the title, + * and [id] stays last so every id-based file matcher keeps working. + */ + +const { sanitizeFilenameLikeYtDlp } = require('../filesystem/sanitizer'); +const { YOUTUBE_ID_PATTERN } = require('../filesystem/constants'); + +const EPISODE_TITLE_MAX_BYTES = 64; +const MIN_NUMBER_DIGITS = 2; +const DATE_EPISODE_DIGITS = 8; + +function assertNonNegativeInteger(value, name) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${name} must be a non-negative integer, got ${value}`); + } +} + +function pad(value, digits) { + return String(value).padStart(digits, '0'); +} + +/** + * @param {number} season - Season number (0 for specials, a year for date seasons) + * @returns {string} - "Season 00", "Season 01", "Season 2024" + */ +function seasonFolderName(season) { + assertNonNegativeInteger(season, 'season'); + return `Season ${pad(season, MIN_NUMBER_DIGITS)}`; +} + +/** + * @param {Object} numbers + * @param {number} numbers.season + * @param {number} numbers.episode + * @param {boolean} [numbers.dateNumbered=false] - Episode is MMDDHHMM, zero-padded to 8 digits + * @returns {string} - "S2024E01151200", "S01E20", "S01E100" + */ +function episodeCode({ season, episode, dateNumbered = false }) { + assertNonNegativeInteger(season, 'season'); + assertNonNegativeInteger(episode, 'episode'); + const episodeDigits = dateNumbered ? DATE_EPISODE_DIGITS : MIN_NUMBER_DIGITS; + return `S${pad(season, MIN_NUMBER_DIGITS)}E${pad(episode, episodeDigits)}`; +} + +/** + * Build an episode's file stem (the name without extension; sidecars append + * their own suffix to it). The episode title is used when it has text, else + * the video's YouTube title; it is sanitized like a yt-dlp title field and cut + * to 64 bytes. + * + * @param {Object} episode + * @param {number} episode.season + * @param {number} episode.episode + * @param {boolean} [episode.dateNumbered=false] + * @param {string} [episode.episodeTitle] - Title captured from a title pattern + * @param {string} [episode.videoTitle] - The video's YouTube title + * @param {string} episode.youtubeId + * @returns {string} - e.g. "S01E20 - It's All Relative [y7xVT7DTt2k]" + */ +function buildEpisodeStem({ season, episode, dateNumbered = false, episodeTitle, videoTitle, youtubeId }) { + if (typeof youtubeId !== 'string' || !YOUTUBE_ID_PATTERN.test(youtubeId)) { + throw new TypeError(`youtubeId is not a video ID: ${youtubeId}`); + } + const code = episodeCode({ season, episode, dateNumbered }); + const rawTitle = (episodeTitle || '').trim() || (videoTitle || '').trim(); + const title = sanitizeFilenameLikeYtDlp(rawTitle, { maxBytes: EPISODE_TITLE_MAX_BYTES }); + return title ? `${code} - ${title} [${youtubeId}]` : `${code} [${youtubeId}]`; +} + +module.exports = { + EPISODE_TITLE_MAX_BYTES, + seasonFolderName, + episodeCode, + buildEpisodeStem +}; diff --git a/server/modules/tvShows/index.js b/server/modules/tvShows/index.js new file mode 100644 index 000000000..b26f49d05 --- /dev/null +++ b/server/modules/tvShows/index.js @@ -0,0 +1,21 @@ +/** + * TV show support: channels and title-pattern shows saved as TV shows + * (<library folder>/<show>/Season NN/SxxEyy - Title [id].ext). + * + * - libraryLayouts: the layout (videos or tv) of each library folder + * - episodeNaming: season folder names and episode file stems + * - dateNumbering: upload-time season and episode numbers + * - routing: where a downloaded video belongs + */ + +const libraryLayouts = require('./libraryLayouts'); +const episodeNaming = require('./episodeNaming'); +const dateNumbering = require('./dateNumbering'); +const routing = require('./routing'); + +module.exports = { + libraryLayouts, + episodeNaming, + dateNumbering, + routing +}; diff --git a/server/modules/tvShows/libraryLayouts.js b/server/modules/tvShows/libraryLayouts.js new file mode 100644 index 000000000..0e1a337d7 --- /dev/null +++ b/server/modules/tvShows/libraryLayouts.js @@ -0,0 +1,24 @@ +/** + * Layout of each library folder: the downloads folder itself ('') and each + * __subfolder (named without the prefix). Media servers fix a library's type + * per folder, so a folder holds either movie-style videos or TV shows. + */ + +const LAYOUT_VIDEOS = 'videos'; +const LAYOUT_TV = 'tv'; + +/** + * Resolve library folder layouts. Every folder uses the videos layout until + * folder layouts can be configured. + * + * @returns {Promise<(libraryFolder: string) => string>} + */ +async function getLayoutResolver() { + return () => LAYOUT_VIDEOS; +} + +module.exports = { + LAYOUT_VIDEOS, + LAYOUT_TV, + getLayoutResolver +}; diff --git a/server/modules/tvShows/routing.js b/server/modules/tvShows/routing.js new file mode 100644 index 000000000..b8730af3e --- /dev/null +++ b/server/modules/tvShows/routing.js @@ -0,0 +1,67 @@ +/** + * Where a downloaded video belongs. The post-processor, the reorganize preview + * and the reorganize itself all decide through this one rule: + * 1. A video of a tracked, enabled channel that is classified into one of the + * channel's title shows goes to that show. The download dialog's subfolder + * override does not apply to it. + * 2. Otherwise the folder resolves as it always has (dialog override, tracked + * channel, playlist fallback, global default); callers pass the result in. + * 3. In a TV folder the video joins its owner channel's channel show. Once + * that show exists its stored location wins, so a later default-folder + * change or a dialog override to another TV folder never splits it. A new + * show is named after the owner channel's folder, not the uploader, so + * VEVO/Topic uploads join their owner's show. + * 4. In a videos folder the video is saved movie-style. + */ + +const { LAYOUT_VIDEOS, LAYOUT_TV } = require('./libraryLayouts'); +const { MAIN_LIBRARY_FOLDER } = require('../filesystem/constants'); + +const KIND_TITLE_SHOW = 'title'; +const KIND_CHANNEL_SHOW = 'channel'; + +/** + * @param {Object} inputs + * @param {{channelId: string, folderName: string, tracked: boolean, enabled: boolean}} inputs.ownerChannel + * The channel that owns the video (tracked = it has a channels row) + * @param {{id: number, libraryFolder: string, folderName: string}|null} [inputs.titleShow] + * The owner channel's title show the video is classified into + * @param {{id: number, libraryFolder: string, folderName: string}|null} [inputs.channelShow] + * The owner channel's existing channel show + * @param {string|null} [inputs.resolvedSubfolder] - The resolved subfolder (without __), null for the main folder + * @param {(libraryFolder: string) => string} inputs.layoutOf - Layout of a library folder ('' = main) + * @returns {{layout: 'videos', libraryFolder: string} + * | {layout: 'tv', kind: string, showId: number|null, channelId: string, libraryFolder: string, folderName: string}} + * showId null means the channel show does not exist yet and is created there + */ +function resolveDestination({ ownerChannel, titleShow = null, channelShow = null, resolvedSubfolder = null, layoutOf }) { + if (titleShow && ownerChannel && ownerChannel.tracked && ownerChannel.enabled) { + return { + layout: LAYOUT_TV, + kind: KIND_TITLE_SHOW, + showId: titleShow.id, + channelId: ownerChannel.channelId, + libraryFolder: titleShow.libraryFolder, + folderName: titleShow.folderName + }; + } + + const libraryFolder = resolvedSubfolder || MAIN_LIBRARY_FOLDER; + if (layoutOf(libraryFolder) !== LAYOUT_TV) { + return { layout: LAYOUT_VIDEOS, libraryFolder }; + } + + if (!ownerChannel || !ownerChannel.channelId || !ownerChannel.folderName) { + throw new TypeError('Routing to a channel show needs the owner channel id and folder name'); + } + const location = channelShow + ? { showId: channelShow.id, libraryFolder: channelShow.libraryFolder, folderName: channelShow.folderName } + : { showId: null, libraryFolder, folderName: ownerChannel.folderName }; + return { layout: LAYOUT_TV, kind: KIND_CHANNEL_SHOW, channelId: ownerChannel.channelId, ...location }; +} + +module.exports = { + KIND_TITLE_SHOW, + KIND_CHANNEL_SHOW, + resolveDestination +}; diff --git a/server/modules/videoDeletionModule.js b/server/modules/videoDeletionModule.js index 64a090102..c2bffccf0 100644 --- a/server/modules/videoDeletionModule.js +++ b/server/modules/videoDeletionModule.js @@ -3,7 +3,9 @@ const fs = require('fs').promises; const path = require('path'); const logger = require('../logger'); const configModule = require('./configModule'); -const { isVideoDirectoryFor, isFileForVideo, cleanupEmptyChannelDirectory, cleanupEmptyParents, isSubfolderDir, listSubdirectories, removeDirectoryResilient } = require('./filesystem'); +const { isVideoDirectoryFor, isFileForVideo, cleanupEmptyChannelDirectory, cleanupEmptyParents, isSubfolderDir, extractSubfolderName, listSubdirectories, removeDirectoryResilient, resolveLibraryFolder, locateEpisodeFolders, cleanupEmptyShowFolders, cleanupOrphanShowFolder } = require('./filesystem'); +const { MAIN_LIBRARY_FOLDER } = require('./filesystem/constants'); +const libraryLayouts = require('./tvShows/libraryLayouts'); const m3uGenerator = require('./m3uGenerator'); const storageUsage = require('./storageUsage'); const { STORED_BYTES_SQL } = storageUsage; @@ -71,6 +73,10 @@ class VideoDeletionModule { try { const baseDir = configModule.directoryPath; + if (flat && await this._tryCleanupShowFolders(filePath, baseDir)) { + return; + } + // Derive channel directory: // Nested: grandparent of filePath (filePath -> videoDir -> channelDir) // Flat: parent of filePath (filePath -> channelDir) @@ -91,6 +97,33 @@ class VideoDeletionModule { } } + /** + * For an episode in a TV library folder, remove its season folder and then + * its show folder once only metadata and art remain in them. + * @param {string} filePath - The deleted episode's file path + * @param {string} baseDir - The downloads root + * @returns {Promise<boolean>} - True if the file was a TV episode + * @private + */ + async _tryCleanupShowFolders(filePath, baseDir) { + const library = resolveLibraryFolder(filePath, baseDir); + if (!library) { + return false; + } + const layoutOf = await libraryLayouts.getLayoutResolver(); + const folders = layoutOf(library.libraryFolder) === libraryLayouts.LAYOUT_TV + ? locateEpisodeFolders(filePath, library.libraryRoot) + : null; + if (!folders) { + return false; + } + const { removedShow } = await cleanupEmptyShowFolders(folders); + if (removedShow) { + await cleanupEmptyParents(library.libraryRoot, baseDir); + } + return true; + } + /** * Delete a single video by ID * Deletes the video directory from disk and marks the video as removed in the database @@ -443,7 +476,8 @@ ${excludeClause} ORDER BY timeCreated ASC * Scan the output directory for orphan empty channel directories and remove them. * Unlike _tryCleanupChannelDirectory (which only runs after a video deletion), this * proactively finds directories that are already empty (or contain only ignorable files - * like poster.jpg) and cleans them up. Handles both root-level and subfolder-level channels. + * like poster.jpg) and cleans them up. Handles both root-level and subfolder-level channels, + * and the show and season folders of TV library folders. * @returns {Promise<{removed: string[], errors: string[]}>} */ async cleanupOrphanDirectories() { @@ -457,21 +491,27 @@ ${excludeClause} ORDER BY timeCreated ASC } try { + const layoutOf = await libraryLayouts.getLayoutResolver(); const topLevelDirs = await listSubdirectories(baseDir); for (const dir of topLevelDirs) { const dirName = path.basename(dir); if (isSubfolderDir(dirName)) { - // Subfolder directory (e.g., __Music) — check its children as channel dirs + // Subfolder directory (e.g., __Music) — check its children as channel or show dirs try { - const channelDirs = await listSubdirectories(dir); - for (const channelDir of channelDirs) { - const wasRemoved = await cleanupEmptyChannelDirectory(channelDir, baseDir, { + const childDirs = await listSubdirectories(dir); + const isTv = layoutOf(extractSubfolderName(dirName)) === libraryLayouts.LAYOUT_TV; + for (const childDir of childDirs) { + if (isTv) { + removed.push(...await cleanupOrphanShowFolder(childDir)); + continue; + } + const wasRemoved = await cleanupEmptyChannelDirectory(childDir, baseDir, { includeIgnorableFiles: true }); if (wasRemoved) { - removed.push(channelDir); + removed.push(childDir); } } // Clean up the subfolder itself if it's now empty @@ -480,6 +520,9 @@ ${excludeClause} ORDER BY timeCreated ASC logger.warn({ err: dirError, dir }, '[Orphan Cleanup] Error processing subfolder directory'); errors.push(dirError.message); } + } else if (layoutOf(MAIN_LIBRARY_FOLDER) === libraryLayouts.LAYOUT_TV) { + // Root-level show directory + removed.push(...await cleanupOrphanShowFolder(dir)); } else { // Root-level channel directory const wasRemoved = await cleanupEmptyChannelDirectory(dir, baseDir, { @@ -492,7 +535,7 @@ ${excludeClause} ORDER BY timeCreated ASC } if (removed.length > 0) { - logger.info({ count: removed.length, directories: removed }, '[Orphan Cleanup] Removed empty channel directories'); + logger.info({ count: removed.length, directories: removed }, '[Orphan Cleanup] Removed empty channel and show directories'); } else { logger.debug('[Orphan Cleanup] No orphan directories found'); } diff --git a/server/modules/videoDownloadPostProcessFiles.js b/server/modules/videoDownloadPostProcessFiles.js index 46910f3ff..ea5ed5e7d 100644 --- a/server/modules/videoDownloadPostProcessFiles.js +++ b/server/modules/videoDownloadPostProcessFiles.js @@ -691,6 +691,7 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { // Downloads are always staged in temp, so we move to final location here // This handles subfolder routing atomically (one move instead of two) let finalVideoPath = videoPath; + let movedChannelFolderPath = null; if (tempPathManager.isTempPath(videoPath)) { logger.info({ isFlatMode }, '[Post-Process] Moving files from temp to final location'); @@ -798,6 +799,7 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { ? videoDirectory : (isFlatMode ? videoDirectory : path.dirname(videoDirectory)); await cleanupEmptyParents(parentDir, tempBasePath); + movedChannelFolderPath = targetChannelFolderForMove; // Verify the final file exists if (!fs.existsSync(finalVideoPath)) { @@ -917,11 +919,11 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { } // Copy channel thumbnail as poster.jpg to channel folder (must be done AFTER all moves) - // Calculate the final channel folder path based on the final video path - // In flat mode, the file is directly in the channel folder - const finalChannelFolderPath = outgoingFlat + // Use the channel folder the move wrote to; without a move, derive it from + // the final video path (in flat mode, the file is directly in the channel folder) + const finalChannelFolderPath = movedChannelFolderPath || (outgoingFlat ? path.dirname(finalVideoPath) - : path.dirname(path.dirname(finalVideoPath)); + : path.dirname(path.dirname(finalVideoPath))); if (jsonData.channel_id) { await copyChannelPosterIfNeeded(jsonData.channel_id, finalChannelFolderPath); await copyChannelBackdropIfNeeded(jsonData.channel_id, finalChannelFolderPath); From db8cce3492134aec71bbf3cab653ff62b458b640 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Fri, 2 Oct 2026 14:53:14 -0700 Subject: [PATCH 02/12] fix: include TV library episodes in Plex playlists Plex playlist sync could not find videos stored in a TV (show) library section, such as files named with the Plex TV Series preset, so those videos were left out of the synced playlists. The file lookup listed show sections without a type filter, which appears to return only show-level items with no file paths. Watch status sync already lists show sections with type=4 (episodes). Request type=4 for show sections when resolving playlist files too. An episode listing covers every show on the server and is fetched again on each resolve round, so show sections are only listed when a file in the batch is named like an episode (SxxEyy). Batches of movie-style files skip them, which keeps a large unrelated TV library from being listed episode by episode. Refs: #175 --- CLAUDE.md | 2 +- .../adapters/__tests__/plexAdapter.test.js | 72 ++++++++++++++++++- .../mediaServers/adapters/plexAdapter.js | 17 ++++- 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e78895246..555b18c51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/tvShows/` (TV show support, in progress; nothing user-visible yet): `libraryLayouts` (layout of each library folder, the main folder `''` or a subfolder name; every folder resolves to `videos` until layouts are stored), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. -- `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). +- `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10, and episodes by listing show sections with type=4, only when a file in the batch is named SxxEyy), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). - `modules/subscriptionImport/`: bulk channel import (`importJobRunner`, `takeoutParser`, `cookiesFetcher`, `thumbnailEnricher`, `concurrencyLimiter`, `errorClassifier`). ### Frontend (client/src/) diff --git a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js index c00cc6257..20a5d44b6 100644 --- a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js @@ -185,7 +185,8 @@ describe('PlexAdapter', () => { ]); axios.get.mockResolvedValue({ data: { MediaContainer: { Metadata: [] } } }); const adapter = new PlexAdapter(cfg); - await adapter.resolveItemIdByFilepath('/data/x.mp4'); + // An episode name, so the show section is searched too. + await adapter.resolveItemIdByFilepath('/data/Show/Season 01/S01E01 - x [x1].mp4'); // enumeration + sections: 2 (configured), 37 (movie), 42 (show). Music/photo skipped. expect(axios.get).toHaveBeenCalledTimes(4); const queried = axios.get.mock.calls.slice(1).map((c) => c[0]); @@ -290,6 +291,75 @@ describe('PlexAdapter', () => { }); }); + describe('show (TV) sections', () => { + const MOVIE_SECTION = { key: '2', type: 'movie', title: 'YouTube' }; + const SHOW_SECTION = { key: '5', type: 'show', title: 'YouTube TV' }; + const EPISODE_FILE = '/plex/__TV/Chan/Season 2024/S2024E03151200 - Ep [vid1].mp4'; + const MOVIE_FILE = '/plex/Chan/Clip [vid2] - vid2/Clip [vid2].mp4'; + const item = (ratingKey, file) => ({ ratingKey, Media: [{ Part: [{ file }] }] }); + + // Mimics Plex: a show section's default /all lists show items, which carry + // no file parts; only type=4 lists its episodes. A movie section lists its + // files without a type, and nothing for the episode type. + const mockServer = (sections) => { + axios.get.mockImplementation(async (url, { params }) => { + if (url.endsWith('/library/sections')) { + return { data: { MediaContainer: { Directory: sections } } }; + } + if (url.endsWith('/library/sections/2/all')) { + return { data: { MediaContainer: { Metadata: params.type === undefined ? [item('201', MOVIE_FILE)] : [] } } }; + } + if (url.endsWith('/library/sections/5/all')) { + const metadata = params.type === 4 ? [item('501', EPISODE_FILE)] : [{ ratingKey: '500', title: 'Chan' }]; + return { data: { MediaContainer: { Metadata: metadata } } }; + } + throw new Error(`unexpected request ${url}`); + }); + }; + + // clearAllMocks keeps implementations, so mockServer would leak into later tests. + afterEach(() => axios.get.mockReset()); + + test('resolves an episode file in a show section', async () => { + mockServer([MOVIE_SECTION, SHOW_SECTION]); + const adapter = new PlexAdapter(cfg); + const id = await adapter.resolveItemIdByFilepath('/data/__TV/Chan/Season 2024/S2024E03151200 - Ep [vid1].mp4'); + expect(id).toBe('501'); + }); + + test('resolves a file in a movie section listed beside a show section', async () => { + mockServer([MOVIE_SECTION, SHOW_SECTION]); + const adapter = new PlexAdapter(cfg); + const id = await adapter.resolveItemIdByFilepath('/data/Chan/Clip [vid2] - vid2/Clip [vid2].mp4'); + expect(id).toBe('201'); + }); + + test('does not list show sections when no file in the batch has an episode name', async () => { + mockServer([MOVIE_SECTION, SHOW_SECTION]); + const adapter = new PlexAdapter(cfg); + await adapter.resolveItemIdByFilepath('/data/Chan/Clip [vid2] - vid2/Clip [vid2].mp4'); + const queried = axios.get.mock.calls.map((c) => c[0]); + expect(queried).not.toContain('http://plex:32400/library/sections/5/all'); + }); + + test('resolves movie-style and episode files in one batch', async () => { + mockServer([MOVIE_SECTION, SHOW_SECTION]); + const adapter = new PlexAdapter(cfg); + const resolved = await adapter.resolveItemIdsByFilepaths([ + '/data/Chan/Clip [vid2] - vid2/Clip [vid2].mp4', + '/data/__TV/Chan/Season 2024/S2024E03151200 - Ep [vid1].mp4', + ]); + expect([...resolved.values()]).toEqual(['201', '501']); + }); + + test('resolves an episode when the configured library is a show section', async () => { + mockServer([SHOW_SECTION]); + const adapter = new PlexAdapter({ ...cfg, plexYoutubeLibraryId: '5' }); + const id = await adapter.resolveItemIdByFilepath('/data/__TV/Chan/Season 2024/S2024E03151200 - Ep [vid1].mp4'); + expect(id).toBe('501'); + }); + }); + describe('plexPlaylistToken override', () => { test('uses plexApiKey by default (no override set)', async () => { axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [] } } }); diff --git a/server/modules/mediaServers/adapters/plexAdapter.js b/server/modules/mediaServers/adapters/plexAdapter.js index 3a33f4e12..a6570c6b8 100644 --- a/server/modules/mediaServers/adapters/plexAdapter.js +++ b/server/modules/mediaServers/adapters/plexAdapter.js @@ -23,6 +23,9 @@ const UNCLAIMED_SERVER_SENTINEL = 'UNCLAIMED_SERVER'; // show-level items with no file parts; ?type=4 lists the episodes instead. const PLEX_TYPE_EPISODE = 4; +// File names Plex's TV scanner reads as an episode (S01E02, S2024E03151200). +const EPISODE_NAME_PATTERN = /S\d+E\d+/i; + // Server-local accountID of the server owner: always 1 in /accounts and the // play-history endpoint. The owner's watch state comes from section listings // (full fidelity); history rows for account 1 are skipped as duplicates. @@ -184,10 +187,20 @@ class PlexAdapter extends BaseAdapter { const candidatesByBasename = new Map(); // basename -> [{ ratingKey, segments }] const sections = await this._getSectionIds(); // Music sections must be queried with type=10 (tracks): the default /all - // for an 'artist' section returns artists, which carry no file paths. + // for an 'artist' section returns artists, which carry no file paths. Show + // sections likewise need type=4 (episodes) instead of file-less shows. An + // episode listing covers every show on the server, which can be huge and + // is re-fetched on each polling round, so show sections are only listed + // when a file is named like an episode, as the TV preset and TV layout are. const hasAudio = targets.some((p) => /\.mp3$/i.test(p)); + const mayBeEpisode = targets.some((p) => EPISODE_NAME_PATTERN.test(extractBasename(p))); const sources = [ - ...sections.video.map((id) => ({ id, params: {} })), + ...sections.video + .filter((id) => mayBeEpisode || !sections.shows.includes(id)) + .map((id) => ({ + id, + params: sections.shows.includes(id) ? { type: PLEX_TYPE_EPISODE } : {}, + })), ...(hasAudio ? sections.music.map((id) => ({ id, params: { type: 10 } })) : []), ]; for (const { id: libraryId, params } of sources) { From a2a1da36dd3801f078a2f0a00e30e61aeb69fa88 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Sat, 3 Oct 2026 06:37:05 -0700 Subject: [PATCH 03/12] feat(tv): save channels as TV shows Each library folder (the main downloads folder and every __subfolder) now has a layout, videos or tv. A channel that downloads to a TV folder is saved as one show, <show>/Season <year>/SxxEyy - Title [id].ext, numbered by upload time, with an episode NFO and a tvshow.nfo, so a Plex, Jellyfin or Emby TV library can index it. - Store subfolder layouts on the subfolders table and the main folder's in config (mainFolderLayout); store shows and episode numbers in the new tv_shows and video_classifications tables. A show's folder is pinned when it is created, so an uploader rename or a default-folder change does not start a second show. - Guard layout changes: a folder or channel that already holds downloaded videos cannot switch between videos and TV, nothing switches while a download runs, and TV folders are video-only. Saved MP3 settings are refused; a download-time MP3 type is downgraded to video for videos whose predicted folder is TV, and an MP3 that still reaches a TV folder is placed as the episode, with a warning, rather than movie-style. - Add GET/PUT /api/library-folders, GET /api/channels/:id/tv and PUT /api/channels/:id/tv/layout, a layout filter on /getchannels, and episode details on the video listings and video metadata. - Settings gains a library folder list, Channel Settings a TV Show section that switches a channel (creating a TV folder when none exists), and the subscription, channel and video pages show TV and episode chips. - Replace episode files through a .part staging file and an atomic rename. fs-extra's overwrite removes the destination before a cross-device copy, so a copy that failed on a full disk could have left no copy of the episode at all. Refs: #175 --- CLAUDE.md | 8 +- client/src/components/ChannelPage.tsx | 12 +- .../ChannelPage/ChannelSettingsDialog.tsx | 192 +++++++----- .../src/components/ChannelPage/VideoCard.tsx | 2 + .../components/ChannelPage/VideoListItem.tsx | 2 + .../components/ChannelPage/VideoTableView.tsx | 2 + .../__tests__/ChannelSettingsDialog.story.tsx | 20 ++ .../__tests__/ChannelSettingsDialog.test.tsx | 152 +++++++++- .../ChannelPage/__tests__/VideoCard.test.tsx | 13 + .../__tests__/VideoListItem.test.tsx | 13 + .../__tests__/VideoTableView.test.tsx | 13 + .../components/ChannelTvSection.tsx | 283 ++++++++++++++++++ .../ChannelPage/components/TvFolderSetup.tsx | 83 +++++ .../__tests__/ChannelTvSection.test.tsx | 220 ++++++++++++++ .../__tests__/TvFolderSetup.test.tsx | 54 ++++ .../hooks/__tests__/useChannelTv.test.ts | 100 +++++++ .../ChannelPage/hooks/useChannelTv.ts | 85 ++++++ .../sections/CoreSettingsSection.tsx | 11 + .../__tests__/CoreSettingsSection.story.tsx | 9 + .../__tests__/CoreSettingsSection.test.tsx | 18 ++ .../components/LibraryFoldersList.tsx | 177 +++++++++++ .../components/MainFolderTvDialog.tsx | 66 ++++ .../components/VideoFilenameTemplate.tsx | 44 ++- .../__tests__/LibraryFoldersList.test.tsx | 243 +++++++++++++++ .../__tests__/MainFolderTvDialog.test.tsx | 73 +++++ .../__tests__/VideoFilenameTemplate.test.tsx | 24 ++ .../ManualDownload/DownloadSettingsDialog.tsx | 25 +- .../__tests__/DownloadSettingsDialog.test.tsx | 96 ++++++ .../components/PlaylistSettingsDialog.tsx | 8 +- .../__tests__/PlaylistSettingsDialog.test.tsx | 53 +++- client/src/components/Subscriptions.tsx | 47 ++- .../components/AddChannelSettingsDialog.tsx | 1 + .../Subscriptions/components/ChannelCard.tsx | 3 +- .../components/ChannelListRow.tsx | 5 +- .../components/SubscriptionSettingsFields.tsx | 44 ++- .../AddChannelSettingsDialog.test.tsx | 19 ++ .../components/__tests__/ChannelCard.test.tsx | 18 ++ .../__tests__/ChannelListRow.test.tsx | 24 ++ .../__tests__/NewPlaylistSettings.test.tsx | 20 ++ .../SubscriptionSettingsFields.test.tsx | 70 +++++ .../Subscriptions/components/chips/TvChip.tsx | 19 ++ .../chips/__tests__/TvChip.test.tsx | 11 + .../Subscriptions/components/chips/index.ts | 1 + .../hooks/__tests__/useChannelList.test.ts | 73 +++++ .../Subscriptions/hooks/useChannelList.ts | 7 +- .../__tests__/ChannelPage.story.tsx | 20 ++ .../components/__tests__/ChannelPage.test.tsx | 47 +++ .../__tests__/Subscriptions.test.tsx | 116 +++++++ .../components/shared/AudioFormatSelect.tsx | 20 +- client/src/components/shared/EpisodeChip.tsx | 34 +++ .../shared/SubfolderAutocomplete.tsx | 21 +- .../VideoModal/components/VideoTechnical.tsx | 4 + .../__tests__/VideoTechnical.test.tsx | 22 ++ .../src/components/shared/VideoModal/types.ts | 3 + .../__tests__/AudioFormatSelect.test.tsx | 21 ++ .../shared/__tests__/EpisodeChip.test.tsx | 23 ++ .../__tests__/SubfolderAutocomplete.test.tsx | 48 +++ client/src/config/configSchema.ts | 3 + .../hooks/__tests__/useLibraryFolders.test.ts | 80 +++++ client/src/hooks/useLibraryFolders.ts | 89 ++++++ client/src/lib/icons.ts | 1 + client/src/types/Channel.ts | 3 + client/src/types/ChannelVideo.ts | 3 + client/src/types/VideoData.ts | 3 + client/src/types/tvShows.ts | 51 ++++ .../utils/__tests__/libraryLayouts.test.ts | 49 +++ client/src/utils/libraryLayouts.ts | 32 ++ config/config.example.json | 1 + docs/CONFIG.md | 15 + docs/DATABASE.md | 4 +- migrations/20261002221349-add-tv-shows.js | 118 ++++++++ .../server.additional-routes.test.js | 16 + server/__tests__/server.apikeys.test.js | 16 + server/__tests__/server.auth-sessions.test.js | 16 + server/__tests__/server.core.test.js | 16 + server/__tests__/server.plex-routes.test.js | 16 + server/__tests__/server.routes.test.js | 22 +- server/models/index.js | 7 + server/models/subfolder.js | 2 + server/models/tvshow.js | 32 ++ server/models/videoclassification.js | 30 ++ .../modules/__tests__/channelModule.test.js | 9 + .../modules/__tests__/channelPoster.test.js | 29 ++ .../__tests__/channelSettingsModule.test.js | 57 ++++ server/modules/__tests__/m3uGenerator.test.js | 11 + .../__tests__/manualDownloadGrouper.test.js | 59 ++++ .../__tests__/playlistDownloadGrouper.test.js | 41 +++ .../modules/__tests__/subfolderModule.test.js | 59 +++- .../videoDownloadPostProcessFiles.test.js | 214 ++++++++++++- .../__tests__/videoMetadataModule.test.js | 28 ++ server/modules/__tests__/videosModule.test.js | 18 ++ .../channel/__tests__/channelCatalog.test.js | 57 +++- .../__tests__/channelVideoQuery.test.js | 19 ++ .../__tests__/channelVideosService.test.js | 2 + server/modules/channel/channelCatalog.js | 28 +- server/modules/channel/channelThumbnails.js | 14 +- server/modules/channel/channelVideoQuery.js | 6 + server/modules/channelSettingsModule.js | 35 ++- .../downloadSettingsResolver.test.js | 56 ++++ .../download/downloadSettingsResolver.js | 36 +++ .../fileOperations.replaceFile.test.js | 95 ++++++ server/modules/filesystem/fileOperations.js | 29 ++ server/modules/m3uGenerator.js | 6 + server/modules/manualDownloadGrouper.js | 26 +- server/modules/playlistDownloadGrouper.js | 47 ++- server/modules/subfolderModule.js | 70 ++++- .../tvShows/__tests__/channelFolders.test.js | 114 +++++++ .../tvShows/__tests__/channelLayout.test.js | 198 ++++++++++++ .../__tests__/episodeAllocator.test.js | 117 ++++++++ .../tvShows/__tests__/episodeInfo.test.js | 47 +++ .../tvShows/__tests__/episodeNaming.test.js | 16 +- .../__tests__/episodePlacement.test.js | 223 ++++++++++++++ .../tvShows/__tests__/layoutGuards.test.js | 178 +++++++++++ .../tvShows/__tests__/libraryFolders.test.js | 183 +++++++++++ .../tvShows/__tests__/libraryLayouts.test.js | 79 ++++- .../tvShows/__tests__/showStore.test.js | 130 ++++++++ .../modules/tvShows/__tests__/tvNfo.test.js | 117 ++++++++ server/modules/tvShows/channelFolders.js | 91 ++++++ server/modules/tvShows/channelLayout.js | 187 ++++++++++++ server/modules/tvShows/constants.js | 37 +++ server/modules/tvShows/episodeAllocator.js | 104 +++++++ server/modules/tvShows/episodeInfo.js | 51 ++++ server/modules/tvShows/episodeNaming.js | 19 +- server/modules/tvShows/episodePlacement.js | 173 +++++++++++ server/modules/tvShows/index.js | 34 ++- server/modules/tvShows/layoutGuards.js | 167 +++++++++++ server/modules/tvShows/libraryFolders.js | 161 ++++++++++ server/modules/tvShows/libraryLayouts.js | 62 +++- server/modules/tvShows/routing.js | 5 +- server/modules/tvShows/showStore.js | 129 ++++++++ server/modules/tvShows/tvNfo.js | 123 ++++++++ .../modules/videoDownloadPostProcessFiles.js | 162 +++++++--- server/modules/videoMetadataModule.js | 10 + server/modules/videosModule.js | 5 + .../__tests__/channels.downloadAll.test.js | 50 ++++ server/routes/__tests__/config.test.js | 42 ++- server/routes/__tests__/playlists.test.js | 91 ++++++ server/routes/__tests__/tvShows.test.js | 157 ++++++++++ .../__tests__/videos.tvDestination.test.js | 65 ++++ server/routes/channels.js | 49 ++- server/routes/config.js | 24 +- server/routes/index.js | 13 +- server/routes/playlists.js | 32 +- server/routes/tvShows.js | 189 ++++++++++++ server/routes/videos.js | 14 +- 145 files changed, 7934 insertions(+), 232 deletions(-) create mode 100644 client/src/components/ChannelPage/components/ChannelTvSection.tsx create mode 100644 client/src/components/ChannelPage/components/TvFolderSetup.tsx create mode 100644 client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx create mode 100644 client/src/components/ChannelPage/components/__tests__/TvFolderSetup.test.tsx create mode 100644 client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts create mode 100644 client/src/components/ChannelPage/hooks/useChannelTv.ts create mode 100644 client/src/components/Configuration/sections/components/LibraryFoldersList.tsx create mode 100644 client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx create mode 100644 client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx create mode 100644 client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx create mode 100644 client/src/components/Subscriptions/components/chips/TvChip.tsx create mode 100644 client/src/components/Subscriptions/components/chips/__tests__/TvChip.test.tsx create mode 100644 client/src/components/shared/EpisodeChip.tsx create mode 100644 client/src/components/shared/__tests__/EpisodeChip.test.tsx create mode 100644 client/src/hooks/__tests__/useLibraryFolders.test.ts create mode 100644 client/src/hooks/useLibraryFolders.ts create mode 100644 client/src/types/tvShows.ts create mode 100644 client/src/utils/__tests__/libraryLayouts.test.ts create mode 100644 client/src/utils/libraryLayouts.ts create mode 100644 migrations/20261002221349-add-tv-shows.js create mode 100644 server/models/tvshow.js create mode 100644 server/models/videoclassification.js create mode 100644 server/modules/filesystem/__tests__/fileOperations.replaceFile.test.js create mode 100644 server/modules/tvShows/__tests__/channelFolders.test.js create mode 100644 server/modules/tvShows/__tests__/channelLayout.test.js create mode 100644 server/modules/tvShows/__tests__/episodeAllocator.test.js create mode 100644 server/modules/tvShows/__tests__/episodeInfo.test.js create mode 100644 server/modules/tvShows/__tests__/episodePlacement.test.js create mode 100644 server/modules/tvShows/__tests__/layoutGuards.test.js create mode 100644 server/modules/tvShows/__tests__/libraryFolders.test.js create mode 100644 server/modules/tvShows/__tests__/showStore.test.js create mode 100644 server/modules/tvShows/__tests__/tvNfo.test.js create mode 100644 server/modules/tvShows/channelFolders.js create mode 100644 server/modules/tvShows/channelLayout.js create mode 100644 server/modules/tvShows/constants.js create mode 100644 server/modules/tvShows/episodeAllocator.js create mode 100644 server/modules/tvShows/episodeInfo.js create mode 100644 server/modules/tvShows/episodePlacement.js create mode 100644 server/modules/tvShows/layoutGuards.js create mode 100644 server/modules/tvShows/libraryFolders.js create mode 100644 server/modules/tvShows/showStore.js create mode 100644 server/modules/tvShows/tvNfo.js create mode 100644 server/routes/__tests__/tvShows.test.js create mode 100644 server/routes/__tests__/videos.tvDestination.test.js create mode 100644 server/routes/tvShows.js diff --git a/CLAUDE.md b/CLAUDE.md index 555b18c51..83786f868 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,9 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/cookieDetails.js`: summarizes the YouTube login cookies (names, counts, earliest expiry; never values) in the active cookie file as `details` on `GET /api/cookies/status` and the upload/delete responses, reading external files through `externalCookies.readExternalCookies`. `modules/cookieTest.js`: `POST /api/cookies/test`, one yt-dlp request to the subscriptions feed with `buildCommonArgs` (same proxy/IP family/cookies/cache as downloads) through `ytDlpRunner`, against a private temp copy of uploaded cookies (yt-dlp writes its jar back on exit, which would otherwise undo a replace/delete made mid-test; external files get spawnYtDlp's snapshot) and never logs stderr (yt-dlp echoes malformed cookie lines with their values); any exit 0 with JSON is signed in (even zero subscriptions), and the feed's "Incomplete yt initial data" warnings mean signed out (EXPIRED_COOKIES), a meaning specific to this feed, so it is not in the shared subscription-import classifier. One test at a time (409), rate-limited by `cookieTestRateLimiter`. - `server.js`: Express entry point. `db.js`: Sequelize setup. `logger.js`: Pino logger with request correlation. - `logger.js` + `logging/`: two transport targets pinned to `trace`, so the root level is the only filter and `logger.applyLevelSetting` can change it live. Only the server process (`process.argv[1]` is `server.js`; the yt-dlp post-processor, scripts, and tests never write files, since post-processor output already reaches the server log through yt-dlp's stdout) also writes the console format, without colors or embedded ANSI codes, to `config/logs/youtarr.<n>.log` through `logging/logFileTransport.js` (pino-roll with `removeOtherLogFiles`, so files from earlier runs are pruned too; `LOG_FILE_MAX_SIZE` default 10MB, `LOG_FILE_MAX_COUNT` older files kept, default 5). A file error prints one stderr notice and stops file output only; an unwritable folder is detected at startup and reported through `logger.getLoggingStatus()` (served as `logging` on `/getconfig`). Never log a field named `level`: it overwrites pino's level and the record is dropped. `modules/logLevelSync.js` applies the `logLevel` setting ('' = `LOG_LEVEL`) at startup, on config change, and in the post-processor. `modules/logFilesModule.js` + `routes/logs.js` serve `GET /api/logs/download` (all files oldest first, numeric order), read line by line through `logging/logScrubber.js`, which replaces the configured keys/tokens and Apprise notification URLs by value plus `X-Plex-Token`/`X-Emby-Token`/`api_key` values and URL userinfo (proxy credentials) with `[REDACTED]`; pino's redaction only covers top-level keys, and tokens reach the log inside logged axios errors and the logged yt-dlp args. The files on disk are not scrubbed. -- `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates. -- `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. -- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to <channel>@latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `<channel folder>.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths and active references), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: <title>`; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). +- `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus, tvshow, videoclassification). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates, TvShow hasMany VideoClassifications. +- `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs, tvShows). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. +- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to <channel>@latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `<channel folder>.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings; MP3 is downgraded to video for videos whose predicted folder has the TV layout), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths, active references, and TV shows with numbered episodes in the folder, not counting a show whose tracked channel has since moved back to a videos folder; reads layouts through `tvShows/libraryLayouts`, which therefore never imports it), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: <title>`; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation, downgrading MP3 to video for URLs whose predicted folder has the TV layout; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). - `modules/titleFilterRegex.js`: evaluates channel title filter regexes with Python's `re` (via `utils/title-filter-regex.py`, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's `--match-filter` exactly. Channel downloads apply the pattern through `ytdlpCommandBuilder.buildMatchFilters`, which escapes only `'` and `&` because yt-dlp's `match_str` unescapes nothing else. Playlist title filters are separate: `playlistModule.buildTitleFilterRegExp` evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile. - `modules/storageUsage.js`: total bytes of downloaded videos (`SUM(file_size + audio_file_size)` over non-removed rows, never a disk walk) and the shared `STORED_BYTES_SQL` fragment the oldest-first auto-removal candidate queries use, so audio-only (MP3) videos count their real size. - `modules/storageGuard.js`: the storage download pause (issues #196/#217). Optional `downloadPauseUsageLimit` (storageUsage total) and `downloadPauseMinFreeSpace` (df) triggers, both off by default; a failed measurement never pauses (fail open). `assertDownloadsAllowed()` throws `DownloadsPausedError` (code `DOWNLOADS_PAUSED`) from `downloadModule`'s fresh-request entry points (`doChannelAndPlaylistDownloads`, `doChannelDownloads`, `doSpecificDownloads`, `doPlaylistDownloads`; queued jobs started with isNextJob skip it), which routes map to 409 via the injected `storageGuard.isPausedError` (Download New reaches `doChannelAndPlaylistDownloads` through the scheduler's `channelDownloadFrequency` task rather than calling it directly, so its own pause check runs first, inside `autoDownloadScheduler.getRunBlocker`, and a paused Download New request gets the `downloads-paused` reason instead of a bare 409); the scheduled sweep records a skipped run first, and a pause hit mid-sweep stops the playlist loop and is reported as `pausedReason` (not a failure); `jobModule.startNextJob` refreshes the guard (so every finished job re-checks) and holds Pending jobs while paused, restarting the queue on the guard's `resumed` event. `startNextJob` coalesces overlapping calls into one in-flight scan (the `resumed` event fires inside the refresh a scan awaits, and a channel job stays Pending while it builds groups, so two scans would start it twice) and catches the unawaited job action's rejection; a job whose action rejects while it is still Pending is marked `Error` by `failUnstartedJob`, which waits for the launching scan to settle and then scans again, so the queue never stalls behind it (the single-group channel job's pre-spawn failure likewise reports a `jobIssue` to its run and calls `startNextJob`). `subscriptionImport` calls `startNextJob` when an import finishes (the import occupies the queue but completes outside the download path, so jobs queued or held meanwhile would otherwise stay Pending). The downloaded-size SUM runs only when a usage limit is set; `GET /api/jobs/download-pause` opts in with `refresh({ includeUsage: true })` for the settings page. `/updateconfig` rejects size values other than blank or `<positive int>MB|GB|TB`; `configModule.normalizeStorageSizeFields` corrects or clears hand-edited ones on load (startup and file-watch reload), because the client saves the full config and one bad value would otherwise block every Settings save. On a paused-state flip it broadcasts `downloadPauseChanged`, sends `notificationModule.sendDownloadPauseNotification` (paused and resumed), and re-checks every 5 min while paused; deletions re-check when paused. `GET /api/jobs/download-pause` serves the status; the client shows it through `hooks/useDownloadPauseStatus`, `layout/DownloadPauseBanner` (every page, dismissible per pause except on `/downloads`), and `shared/DownloadPauseAlert`, with settings at `/settings/storage-limits` (`Configuration/sections/StorageLimitsSection.tsx`). @@ -47,7 +47,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`<library folder>/<show>/Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. -- `modules/tvShows/` (TV show support, in progress; nothing user-visible yet): `libraryLayouts` (layout of each library folder, the main folder `''` or a subfolder name; every folder resolves to `videos` until layouts are stored), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. +- `modules/tvShows/` (TV show support; channel shows ship in this branch, title shows and the reorganize come later): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos can't switch between videos and TV (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. - `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10, and episodes by listing show sections with type=4, only when a file in the batch is named SxxEyy), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). diff --git a/client/src/components/ChannelPage.tsx b/client/src/components/ChannelPage.tsx index ac7e8d575..7d1535855 100644 --- a/client/src/components/ChannelPage.tsx +++ b/client/src/components/ChannelPage.tsx @@ -13,8 +13,10 @@ import SubscriptionsBackButton from './shared/SubscriptionsBackButton'; import OpenInYouTubeLink, { youtubeChannelUrl } from './shared/OpenInYouTubeLink'; import SubFolderChip from './Subscriptions/components/chips/SubFolderChip'; import QualityChip from './Subscriptions/components/chips/QualityChip'; +import TvChip from './Subscriptions/components/chips/TvChip'; import AutoDownloadTabToggles from './ChannelPage/components/AutoDownloadTabToggles'; import { useAutoDownloadTabToggle } from './ChannelPage/hooks/useAutoDownloadTabToggle'; +import { useChannelTv } from './ChannelPage/hooks/useChannelTv'; import { SHARED_CHANNEL_META_CHIP_STYLE, SHARED_CHANNEL_META_DEFAULT_SURFACE_STYLE } from './shared/chipStyles'; interface ChannelPageProps { @@ -36,6 +38,7 @@ function ChannelPage({ token }: ChannelPageProps) { const { channel_id } = useParams(); const { config, loading: configLoading } = useConfig(token); const globalPreferredResolution = config.preferredResolution || '1080'; + const { tv: channelTv, refetch: refetchChannelTv } = useChannelTv(channel_id, token); const handleSettingsSaved = (updated: { sub_folder: string | null; @@ -76,6 +79,8 @@ function ChannelPage({ token }: ChannelPageProps) { } return next; }); + // A folder change can switch the channel between Videos and TV. + void refetchChannelTv(); }; // Monotonic request id keeps a slow in-flight /getChannelInfo from @@ -228,7 +233,12 @@ function ChannelPage({ token }: ChannelPageProps) { if (!channel) { return null; } - return <SubFolderChip subFolder={channel.sub_folder} />; + return ( + <Box className="flex flex-wrap items-center gap-1"> + <SubFolderChip subFolder={channel.sub_folder} /> + {channelTv?.layout === 'tv' && <TvChip />} + </Box> + ); }; const handleAutoDownloadTabsChange = useCallback((enabledTabs: string, savedChannelId: string) => { diff --git a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx index 36174e5a7..c159effc9 100644 --- a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx +++ b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx @@ -28,17 +28,25 @@ import { Tabs, Chip, } from '../ui'; -import { CheckCircle as CheckCircleIcon, XCircle as CancelIcon, Info as InfoIcon, Copy as ContentCopyIcon, Settings as SettingsIcon, Download as DownloadIcon, Filter as FilterAltIcon, Shield as RatingIcon, Tag as TagIcon, ShieldCheck as AutoRemovalIcon } from '../../lib/icons'; +import { CheckCircle as CheckCircleIcon, XCircle as CancelIcon, Info as InfoIcon, Copy as ContentCopyIcon, Settings as SettingsIcon, Download as DownloadIcon, Filter as FilterAltIcon, Shield as RatingIcon, Tag as TagIcon, ShieldCheck as AutoRemovalIcon, Tv as TvIcon } from '../../lib/icons'; import useMediaQuery from '../../hooks/useMediaQuery'; import { useConfig } from '../../hooks/useConfig'; import { useSubfolders } from '../../hooks/useSubfolders'; +import { useLibraryFolders } from '../../hooks/useLibraryFolders'; import { SubfolderAutocomplete } from '../shared/SubfolderAutocomplete'; import { ResolutionSelect } from '../shared/ResolutionSelect'; import { AudioFormatSelect } from '../shared/AudioFormatSelect'; import { RatingSelect } from '../shared/RatingSelect'; import { RATING_OPTIONS } from '../../utils/ratings'; +import { effectiveLibraryFolder } from '../../utils/libraryLayouts'; +import { LibraryLayout } from '../../types/tvShows'; import RatingBadge from '../shared/RatingBadge'; import TabsEditor, { TabsEditorRefreshResult } from './components/TabsEditor'; +import ChannelTvSection from './components/ChannelTvSection'; +import { useChannelTv } from './hooks/useChannelTv'; + +const TV_FOLDER_GENERAL_CAPTION = 'Episodes are saved in season folders, and channel playlist files are off for TV shows.'; +const SUBFOLDER_BUSY_MESSAGE = 'Cannot change subfolder while downloads are in progress for this channel. Please wait for downloads to complete.'; const M3U_SORT_ORDERS = ['oldest_first', 'newest_first'] as const; type M3uSortOrder = (typeof M3U_SORT_ORDERS)[number]; @@ -127,6 +135,7 @@ function ChannelSettingsDialog({ open, onClose, channelId, + channelName, token, onSettingsSaved }: ChannelSettingsDialogProps) { @@ -178,6 +187,9 @@ function ChannelSettingsDialog({ const globalQuality = config.preferredResolution || '1080'; const { subfolders, createSubfolder } = useSubfolders(token); + const { folders: libraryFolders, layoutOf, setFolderLayout } = useLibraryFolders(token); + const { tv, loading: tvLoading, error: tvError, switchLayout } = useChannelTv(channelId, token, open); + const draftIsTv = layoutOf(effectiveLibraryFolder(settings.sub_folder, config.defaultSubfolder)) === 'tv'; // Duration input state (in minutes for UI convenience) const [minDurationMinutes, setMinDurationMinutes] = useState<string>(''); @@ -198,6 +210,7 @@ function ChannelSettingsDialog({ const sections = [ { id: 'general', label: 'General', icon: <SettingsIcon size={18} /> }, + { id: 'tv', label: 'TV Show', icon: <TvIcon size={18} /> }, { id: 'filters', label: 'Filters', icon: <FilterAltIcon size={18} /> }, { id: 'ratings', label: 'Ratings', icon: <RatingIcon size={18} /> }, { id: 'tags', label: 'Tags', icon: <TagIcon size={18} /> }, @@ -321,20 +334,23 @@ function ChannelSettingsDialog({ }); if (!response.ok) { - if (response.status === 409) { - throw new Error('Cannot change subfolder while downloads are in progress for this channel. Please wait for downloads to complete.'); - } - - let errorMessage = 'Failed to update settings'; + let serverMessage: string | null = null; + let parseFailed = false; try { const data = await response.json(); - errorMessage = data.error || errorMessage; + serverMessage = typeof data?.error === 'string' && data.error ? data.error : null; } catch (parseError) { - // If JSON parsing fails, use generic error with status - errorMessage = `Server error: ${response.status} ${response.statusText}`; + parseFailed = true; } - throw new Error(errorMessage); + if (serverMessage) { + throw new Error(serverMessage); + } + if (response.status === 409) { + throw new Error(SUBFOLDER_BUSY_MESSAGE); + } + // Without a JSON body, report the status instead + throw new Error(parseFailed ? `Server error: ${response.status} ${response.statusText}` : 'Failed to update settings'); } const result = await response.json(); @@ -462,6 +478,22 @@ function ChannelSettingsDialog({ })); }; + // A layout switch saves the channel's new sub_folder on the server right away. + const handleLayoutSwitch = async (layout: LibraryLayout, folder?: string) => { + const result = await switchLayout(layout, folder); + const subFolder = result.settings.sub_folder ?? null; + setSettings((prev) => ({ ...prev, sub_folder: subFolder })); + setOriginalSettings((prev) => ({ ...prev, sub_folder: subFolder })); + if (onSettingsSaved) { + const savedSettings: ChannelSettings = { ...originalSettings, sub_folder: subFolder }; + onSettingsSaved({ + ...savedSettings, + detectedTabs, + availableTabs: detectedTabs.filter((tab) => !savedSettings.hidden_tabs.includes(tab)), + }); + } + }; + const handlePreviewFilter = async () => { setLoadingPreview(true); setPreviewError(null); @@ -648,69 +680,78 @@ function ChannelSettingsDialog({ ...settings, audio_format: value })} - helperText={settings.audio_format ? 'MP3 files are saved at 192kbps in the same folder as videos.' : undefined} + videoOnly={draftIsTv} + helperText={settings.audio_format && !draftIsTv ? 'MP3 files are saved at 192kbps in the same folder as videos.' : undefined} /> - <div className="mt-2"> - <FormControl fullWidth> - <InputLabel id="video-file-structure-label">Video File Structure</InputLabel> - <Select - labelId="video-file-structure-label" - value={settings.skip_video_folder === null ? 'default' : settings.skip_video_folder ? 'flat' : 'subfolders'} - onChange={(e: SelectChangeEvent<string>) => setSettings({ - ...settings, - skip_video_folder: e.target.value === 'default' ? null : e.target.value === 'flat' - })} - label="Video File Structure" - > - <MenuItem value="default"> - Use global setting ({config.defaultSkipVideoFolder ? 'Flat' : 'Video subfolders'}) - </MenuItem> - <MenuItem value="flat">Flat (no video subfolders)</MenuItem> - <MenuItem value="subfolders">Video subfolders</MenuItem> - </Select> - </FormControl> - <Typography variant="caption" color="text.secondary" className="mt-1 mb-2 block"> - Flat saves video files directly in the channel folder instead of individual video subfolders. Only affects new downloads. + {draftIsTv ? ( + <Typography variant="caption" color="text.secondary" className="mt-2 block"> + {TV_FOLDER_GENERAL_CAPTION} </Typography> - </div> + ) : ( + <> + <div className="mt-2"> + <FormControl fullWidth> + <InputLabel id="video-file-structure-label">Video File Structure</InputLabel> + <Select + labelId="video-file-structure-label" + value={settings.skip_video_folder === null ? 'default' : settings.skip_video_folder ? 'flat' : 'subfolders'} + onChange={(e: SelectChangeEvent<string>) => setSettings({ + ...settings, + skip_video_folder: e.target.value === 'default' ? null : e.target.value === 'flat' + })} + label="Video File Structure" + > + <MenuItem value="default"> + Use global setting ({config.defaultSkipVideoFolder ? 'Flat' : 'Video subfolders'}) + </MenuItem> + <MenuItem value="flat">Flat (no video subfolders)</MenuItem> + <MenuItem value="subfolders">Video subfolders</MenuItem> + </Select> + </FormControl> + <Typography variant="caption" color="text.secondary" className="mt-1 mb-2 block"> + Flat saves video files directly in the channel folder instead of individual video subfolders. Only affects new downloads. + </Typography> + </div> - <div className="mt-2"> - <FormControlLabel - control={ - <Switch - checked={settings.m3u_enabled} - onChange={(e) => setSettings({ - ...settings, - m3u_enabled: e.target.checked - })} + <div className="mt-2"> + <FormControlLabel + control={ + <Switch + checked={settings.m3u_enabled} + onChange={(e) => setSettings({ + ...settings, + m3u_enabled: e.target.checked + })} + /> + } + label="Generate channel playlist file (.m3u)" /> - } - label="Generate channel playlist file (.m3u)" - /> - <Typography variant="caption" color="text.secondary" className="mt-1 block"> - Writes a playlist of this channel's downloaded videos to the top of the - channel folder. Jellyfin and Emby import it automatically; it updates after - downloads and deletions, and refreshes nightly. - </Typography> - {settings.m3u_enabled && ( - <FormControl fullWidth className="mt-2"> - <InputLabel id="m3u-sort-order-label">Playlist Order</InputLabel> - <Select - labelId="m3u-sort-order-label" - value={settings.m3u_sort_order} - onChange={(e: SelectChangeEvent<string>) => setSettings({ - ...settings, - m3u_sort_order: toM3uSortOrder(e.target.value) - })} - label="Playlist Order" - > - <MenuItem value="oldest_first">Oldest first (chronological)</MenuItem> - <MenuItem value="newest_first">Newest first</MenuItem> - </Select> - </FormControl> - )} - </div> + <Typography variant="caption" color="text.secondary" className="mt-1 block"> + Writes a playlist of this channel's downloaded videos to the top of the + channel folder. Jellyfin and Emby import it automatically; it updates after + downloads and deletions, and refreshes nightly. + </Typography> + {settings.m3u_enabled && ( + <FormControl fullWidth className="mt-2"> + <InputLabel id="m3u-sort-order-label">Playlist Order</InputLabel> + <Select + labelId="m3u-sort-order-label" + value={settings.m3u_sort_order} + onChange={(e: SelectChangeEvent<string>) => setSettings({ + ...settings, + m3u_sort_order: toM3uSortOrder(e.target.value) + })} + label="Playlist Order" + > + <MenuItem value="oldest_first">Oldest first (chronological)</MenuItem> + <MenuItem value="newest_first">Newest first</MenuItem> + </Select> + </FormControl> + )} + </div> + </> + )} <Alert severity="info" style={{ marginBottom: 16 }}> <Typography variant="body2" style={{ fontWeight: 'bold', marginBottom: 8 }}> @@ -741,6 +782,7 @@ function ChannelSettingsDialog({ label="Subfolder" helperText="Choose where this channel's videos are saved" createSubfolder={createSubfolder} + layoutOf={layoutOf} /> <Alert severity="info" style={{ marginTop: 8 }}> <Typography variant="caption"> @@ -753,6 +795,20 @@ function ChannelSettingsDialog({ </div> </div> ); + case 'tv': + return ( + <ChannelTvSection + channelName={channelName} + tv={tv} + loading={tvLoading} + error={tvError} + folders={libraryFolders} + onSwitch={handleLayoutSwitch} + createSubfolder={createSubfolder} + setFolderLayout={setFolderLayout} + disabled={saving} + /> + ); case 'filters': return ( <div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 16 : 24 }}> diff --git a/client/src/components/ChannelPage/VideoCard.tsx b/client/src/components/ChannelPage/VideoCard.tsx index 5135eae5b..5099da031 100644 --- a/client/src/components/ChannelPage/VideoCard.tsx +++ b/client/src/components/ChannelPage/VideoCard.tsx @@ -17,6 +17,7 @@ import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; import WatchedChip from '../shared/WatchedChip'; +import EpisodeChip from '../shared/EpisodeChip'; import ProtectionShieldButton from '../shared/ProtectionShieldButton'; import ThumbnailClickOverlay from '../shared/ThumbnailClickOverlay'; import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_THEMED_CHIP_SMALL_STYLE } from '../shared/chipStyles'; @@ -389,6 +390,7 @@ function VideoCard({ size="small" style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE, flexShrink: 0 }} /> + <EpisodeChip episode={video.episode} /> <WatchedChip watchedBy={video.watchedBy || []} /> <Chip icon={getStatusIcon(status)} diff --git a/client/src/components/ChannelPage/VideoListItem.tsx b/client/src/components/ChannelPage/VideoListItem.tsx index 87bfa99ec..d393a6a52 100644 --- a/client/src/components/ChannelPage/VideoListItem.tsx +++ b/client/src/components/ChannelPage/VideoListItem.tsx @@ -21,6 +21,7 @@ import ProtectionShieldButton from '../shared/ProtectionShieldButton'; import RatingBadge from '../shared/RatingBadge'; import ThumbnailClickOverlay from '../shared/ThumbnailClickOverlay'; import WatchedChip from '../shared/WatchedChip'; +import EpisodeChip from '../shared/EpisodeChip'; interface VideoListItemProps { video: ChannelVideo; checkedBoxes: string[]; @@ -369,6 +370,7 @@ function VideoListItem({ showNA style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }} /> + <EpisodeChip episode={video.episode} /> <WatchedChip watchedBy={video.watchedBy || []} /> <Chip icon={getStatusIcon(status)} diff --git a/client/src/components/ChannelPage/VideoTableView.tsx b/client/src/components/ChannelPage/VideoTableView.tsx index 3e732b43f..39c0da535 100644 --- a/client/src/components/ChannelPage/VideoTableView.tsx +++ b/client/src/components/ChannelPage/VideoTableView.tsx @@ -15,6 +15,7 @@ import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; import WatchedChip from '../shared/WatchedChip'; +import EpisodeChip from '../shared/EpisodeChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_THEMED_CHIP_SMALL_STYLE } from '../shared/chipStyles'; import { getPublishedDateDisplay } from './publishedDateDisplay'; @@ -351,6 +352,7 @@ function VideoTableView({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }} /> )} + <EpisodeChip episode={video.episode} /> <WatchedChip watchedBy={video.watchedBy || []} /> <Chip icon={getStatusIcon(status)} diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx index 620b0931b..7667faf0f 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx @@ -30,6 +30,26 @@ const meta: Meta<typeof ChannelSettingsDialog> = { http.get('/api/channels/subfolders', () => HttpResponse.json(['Movies', 'Shows']) ), + http.get('/api/channels/chan-1/tv', () => + HttpResponse.json({ + layout: 'videos', + libraryFolder: '', + show: null, + tvFolders: ['Shows'], + defaultFolder: '', + defaultFolderLayout: 'videos', + hasDownloads: false, + canSwitch: true, + }) + ), + http.get('/api/library-folders', () => + HttpResponse.json({ + folders: [ + { name: '', layout: 'videos', isDefault: true, hasFiles: true, channels: 1 }, + { name: 'Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 0 }, + ], + }) + ), ], }, }, diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx index 2b9776822..cb79813e1 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx @@ -33,6 +33,32 @@ jest.mock('../../../hooks/useSubfolders', () => ({ }), })); +// Library folder layouts drive the General section's TV-only controls. +const mockLayoutOf = jest.fn((_folder: string) => 'videos'); +jest.mock('../../../hooks/useLibraryFolders', () => ({ + LIBRARY_FOLDERS_UPDATED_EVENT: 'library-folders-updated', + useLibraryFolders: () => ({ + folders: [], + loading: false, + error: null, + layoutOf: mockLayoutOf, + refetch: jest.fn(), + setFolderLayout: jest.fn(() => Promise.resolve()), + }), +})); + +const mockSwitchLayout = jest.fn(); +const mockChannelTv: { current: unknown } = { current: null }; +jest.mock('../hooks/useChannelTv', () => ({ + useChannelTv: () => ({ + tv: mockChannelTv.current, + loading: false, + error: null, + refetch: jest.fn(), + switchLayout: mockSwitchLayout, + }), +})); + // eslint-disable-next-line @typescript-eslint/no-var-requires const mockAxios = require('axios'); @@ -106,9 +132,12 @@ describe('ChannelSettingsDialog', () => { mockRefetchConfig.mockResolvedValue(undefined); // Reset mockUseConfig to default mockUseConfig.mockReturnValue(buildUseConfigResult()); + mockLayoutOf.mockImplementation(() => 'videos'); + mockSwitchLayout.mockReset(); + mockChannelTv.current = null; }); - async function openSettingsSection(sectionName: 'General' | 'Auto Download' | 'Filters' | 'Ratings' | 'Auto-Removal') { + async function openSettingsSection(sectionName: 'General' | 'TV Show' | 'Auto Download' | 'Filters' | 'Ratings' | 'Auto-Removal') { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: sectionName })); return user; @@ -2053,4 +2082,125 @@ describe('ChannelSettingsDialog', () => { expect(body.auto_removal_keep_recent_count).toBeNull(); }); }); + + describe('TV Show section', () => { + const videosChannelTv = { + layout: 'videos', + libraryFolder: '', + show: null, + tvFolders: ['Anime'], + defaultFolder: '', + defaultFolderLayout: 'videos', + hasDownloads: false, + canSwitch: true, + }; + + const renderLoaded = async (loaded: Record<string, unknown> = mockChannelSettings) => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(loaded), + }); + render(<ChannelSettingsDialog {...defaultProps} />); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + }; + + test('opens the TV Show section', async () => { + mockChannelTv.current = videosChannelTv; + await renderLoaded(); + + await openSettingsSection('TV Show'); + + expect(screen.getByText('Show this channel as')).toBeInTheDocument(); + }); + + test('hides the file structure and playlist file controls for a TV folder', async () => { + mockLayoutOf.mockImplementation((folder: string) => (folder === 'Anime' ? 'tv' : 'videos')); + await renderLoaded({ ...mockChannelSettings, sub_folder: 'Anime' }); + + expect(screen.queryByLabelText('Video File Structure')).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Generate channel playlist file/i)).not.toBeInTheDocument(); + }); + + test('explains how a TV folder saves episodes', async () => { + mockLayoutOf.mockImplementation((folder: string) => (folder === 'Anime' ? 'tv' : 'videos')); + await renderLoaded({ ...mockChannelSettings, sub_folder: 'Anime' }); + + expect( + screen.getByText('Episodes are saved in season folders, and channel playlist files are off for TV shows.') + ).toBeInTheDocument(); + }); + + test('marks the download type as video-only for a TV folder', async () => { + mockLayoutOf.mockImplementation((folder: string) => (folder === 'Anime' ? 'tv' : 'videos')); + await renderLoaded({ ...mockChannelSettings, sub_folder: 'Anime' }); + + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + }); + + test('a layout switch updates the subfolder without enabling Save', async () => { + mockChannelTv.current = videosChannelTv; + mockSwitchLayout.mockResolvedValueOnce({ + settings: { sub_folder: 'Anime' }, + tv: { ...videosChannelTv, layout: 'tv', libraryFolder: 'Anime' }, + }); + await renderLoaded(); + + const user = await openSettingsSection('TV Show'); + await user.click(screen.getByRole('button', { name: 'TV show' })); + await waitFor(() => { + expect(mockOnSettingsSaved).toHaveBeenCalledWith(expect.objectContaining({ sub_folder: 'Anime' })); + }); + await openSettingsSection('General'); + + expect(screen.getByLabelText('Subfolder')).toHaveValue('__Anime'); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + test('a layout switch reports the saved settings and tabs to the channel page', async () => { + mockChannelTv.current = videosChannelTv; + mockSwitchLayout.mockResolvedValueOnce({ + settings: { sub_folder: 'Anime' }, + tv: { ...videosChannelTv, layout: 'tv', libraryFolder: 'Anime' }, + }); + await renderLoaded({ ...mockChannelSettings, detected_tabs: ['videos', 'shorts'], hidden_tabs: ['shorts'] }); + + const user = await openSettingsSection('TV Show'); + await user.click(screen.getByRole('button', { name: 'TV show' })); + + await waitFor(() => { + expect(mockOnSettingsSaved).toHaveBeenCalledWith(expect.objectContaining({ + sub_folder: 'Anime', + detectedTabs: ['videos', 'shorts'], + availableTabs: ['videos'], + })); + }); + }); + + test("shows the server's 409 message when a save is refused", async () => { + const refusal = "This channel already has downloaded videos, so it can't switch between Videos and TV or move to another TV folder yet."; + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockChannelSettings), + }) + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: jest.fn().mockResolvedValueOnce({ error: refusal }), + }); + render(<ChannelSettingsDialog {...defaultProps} />); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + const user = userEvent.setup(); + + await user.click(screen.getByLabelText('Channel Video Quality Override')); + await user.click(screen.getByText('720p (HD)')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText(refusal)).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx index 5c9c4ae66..c5d0194d4 100644 --- a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx @@ -886,4 +886,17 @@ describe('VideoCard Component', () => { expect(screen.queryByText('Watched')).not.toBeInTheDocument(); }); }); + + describe('Episode Chip', () => { + test('renders the episode code for a downloaded TV episode', () => { + const episodeVideo = { ...mockVideo, added: true, removed: false, episode: { showName: 'Test Channel', season: 2024, episode: 3151200, code: 'S2024E03151200' } }; + renderWithProviders(<VideoCard {...defaultProps} video={episodeVideo} />); + expect(screen.getByTestId('episode-chip')).toHaveTextContent('S2024E03151200'); + }); + + test('does not render an episode chip for a video without episode details', () => { + renderWithProviders(<VideoCard {...defaultProps} />); + expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx index cfb263f33..dd61ed279 100644 --- a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx @@ -889,4 +889,17 @@ describe('VideoListItem Component', () => { expect(screen.queryByText('Watched')).not.toBeInTheDocument(); }); }); + + describe('Episode Chip', () => { + test('renders the episode code for a downloaded TV episode', () => { + const episodeVideo = { ...mockVideo, added: true, removed: false, episode: { showName: 'Test Channel', season: 2024, episode: 3151200, code: 'S2024E03151200' } }; + renderWithProviders(<VideoListItem {...defaultProps} video={episodeVideo} />); + expect(screen.getByTestId('episode-chip')).toHaveTextContent('S2024E03151200'); + }); + + test('does not render an episode chip for a video without episode details', () => { + renderWithProviders(<VideoListItem {...defaultProps} />); + expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx index 5b3e34f7d..a872d46e3 100644 --- a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx @@ -1122,4 +1122,17 @@ describe('VideoTableView Component', () => { expect(screen.queryByText('Watched')).not.toBeInTheDocument(); }); }); + + describe('Episode Chip', () => { + test('renders the episode code for a downloaded TV episode', () => { + const episodeVideo = { ...mockVideo, added: true, removed: false, episode: { showName: 'Test Channel', season: 2024, episode: 3151200, code: 'S2024E03151200' } }; + renderWithProviders(<VideoTableView {...defaultProps} videos={[episodeVideo]} />); + expect(screen.getByTestId('episode-chip')).toHaveTextContent('S2024E03151200'); + }); + + test('does not render an episode chip for a video without episode details', () => { + renderWithProviders(<VideoTableView {...defaultProps} />); + expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/ChannelPage/components/ChannelTvSection.tsx b/client/src/components/ChannelPage/components/ChannelTvSection.tsx new file mode 100644 index 000000000..9d75c1d40 --- /dev/null +++ b/client/src/components/ChannelPage/components/ChannelTvSection.tsx @@ -0,0 +1,283 @@ +import React, { useId, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + Typography, +} from '../../ui'; +import { ChannelTvState, LibraryFolder, LibraryLayout } from '../../../types/tvShows'; +import { libraryFolderLabel } from '../../../utils/libraryLayouts'; +import TvFolderSetup from './TvFolderSetup'; + +/** The server's refusal when it can't tell which Videos folder to switch back to. */ +export const CHOOSE_VIDEOS_FOLDER_MESSAGE = 'Choose a Videos folder.'; +const SWITCH_FAILED_MESSAGE = "Couldn't switch this channel's layout."; +const HAS_DOWNLOADS_NOTE = "This channel already has downloaded videos, so it can't switch between Videos and TV yet."; +const SWITCH_SAVES_NOTE = 'Switching saves right away.'; +const DEFAULT_FOLDER_TV_NOTE = + "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; +const NO_VIDEOS_FOLDER_MESSAGE = 'No library folder uses the Videos layout yet.'; +const PLEX_SETUP_NOTE = + 'Plex: add a TV Shows library for this folder (scanner Plex TV Series, agent Plex Personal Media or Plex NFO Series). ' + + 'Then map the folder under Settings > Plex > subfolder library mappings so new episodes refresh it.'; +const JELLYFIN_EMBY_SETUP_NOTE = + 'Jellyfin and Emby: add a Shows library for this folder with the NFO reader on, NFO saving off and online metadata off.'; + +type Step = 'idle' | 'pickTv' | 'pickVideos' | 'setupTv'; + +export interface ChannelTvSectionProps { + channelName: string; + tv: ChannelTvState | null; + loading: boolean; + error: string | null; + /** Library folders with their layouts, for the Videos folder picker */ + folders: LibraryFolder[]; + /** Switches the channel's layout; rejects with the server's refusal message */ + onSwitch: (layout: LibraryLayout, folder?: string) => Promise<void>; + createSubfolder: (name: string) => Promise<void>; + setFolderLayout: (name: string, layout: LibraryLayout) => Promise<void>; + disabled?: boolean; +} + +interface FolderPickerProps { + prompt: string; + label: string; + folders: string[]; + value: string; + onChange: (value: string) => void; + actionLabel: string; + onConfirm: () => void; + disabled: boolean; +} + +function FolderPicker({ prompt, label, folders, value, onChange, actionLabel, onConfirm, disabled }: FolderPickerProps) { + const labelId = useId(); + return ( + <Box className="flex flex-col gap-2 rounded-[var(--radius-ui)] border border-border p-3"> + <Typography variant="body2">{prompt}</Typography> + <FormControl fullWidth> + <InputLabel id={labelId} shrink> + {label} + </InputLabel> + <Select + labelId={labelId} + size="small" + value={value} + disabled={disabled} + onChange={(event) => onChange(String(event.target.value))} + > + {folders.map((folder) => ( + <MenuItem key={folder || '(main)'} value={folder}> + {libraryFolderLabel(folder)} + </MenuItem> + ))} + </Select> + </FormControl> + <Box> + <Button variant="contained" size="small" disabled={disabled} onClick={onConfirm}> + {actionLabel} + </Button> + </Box> + </Box> + ); +} + +function MediaServerSetupNotes() { + return ( + <Box className="flex flex-col gap-1 rounded-[var(--radius-ui)] bg-muted p-3"> + <Typography variant="body2" className="font-semibold"> + Media server setup + </Typography> + <Typography variant="caption" color="text.secondary"> + {PLEX_SETUP_NOTE} + </Typography> + <Typography variant="caption" color="text.secondary"> + {JELLYFIN_EMBY_SETUP_NOTE} + </Typography> + </Box> + ); +} + +/** Channel Settings section that switches a channel between Videos and TV show. */ +function ChannelTvSection({ + channelName, + tv, + loading, + error, + folders, + onSwitch, + createSubfolder, + setFolderLayout, + disabled = false, +}: ChannelTvSectionProps) { + const headingId = useId(); + const [step, setStep] = useState<Step>('idle'); + const [pending, setPending] = useState(false); + const [switchError, setSwitchError] = useState<string | null>(null); + const [tvChoice, setTvChoice] = useState(''); + const [videosChoice, setVideosChoice] = useState(''); + + if (!tv) { + if (error && !loading) { + return <Alert severity="error">{error}</Alert>; + } + return ( + <Box className="flex justify-center py-10"> + <CircularProgress /> + </Box> + ); + } + + const isTv = tv.layout === 'tv'; + const locked = !tv.canSwitch || pending || disabled; + const videosFolders = folders.filter((folder) => folder.layout === 'videos').map((folder) => folder.name); + + const runSwitch = async (layout: LibraryLayout, folder?: string) => { + setPending(true); + setSwitchError(null); + try { + await onSwitch(layout, folder); + setStep('idle'); + } catch (err: unknown) { + const message = err instanceof Error && err.message ? err.message : SWITCH_FAILED_MESSAGE; + if (layout === 'videos' && folder === undefined && message === CHOOSE_VIDEOS_FOLDER_MESSAGE) { + setVideosChoice(videosFolders[0] ?? ''); + setStep('pickVideos'); + } else { + setSwitchError(message); + } + } finally { + setPending(false); + } + }; + + const chooseVideos = () => { + setSwitchError(null); + if (!isTv) { + setStep('idle'); + return; + } + void runSwitch('videos'); + }; + + const chooseTv = () => { + setSwitchError(null); + if (isTv) { + setStep('idle'); + return; + } + if (tv.defaultFolderLayout === 'tv' || tv.tvFolders.length === 1) { + void runSwitch('tv'); + return; + } + if (tv.tvFolders.length > 1) { + setTvChoice(tv.tvFolders[0]); + setStep('pickTv'); + return; + } + setStep('setupTv'); + }; + + const handleSetupSwitch = async (layout: LibraryLayout, folder: string) => { + await onSwitch(layout, folder); + setStep('idle'); + }; + + const destination = isTv + ? `Episodes go to ${libraryFolderLabel(tv.show?.libraryFolder ?? tv.libraryFolder)}/${tv.show?.folderName ?? channelName}/Season YYYY/` + : `Videos are saved movie-style in ${libraryFolderLabel(tv.libraryFolder)}.`; + + return ( + <Box className="flex flex-col gap-4"> + <Box className="flex flex-col gap-2"> + <Typography id={headingId} variant="subtitle2" className="font-semibold"> + Show this channel as + </Typography> + <Box role="group" aria-labelledby={headingId} className="flex flex-wrap gap-2"> + <Button + variant={isTv ? 'outlined' : 'contained'} + color={isTv ? 'inherit' : 'primary'} + size="small" + aria-pressed={!isTv} + disabled={locked} + onClick={chooseVideos} + > + Videos + </Button> + <Button + variant={isTv ? 'contained' : 'outlined'} + color={isTv ? 'primary' : 'inherit'} + size="small" + aria-pressed={isTv} + disabled={locked} + onClick={chooseTv} + > + TV show + </Button> + </Box> + <Typography variant="body2" color="text.secondary"> + {destination} + </Typography> + <Typography variant="caption" color="text.secondary"> + {tv.canSwitch ? SWITCH_SAVES_NOTE : HAS_DOWNLOADS_NOTE} + </Typography> + </Box> + + {error && <Alert severity="error">{error}</Alert>} + + {switchError && ( + <Alert severity="error" onClose={() => setSwitchError(null)}> + {switchError} + </Alert> + )} + + {step === 'pickTv' && !isTv && ( + <FolderPicker + prompt="Choose the TV folder for this channel's show." + label="TV folder" + folders={tv.tvFolders} + value={tvChoice} + onChange={setTvChoice} + actionLabel="Switch to TV show" + onConfirm={() => { void runSwitch('tv', tvChoice); }} + disabled={locked} + /> + )} + + {step === 'pickVideos' && isTv && (videosFolders.length > 0 ? ( + <FolderPicker + prompt="Choose the Videos folder to save this channel in." + label="Videos folder" + folders={videosFolders} + value={videosChoice} + onChange={setVideosChoice} + actionLabel="Switch to Videos" + onConfirm={() => { void runSwitch('videos', videosChoice); }} + disabled={locked} + /> + ) : ( + <Alert severity="warning">{NO_VIDEOS_FOLDER_MESSAGE}</Alert> + ))} + + {step === 'setupTv' && !isTv && ( + <TvFolderSetup + createSubfolder={createSubfolder} + setFolderLayout={setFolderLayout} + onSwitch={handleSetupSwitch} + disabled={locked} + /> + )} + + {tv.defaultFolderLayout === 'tv' && <Alert severity="info">{DEFAULT_FOLDER_TV_NOTE}</Alert>} + + {(isTv || step === 'setupTv') && <MediaServerSetupNotes />} + </Box> + ); +} + +export default ChannelTvSection; diff --git a/client/src/components/ChannelPage/components/TvFolderSetup.tsx b/client/src/components/ChannelPage/components/TvFolderSetup.tsx new file mode 100644 index 000000000..c7b7c1bbb --- /dev/null +++ b/client/src/components/ChannelPage/components/TvFolderSetup.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { Alert, Box, Button, TextField, Typography } from '../../ui'; +import { LibraryLayout } from '../../../types/tvShows'; + +export const DEFAULT_TV_FOLDER_NAME = 'TV Shows'; +export const EMPTY_FOLDER_NAME_MESSAGE = 'Enter a folder name.'; +const SETUP_FAILED_MESSAGE = "Couldn't set up the TV folder."; + +export interface TvFolderSetupProps { + /** Registers the subfolder; rejects with the server's message */ + createSubfolder: (name: string) => Promise<void>; + /** Sets a folder's layout; rejects with the server's message */ + setFolderLayout: (name: string, layout: LibraryLayout) => Promise<void>; + /** Switches the channel into the new folder; rejects with the server's message */ + onSwitch: (layout: LibraryLayout, folder: string) => Promise<void>; + disabled?: boolean; +} + +/** Inline step that creates a TV folder and moves the channel into it. */ +function TvFolderSetup({ createSubfolder, setFolderLayout, onSwitch, disabled = false }: TvFolderSetupProps) { + const [name, setName] = useState(DEFAULT_TV_FOLDER_NAME); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const trimmedName = name.trim(); + + const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + if (!trimmedName) { + setError(EMPTY_FOLDER_NAME_MESSAGE); + return; + } + setBusy(true); + setError(null); + try { + await createSubfolder(trimmedName); + await setFolderLayout(trimmedName, 'tv'); + await onSwitch('tv', trimmedName); + } catch (err: unknown) { + setError(err instanceof Error && err.message ? err.message : SETUP_FAILED_MESSAGE); + } finally { + setBusy(false); + } + }; + + return ( + <Box + component="form" + onSubmit={handleSubmit} + className="flex flex-col gap-3 rounded-[var(--radius-ui)] border border-border p-3" + > + <Box className="flex flex-col gap-1"> + <Typography variant="body2" className="font-semibold"> + Set up a TV folder + </Typography> + <Typography variant="caption" color="text.secondary"> + TV shows are saved in a library folder of their own. Create one to switch this channel to it. + </Typography> + </Box> + <TextField + label="Folder name" + value={name} + onChange={(event) => setName(event.target.value)} + size="small" + fullWidth + disabled={busy || disabled} + error={error === EMPTY_FOLDER_NAME_MESSAGE} + helperText={trimmedName ? `Saved as __${trimmedName} in your downloads folder.` : undefined} + /> + {error && ( + <Alert severity="error" onClose={() => setError(null)}> + {error} + </Alert> + )} + <Box> + <Button type="submit" variant="contained" size="small" loading={busy} disabled={disabled}> + Create TV folder + </Button> + </Box> + </Box> + ); +} + +export default TvFolderSetup; diff --git a/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx new file mode 100644 index 000000000..dc8fb01eb --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx @@ -0,0 +1,220 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import ChannelTvSection, { ChannelTvSectionProps } from '../ChannelTvSection'; +import { ChannelTvState, LibraryFolder } from '../../../../types/tvShows'; + +const videosTv: ChannelTvState = { + layout: 'videos', + libraryFolder: 'Gaming', + show: null, + tvFolders: ['Anime'], + defaultFolder: '', + defaultFolderLayout: 'videos', + hasDownloads: false, + canSwitch: true, +}; + +const tvTv: ChannelTvState = { + ...videosTv, + layout: 'tv', + libraryFolder: 'Anime', + show: { name: 'Tech Show', folderName: 'Tech Show', libraryFolder: 'Anime', path: '/videos/__Anime/Tech Show' }, +}; + +const folder = (name: string, layout: 'videos' | 'tv'): LibraryFolder => ({ + name, + layout, + isDefault: false, + hasFiles: false, + channels: 0, +}); + +const libraryFolders = [folder('', 'videos'), folder('Gaming', 'videos'), folder('Anime', 'tv')]; + +function renderSection(overrides: Partial<ChannelTvSectionProps> = {}) { + const props: ChannelTvSectionProps = { + channelName: 'Tech Channel', + tv: videosTv, + loading: false, + error: null, + folders: libraryFolders, + onSwitch: jest.fn().mockResolvedValue(undefined), + createSubfolder: jest.fn().mockResolvedValue(undefined), + setFolderLayout: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; + render(<ChannelTvSection {...props} />); + return { props, user: userEvent.setup() }; +} + +describe('ChannelTvSection', () => { + describe('destination', () => { + test('describes where a Videos channel saves its videos', () => { + renderSection(); + + expect(screen.getByText('Videos are saved movie-style in __Gaming.')).toBeInTheDocument(); + }); + + test("describes where a TV channel's episodes go", () => { + renderSection({ tv: tvTv }); + + expect(screen.getByText('Episodes go to __Anime/Tech Show/Season YYYY/')).toBeInTheDocument(); + }); + + test('uses the channel name before the show exists', () => { + renderSection({ tv: { ...tvTv, libraryFolder: '', show: null } }); + + expect(screen.getByText('Episodes go to Main folder/Tech Channel/Season YYYY/')).toBeInTheDocument(); + }); + }); + + test('marks the current layout as pressed', () => { + renderSection({ tv: tvTv }); + + expect(screen.getByRole('button', { name: 'TV show' })).toHaveAttribute('aria-pressed', 'true'); + }); + + test('locks both choices and explains why when the channel has downloads', () => { + renderSection({ tv: { ...videosTv, hasDownloads: true, canSwitch: false } }); + + expect(screen.getByRole('button', { name: 'Videos' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'TV show' })).toBeDisabled(); + expect( + screen.getByText("This channel already has downloaded videos, so it can't switch between Videos and TV yet.") + ).toBeInTheDocument(); + }); + + describe('switching to TV', () => { + test('switches straight to the only TV folder', async () => { + const { props, user } = renderSection(); + + await user.click(screen.getByRole('button', { name: 'TV show' })); + + expect(props.onSwitch).toHaveBeenCalledWith('tv', undefined); + }); + + test('switches straight away when the default subfolder is a TV folder', async () => { + const { props, user } = renderSection({ + tv: { ...videosTv, tvFolders: ['Anime', 'Kids'], defaultFolder: 'Kids', defaultFolderLayout: 'tv' }, + }); + + await user.click(screen.getByRole('button', { name: 'TV show' })); + + expect(props.onSwitch).toHaveBeenCalledWith('tv', undefined); + }); + + test('asks which TV folder to use when there are several', async () => { + const { props, user } = renderSection({ tv: { ...videosTv, tvFolders: ['Anime', 'Kids'] } }); + + await user.click(screen.getByRole('button', { name: 'TV show' })); + await user.click(screen.getByLabelText('TV folder')); + await user.click(await screen.findByRole('option', { name: '__Kids' })); + await user.click(screen.getByRole('button', { name: 'Switch to TV show' })); + + expect(props.onSwitch).toHaveBeenCalledWith('tv', 'Kids'); + }); + + test('sets up a TV folder when none exists, then switches to it', async () => { + const calls: string[] = []; + const { user } = renderSection({ + tv: { ...videosTv, tvFolders: [] }, + createSubfolder: jest.fn(async (name: string) => { calls.push(`create:${name}`); }), + setFolderLayout: jest.fn(async (name: string, layout: string) => { calls.push(`layout:${name}:${layout}`); }), + onSwitch: jest.fn(async (layout: string, target?: string) => { calls.push(`switch:${layout}:${target}`); }), + }); + + await user.click(screen.getByRole('button', { name: 'TV show' })); + await user.click(screen.getByRole('button', { name: 'Create TV folder' })); + + await waitFor(() => { + expect(calls).toEqual(['create:TV Shows', 'layout:TV Shows:tv', 'switch:tv:TV Shows']); + }); + }); + + test('shows the media server notes during setup', async () => { + const { user } = renderSection({ tv: { ...videosTv, tvFolders: [] } }); + + await user.click(screen.getByRole('button', { name: 'TV show' })); + + expect(screen.getByText('Media server setup')).toBeInTheDocument(); + }); + + test("shows the server's refusal", async () => { + const refusal = "TV shows are video-only. Change this channel's download type to Video before saving it to a TV folder."; + const { user } = renderSection({ onSwitch: jest.fn().mockRejectedValue(new Error(refusal)) }); + + await user.click(screen.getByRole('button', { name: 'TV show' })); + + expect(await screen.findByText(refusal)).toBeInTheDocument(); + }); + }); + + describe('switching to Videos', () => { + test('switches back without asking when the server knows the folder', async () => { + const { props, user } = renderSection({ tv: tvTv }); + + await user.click(screen.getByRole('button', { name: 'Videos' })); + + expect(props.onSwitch).toHaveBeenCalledWith('videos', undefined); + }); + + test('asks for a Videos folder when the server needs one, then retries with it', async () => { + const onSwitch = jest.fn() + .mockRejectedValueOnce(new Error('Choose a Videos folder.')) + .mockResolvedValueOnce(undefined); + const { user } = renderSection({ tv: tvTv, onSwitch }); + + await user.click(screen.getByRole('button', { name: 'Videos' })); + await user.click(await screen.findByLabelText('Videos folder')); + await user.click(await screen.findByRole('option', { name: '__Gaming' })); + await user.click(screen.getByRole('button', { name: 'Switch to Videos' })); + + expect(onSwitch).toHaveBeenLastCalledWith('videos', 'Gaming'); + }); + + test('offers only Videos-layout folders', async () => { + const onSwitch = jest.fn().mockRejectedValueOnce(new Error('Choose a Videos folder.')); + const { user } = renderSection({ tv: tvTv, onSwitch }); + + await user.click(screen.getByRole('button', { name: 'Videos' })); + await user.click(await screen.findByLabelText('Videos folder')); + + expect(await screen.findAllByRole('option')).toHaveLength(2); + }); + }); + + test('explains that the default subfolder is a TV folder', () => { + renderSection({ tv: { ...videosTv, defaultFolder: 'Anime', defaultFolderLayout: 'tv' } }); + + expect( + screen.getByText( + "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show." + ) + ).toBeInTheDocument(); + }); + + test('shows the media server notes for a TV channel', () => { + renderSection({ tv: tvTv }); + + expect(screen.getByText(/Plex: add a TV Shows library for this folder/)).toBeInTheDocument(); + }); + + test('hides the media server notes for a Videos channel', () => { + renderSection(); + + expect(screen.queryByText('Media server setup')).not.toBeInTheDocument(); + }); + + test('shows a spinner while the TV state loads', () => { + renderSection({ tv: null, loading: true }); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + test('shows the load error when the TV state could not load', () => { + renderSection({ tv: null, error: "Failed to load the channel's TV settings" }); + + expect(screen.getByText("Failed to load the channel's TV settings")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/__tests__/TvFolderSetup.test.tsx b/client/src/components/ChannelPage/components/__tests__/TvFolderSetup.test.tsx new file mode 100644 index 000000000..6846d416f --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/TvFolderSetup.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import TvFolderSetup from '../TvFolderSetup'; + +describe('TvFolderSetup', () => { + const setup = () => { + const calls: string[] = []; + const createSubfolder = jest.fn(async (name: string) => { calls.push(`create:${name}`); }); + const setFolderLayout = jest.fn(async (name: string, layout: string) => { calls.push(`layout:${name}:${layout}`); }); + const onSwitch = jest.fn(async (layout: string, folder: string) => { calls.push(`switch:${layout}:${folder}`); }); + render(<TvFolderSetup createSubfolder={createSubfolder} setFolderLayout={setFolderLayout} onSwitch={onSwitch} />); + return { calls, createSubfolder, setFolderLayout, onSwitch, user: userEvent.setup() }; + }; + + test('suggests "TV Shows" and shows where it is saved', () => { + setup(); + + expect(screen.getByLabelText('Folder name')).toHaveValue('TV Shows'); + expect(screen.getByText('Saved as __TV Shows in your downloads folder.')).toBeInTheDocument(); + }); + + test('refuses an empty name without calling the server', async () => { + const { user, createSubfolder } = setup(); + + await user.clear(screen.getByLabelText('Folder name')); + await user.click(screen.getByRole('button', { name: 'Create TV folder' })); + + expect(screen.getByText('Enter a folder name.')).toBeInTheDocument(); + expect(createSubfolder).not.toHaveBeenCalled(); + }); + + test('creates the folder, makes it a TV folder, then switches the channel to it', async () => { + const { user, calls } = setup(); + + await user.clear(screen.getByLabelText('Folder name')); + await user.type(screen.getByLabelText('Folder name'), ' Shows '); + await user.click(screen.getByRole('button', { name: 'Create TV folder' })); + + await waitFor(() => { + expect(calls).toEqual(['create:Shows', 'layout:Shows:tv', 'switch:tv:Shows']); + }); + }); + + test('shows the server message and stops when a step fails', async () => { + const { user, setFolderLayout, onSwitch } = setup(); + setFolderLayout.mockRejectedValueOnce(new Error('__TV Shows already holds downloaded files.')); + + await user.click(screen.getByRole('button', { name: 'Create TV folder' })); + + expect(await screen.findByText('__TV Shows already holds downloaded files.')).toBeInTheDocument(); + expect(onSwitch).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts new file mode 100644 index 000000000..e50f24d3e --- /dev/null +++ b/client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts @@ -0,0 +1,100 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; + +jest.mock('axios', () => ({ + get: jest.fn(), + put: jest.fn(), + isAxiosError: (e: unknown) => Boolean(e && (e as { isAxiosError?: boolean }).isAxiosError), +})); + +const axios = require('axios'); + +import { useChannelTv } from '../useChannelTv'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../../hooks/useLibraryFolders'; + +const TV_STATE = { + layout: 'videos', + libraryFolder: 'Kids', + show: null, + tvFolders: ['TV'], + defaultFolder: 'Kids', + defaultFolderLayout: 'videos', + hasDownloads: false, + canSwitch: true, +}; + +describe('useChannelTv', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: TV_STATE }); + }); + + test('loads the channel TV state', async () => { + const { result } = renderHook(() => useChannelTv('UC1', 'token')); + await waitFor(() => expect(result.current.tv).toEqual(TV_STATE)); + expect(axios.get).toHaveBeenCalledWith('/api/channels/UC1/tv', { headers: { 'x-access-token': 'token' } }); + }); + + test('does not fetch while disabled', () => { + renderHook(() => useChannelTv('UC1', 'token', false)); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('switches the layout and keeps the returned state', async () => { + const tvAfter = { ...TV_STATE, layout: 'tv', libraryFolder: 'TV' }; + axios.put.mockResolvedValueOnce({ data: { settings: { sub_folder: 'TV' }, tv: tvAfter } }); + const { result } = renderHook(() => useChannelTv('UC1', 'token')); + await waitFor(() => expect(result.current.tv).toEqual(TV_STATE)); + + let switched; + await act(async () => { switched = await result.current.switchLayout('tv', 'TV'); }); + + expect(axios.put).toHaveBeenCalledWith( + '/api/channels/UC1/tv/layout', { layout: 'tv', folder: 'TV' }, { headers: { 'x-access-token': 'token' } } + ); + expect(switched).toEqual({ settings: { sub_folder: 'TV' }, tv: tvAfter }); + expect(result.current.tv).toEqual(tvAfter); + }); + + test('sends no folder when none is chosen', async () => { + axios.put.mockResolvedValueOnce({ data: { settings: { sub_folder: 'TV' }, tv: TV_STATE } }); + const { result } = renderHook(() => useChannelTv('UC1', 'token')); + await waitFor(() => expect(result.current.tv).not.toBeNull()); + + await act(async () => { await result.current.switchLayout('videos'); }); + + expect(axios.put.mock.calls[0][1]).toEqual({ layout: 'videos' }); + }); + + test('throws the server refusal', async () => { + axios.put.mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { error: 'has downloads' } } }); + const { result } = renderHook(() => useChannelTv('UC1', 'token')); + await waitFor(() => expect(result.current.tv).not.toBeNull()); + + await expect(result.current.switchLayout('tv')).rejects.toThrow('has downloads'); + }); + + test('refetches when a folder layout changes', async () => { + renderHook(() => useChannelTv('UC1', 'token')); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + + act(() => { window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); }); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + }); + + test('keeps a switch result over an older refetch that finishes later', async () => { + const tvAfter = { ...TV_STATE, layout: 'tv', libraryFolder: 'TV' }; + const { result } = renderHook(() => useChannelTv('UC1', 'token')); + await waitFor(() => expect(result.current.tv).toEqual(TV_STATE)); + + let resolveStaleFetch: (value: unknown) => void = () => {}; + axios.get.mockImplementationOnce(() => new Promise((resolve) => { resolveStaleFetch = resolve; })); + act(() => { window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); }); + axios.put.mockResolvedValueOnce({ data: { settings: { sub_folder: 'TV' }, tv: tvAfter } }); + await act(async () => { await result.current.switchLayout('tv'); }); + await act(async () => { resolveStaleFetch({ data: TV_STATE }); }); + + expect(result.current.tv).toEqual(tvAfter); + }); +}); + diff --git a/client/src/components/ChannelPage/hooks/useChannelTv.ts b/client/src/components/ChannelPage/hooks/useChannelTv.ts new file mode 100644 index 000000000..7cf0bffbd --- /dev/null +++ b/client/src/components/ChannelPage/hooks/useChannelTv.ts @@ -0,0 +1,85 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import axios from 'axios'; +import { ChannelTvState, LibraryLayout } from '../../../types/tvShows'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; + +export interface ChannelLayoutSwitchResult { + settings: { sub_folder: string | null }; + tv: ChannelTvState; +} + +export interface UseChannelTvResult { + tv: ChannelTvState | null; + loading: boolean; + error: string | null; + refetch: () => Promise<void>; + /** Switch the channel between Videos and TV; throws with the server's refusal message */ + switchLayout: (layout: LibraryLayout, folder?: string) => Promise<ChannelLayoutSwitchResult>; +} + +function errorMessage(err: unknown, fallback: string): string { + if (axios.isAxiosError(err)) { + const data = err.response?.data as { error?: string } | undefined; + if (data?.error) return data.error; + } + return fallback; +} + +/** A channel's TV layout state (GET/PUT /api/channels/:channelId/tv). */ +export function useChannelTv(channelId: string | undefined, token: string | null, enabled = true): UseChannelTvResult { + const [tv, setTv] = useState<ChannelTvState | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + // Only the newest request may set state: a refetch started before a layout + // switch (a folder layout change fires one) must not overwrite its result. + const requestSeq = useRef(0); + + const fetchTv = useCallback(async () => { + if (!token || !channelId || !enabled) return; + const seq = ++requestSeq.current; + setLoading(true); + setError(null); + try { + const response = await axios.get<ChannelTvState>(`/api/channels/${encodeURIComponent(channelId)}/tv`, { + headers: { 'x-access-token': token }, + }); + if (seq === requestSeq.current) setTv(response.data); + } catch (err) { + if (seq === requestSeq.current) setError(errorMessage(err, 'Failed to load the channel\'s TV settings')); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [channelId, token, enabled]); + + const switchLayout = useCallback(async (layout: LibraryLayout, folder?: string) => { + if (!token || !channelId) throw new Error('Not signed in'); + const seq = ++requestSeq.current; + setLoading(false); + try { + const response = await axios.put<ChannelLayoutSwitchResult>( + `/api/channels/${encodeURIComponent(channelId)}/tv/layout`, + folder === undefined ? { layout } : { layout, folder }, + { headers: { 'x-access-token': token } } + ); + if (seq === requestSeq.current) setTv(response.data.tv); + return response.data; + } catch (err) { + throw new Error(errorMessage(err, 'Failed to switch the channel\'s layout')); + } + }, [channelId, token]); + + useEffect(() => { + fetchTv(); + }, [fetchTv]); + + // A folder's layout change changes the layout of the channels in it. + useEffect(() => { + const handler = () => { fetchTv(); }; + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + return () => window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + }, [fetchTv]); + + return { tv, loading, error, refetch: fetchTv, switchLayout }; +} + +export default useChannelTv; diff --git a/client/src/components/Configuration/sections/CoreSettingsSection.tsx b/client/src/components/Configuration/sections/CoreSettingsSection.tsx index e595e849f..f9deaae89 100644 --- a/client/src/components/Configuration/sections/CoreSettingsSection.tsx +++ b/client/src/components/Configuration/sections/CoreSettingsSection.tsx @@ -29,11 +29,13 @@ import { ConfigurationCard } from '../common/ConfigurationCard'; import { InfoTooltip } from '../common/InfoTooltip'; import SubtitleLanguageSelector from '../SubtitleLanguageSelector'; import { VideoFilenameTemplate } from './components/VideoFilenameTemplate'; +import { LibraryFoldersList } from './components/LibraryFoldersList'; import { SubfolderAutocomplete } from '../../shared/SubfolderAutocomplete'; import { ManageSubfoldersDialog } from '../../shared/ManageSubfoldersDialog'; import { AddSubfolderDialog } from '../../shared/AddSubfolderDialog'; import { Plus as AddIcon, Settings as SettingsIcon } from '../../../lib/icons'; import { useSubfolders } from '../../../hooks/useSubfolders'; +import { useLibraryFolders } from '../../../hooks/useLibraryFolders'; import { ConfigState, DeploymentEnvironment, PlatformManagedState } from '../types'; import { getChannelFilesOptions } from '../helpers'; import { ScheduleSummary } from './components/ScheduleSummary'; @@ -61,6 +63,8 @@ export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ }) => { // Fetch available subfolders const { subfolders, loading: subfoldersLoading, createSubfolder } = useSubfolders(token); + const libraryFolders = useLibraryFolders(token); + const { layoutOf } = libraryFolders; // State for confirmation dialog when setting default subfolder const [manageOpen, setManageOpen] = useState(false); @@ -576,6 +580,7 @@ export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ label="Default Subfolder" helperText="Default download location for channels using 'Default Subfolder'" showAddAction={false} + layoutOf={layoutOf} /> <Box className="flex items-center min-h-[48px] mt-5"> <InfoTooltip @@ -668,6 +673,12 @@ export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ </Box> </Grid> + <Grid item xs={12}> + <Box className="border-t pt-3"> + <LibraryFoldersList library={libraryFolders} /> + </Box> + </Grid> + <Grid item xs={12}> <Box className="border-t pt-3"> <VideoFilenameTemplate diff --git a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx index 98857f190..7e1544e3b 100644 --- a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx +++ b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx @@ -16,6 +16,15 @@ const meta: Meta<typeof CoreSettingsSection> = { http.get('/api/channels/subfolders', () => HttpResponse.json(['Movies', 'Shows']) ), + http.get('/api/library-folders', () => + HttpResponse.json({ + folders: [ + { name: '', layout: 'videos', isDefault: true, hasFiles: true, channels: 2 }, + { name: 'Movies', layout: 'videos', isDefault: false, hasFiles: false, channels: 1 }, + { name: 'Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 0 }, + ], + }) + ), ], }, }, diff --git a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx index e561cfd4f..bde992fb1 100644 --- a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx @@ -20,6 +20,18 @@ jest.mock('../../../../hooks/useSubfolders', () => ({ }), })); +// Mock useLibraryFolders hook to prevent network requests +jest.mock('../../../../hooks/useLibraryFolders', () => ({ + useLibraryFolders: () => ({ + folders: [{ name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 0 }], + loading: false, + error: null, + layoutOf: () => 'videos', + refetch: () => Promise.resolve(), + setFolderLayout: () => Promise.resolve(), + }), +})); + // Mock SubtitleLanguageSelector to simplify testing jest.mock('../../SubtitleLanguageSelector', () => ({ __esModule: true, @@ -138,6 +150,12 @@ describe('CoreSettingsSection Component', () => { const infoTrigger = await screen.findByRole('button', { name: /Jellyfin \/ Kodi \/ Emby Setting Information/i }); expect(infoTrigger).toHaveAttribute('data-state', 'closed'); }); + + test('renders the library folder layouts in File Structure Settings', () => { + const props = createSectionProps(); + renderWithProviders(<CoreSettingsSection {...props} />); + expect(screen.getByRole('button', { name: 'Layout for Main folder' })).toBeInTheDocument(); + }); }); describe('Create video fanart files Checkbox', () => { diff --git a/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx b/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx new file mode 100644 index 000000000..41ada0257 --- /dev/null +++ b/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx @@ -0,0 +1,177 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + MenuItem, + Select, + Typography, +} from '../../../ui'; +import type { UseLibraryFoldersResult } from '../../../../hooks/useLibraryFolders'; +import { libraryFolderLabel } from '../../../../utils/libraryLayouts'; +import { LibraryFolder, LibraryLayout } from '../../../../types/tvShows'; +import { MainFolderTvDialog } from './MainFolderTvDialog'; + +interface LibraryFoldersListProps { + /** The section's useLibraryFolders result, shared so the page loads the folders once */ + library: UseLibraryFoldersResult; +} + +const MAIN_FOLDER = ''; +const CHANGE_FAILED = 'Failed to change the folder layout'; +const EXPLANATION = + "Each folder's layout must match its media server library: Videos for a Plex Other Videos or Jellyfin/Emby Movies library, TV shows for a TV library."; +const HAS_FILES_CAPTION = "Holds downloaded videos, so its layout can't change yet."; +const DEFAULT_TV_NOTE = + "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; + +const LAYOUT_OPTIONS: { value: LibraryLayout; label: string }[] = [ + { value: 'videos', label: 'Videos' }, + { value: 'tv', label: 'TV shows' }, +]; + +function channelCountText(count: number): string { + return count === 1 ? '1 channel' : `${count} channels`; +} + +interface LibraryFolderRowProps { + folder: LibraryFolder; + disabled: boolean; + onLayoutChange: (folder: LibraryFolder, layout: LibraryLayout) => void; +} + +const LibraryFolderRow: React.FC<LibraryFolderRowProps> = ({ folder, disabled, onLayoutChange }) => { + const label = libraryFolderLabel(folder.name); + + return ( + <li className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"> + <Box className="min-w-0"> + <Box className="flex flex-wrap items-center gap-2"> + <Typography variant="body2" className="font-medium break-all"> + {label} + </Typography> + {folder.isDefault && <Chip label="Default" size="small" variant="outlined" color="primary" />} + </Box> + <Typography variant="caption" color="text.secondary" className="block"> + {channelCountText(folder.channels)} + </Typography> + {folder.hasFiles && ( + <Typography variant="caption" color="text.secondary" className="block"> + {HAS_FILES_CAPTION} + </Typography> + )} + </Box> + <Select + size="small" + value={folder.layout} + disabled={disabled || folder.hasFiles} + onValueChange={(next) => onLayoutChange(folder, next as LibraryLayout)} + inputProps={{ 'aria-label': `Layout for ${label}` }} + className="w-full sm:w-40 shrink-0" + > + {LAYOUT_OPTIONS.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + </li> + ); +}; + +/** Library folders with their layouts (Videos or TV shows). */ +export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library }) => { + const { folders, loading, error, refetch, setFolderLayout } = library; + const [changing, setChanging] = useState(false); + const [changeError, setChangeError] = useState<string | null>(null); + const [confirmMainTv, setConfirmMainTv] = useState(false); + + const applyLayout = async (name: string, layout: LibraryLayout) => { + setChanging(true); + try { + await setFolderLayout(name, layout); + setChangeError(null); + } catch (err: unknown) { + setChangeError(err instanceof Error && err.message ? err.message : CHANGE_FAILED); + } finally { + setChanging(false); + } + }; + + const handleLayoutChange = (folder: LibraryFolder, layout: LibraryLayout) => { + if (layout === folder.layout) return; + if (folder.name === MAIN_FOLDER && layout === 'tv') { + setConfirmMainTv(true); + return; + } + void applyLayout(folder.name, layout); + }; + + const handleConfirmMainTv = async () => { + await applyLayout(MAIN_FOLDER, 'tv'); + setConfirmMainTv(false); + }; + + const defaultIsTv = folders.some((folder) => folder.isDefault && folder.layout === 'tv'); + + return ( + <Box className="flex flex-col gap-3"> + <Typography variant="subtitle2" className="font-bold"> + Library folders + </Typography> + <Typography variant="caption" color="text.secondary"> + {EXPLANATION} + </Typography> + + {changeError && ( + <Alert severity="error" onClose={() => setChangeError(null)}> + {changeError} + </Alert> + )} + + {error && ( + <Alert + severity="error" + action={ + <Button size="small" variant="text" onClick={() => void refetch()}> + Retry + </Button> + } + > + {error} + </Alert> + )} + + {loading && folders.length === 0 ? ( + <Box className="flex items-center gap-2 text-sm text-muted-foreground"> + <CircularProgress size={16} /> + <span>Loading library folders...</span> + </Box> + ) : ( + folders.length > 0 && ( + <Box component="ul" className="m-0 list-none divide-y divide-border border-y border-border p-0"> + {folders.map((folder) => ( + <LibraryFolderRow + key={folder.name || 'main-folder'} + folder={folder} + disabled={changing} + onLayoutChange={handleLayoutChange} + /> + ))} + </Box> + ) + )} + + {defaultIsTv && <Alert severity="info">{DEFAULT_TV_NOTE}</Alert>} + + <MainFolderTvDialog + open={confirmMainTv} + busy={changing} + onCancel={() => setConfirmMainTv(false)} + onConfirm={handleConfirmMainTv} + /> + </Box> + ); +}; diff --git a/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx b/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx new file mode 100644 index 000000000..dc8d31fea --- /dev/null +++ b/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx @@ -0,0 +1,66 @@ +import React, { useId } from 'react'; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '../../../ui'; + +interface MainFolderTvDialogProps { + open: boolean; + onCancel: () => void; + onConfirm: () => void; + busy?: boolean; +} + +const PARAGRAPHS = [ + 'Every channel saved directly in the main folder becomes a TV show. Channels you want to keep as regular videos must move to a subfolder first.', + "Jellyfin and Emby: each __subfolder inside the main folder appears as an extra show in a TV library pointed at the main folder. They can't be excluded per library.", + 'Plex: Youtarr writes a .plexignore file in the main folder so a Plex TV library there skips the __subfolders.', + 'Choose this if all your content is TV-style, or your Plex TV library already points at the main folder (for example, you use the Plex TV Series filename preset).', +]; + +export const MainFolderTvDialog: React.FC<MainFolderTvDialogProps> = ({ + open, + onCancel, + onConfirm, + busy = false, +}) => { + const titleId = useId(); + + return ( + <Dialog + open={open} + onClose={() => { + if (!busy) onCancel(); + }} + aria-labelledby={titleId} + > + <DialogTitle id={titleId}>Use the main folder for TV shows?</DialogTitle> + <DialogContent> + {PARAGRAPHS.map((text, index) => ( + <DialogContentText key={text} className={index > 0 ? 'mt-3' : undefined}> + {text} + </DialogContentText> + ))} + </DialogContent> + <DialogActions> + <Button onClick={onCancel} disabled={busy}> + Cancel + </Button> + <Button + variant="contained" + color="primary" + onClick={onConfirm} + disabled={busy} + startIcon={busy ? <CircularProgress size={14} /> : undefined} + > + Use for TV shows + </Button> + </DialogActions> + </Dialog> + ); +}; diff --git a/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx b/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx index 3872bcd21..f4e8b89ad 100644 --- a/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx +++ b/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx @@ -6,7 +6,9 @@ import { Button, Link, CircularProgress, + Tooltip, } from '../../../ui'; +import { Info as InfoIcon } from '../../../../lib/icons'; import { FILENAME_PRESETS, PLEX_TV_SERIES_PRESET_PREFIX, @@ -37,6 +39,9 @@ const SEVERITY_TEXT: Record<'warn' | 'danger', string> = { "Filename is very long. Downloads are likely to fail on Windows and SMB-mounted NAS shares (260-char path limit). Pure CJK or emoji content can also exceed Linux/macOS' 255-byte per-filename limit.", }; +const TV_SHOW_HINT = + 'For TV-style channels, save the channel as a TV show instead: Channel Settings > TV Show. Episodes then get season folders and NFO files.'; + export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ value, onChange, @@ -111,17 +116,34 @@ export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ /> <Box className="flex flex-wrap gap-2"> - {FILENAME_PRESETS.map((preset) => ( - <Button - key={preset.label} - variant="outlined" - size="small" - onClick={() => onChange(preset.prefix)} - title={preset.description} - > - {preset.label} - </Button> - ))} + {FILENAME_PRESETS.map((preset) => { + const presetButton = ( + <Button + key={preset.label} + variant="outlined" + size="small" + onClick={() => onChange(preset.prefix)} + title={preset.description} + > + {preset.label} + </Button> + ); + if (preset.prefix !== PLEX_TV_SERIES_PRESET_PREFIX) return presetButton; + return ( + <Box key={preset.label} className="inline-flex items-center gap-1"> + {presetButton} + <Tooltip title={TV_SHOW_HINT} placement="top"> + <button + type="button" + aria-label="About saving channels as TV shows" + className="inline-flex items-center justify-center rounded-full p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + <InfoIcon size={16} aria-hidden /> + </button> + </Tooltip> + </Box> + ); + })} </Box> {showTvSeriesPrefixTip && ( diff --git a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx new file mode 100644 index 000000000..4e22afda0 --- /dev/null +++ b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx @@ -0,0 +1,243 @@ +import React from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { LibraryFoldersList } from '../LibraryFoldersList'; +import type { LibraryFolder } from '../../../../../types/tvShows'; +import type { UseLibraryFoldersResult } from '../../../../../hooks/useLibraryFolders'; + +const mockSetFolderLayout = jest.fn(); +const mockRefetch = jest.fn(); + +const FOLDERS: LibraryFolder[] = [ + { name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 3 }, + { name: 'Shows', layout: 'videos', isDefault: false, hasFiles: false, channels: 1 }, + { name: 'Kids', layout: 'tv', isDefault: false, hasFiles: true, channels: 0 }, +]; + +type User = ReturnType<typeof userEvent.setup>; + +// The library folders state the section passes down (its useLibraryFolders result). +let library: UseLibraryFoldersResult; + +function mockHook(overrides: Partial<UseLibraryFoldersResult> = {}) { + library = { + folders: FOLDERS, + loading: false, + error: null, + layoutOf: () => 'videos', + refetch: mockRefetch, + setFolderLayout: mockSetFolderLayout, + ...overrides, + }; +} + +function rowFor(label: string): HTMLElement { + const row = screen.getAllByRole('listitem').find((item) => within(item).queryByText(label)); + if (!row) throw new Error(`No row for ${label}`); + return row; +} + +async function chooseLayout(user: User, folderLabel: string, optionLabel: string) { + await user.click(screen.getByRole('button', { name: `Layout for ${folderLabel}` })); + await user.click(await screen.findByRole('option', { name: optionLabel })); +} + +describe('LibraryFoldersList', () => { + beforeEach(() => { + mockHook(); + }); + + test('renders a row for each folder with its label', () => { + render(<LibraryFoldersList library={library} />); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect(screen.getByText('Main folder')).toBeInTheDocument(); + expect(screen.getByText('__Shows')).toBeInTheDocument(); + expect(screen.getByText('__Kids')).toBeInTheDocument(); + }); + + test('marks the default folder', () => { + render(<LibraryFoldersList library={library} />); + expect(within(rowFor('Main folder')).getByText('Default')).toBeInTheDocument(); + }); + + test('does not mark folders that are not the default', () => { + render(<LibraryFoldersList library={library} />); + expect(within(rowFor('__Shows')).queryByText('Default')).not.toBeInTheDocument(); + }); + + test('shows the plural channel count', () => { + render(<LibraryFoldersList library={library} />); + expect(within(rowFor('Main folder')).getByText('3 channels')).toBeInTheDocument(); + }); + + test('shows the singular channel count', () => { + render(<LibraryFoldersList library={library} />); + expect(within(rowFor('__Shows')).getByText('1 channel')).toBeInTheDocument(); + }); + + test('shows each folder layout as the selected value', () => { + render(<LibraryFoldersList library={library} />); + expect(screen.getByRole('button', { name: 'Layout for __Kids' })).toHaveTextContent('TV shows'); + }); + + test('changes a subfolder layout through the hook', async () => { + const user = userEvent.setup(); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, '__Shows', 'TV shows'); + + expect(mockSetFolderLayout).toHaveBeenCalledWith('Shows', 'tv'); + }); + + test('disables the layout select of a folder that holds downloaded videos', () => { + render(<LibraryFoldersList library={library} />); + expect(screen.getByRole('button', { name: 'Layout for __Kids' })).toBeDisabled(); + }); + + test('explains why a folder with downloaded videos is locked', () => { + render(<LibraryFoldersList library={library} />); + expect( + within(rowFor('__Kids')).getByText("Holds downloaded videos, so its layout can't change yet.") + ).toBeInTheDocument(); + }); + + test('switching the main folder to TV asks for confirmation first', async () => { + const user = userEvent.setup(); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, 'Main folder', 'TV shows'); + + expect( + await screen.findByRole('dialog', { name: 'Use the main folder for TV shows?' }) + ).toBeInTheDocument(); + expect(mockSetFolderLayout).not.toHaveBeenCalled(); + }); + + test('confirming the main folder dialog changes the layout', async () => { + const user = userEvent.setup(); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, 'Main folder', 'TV shows'); + await user.click(await screen.findByRole('button', { name: 'Use for TV shows' })); + + expect(mockSetFolderLayout).toHaveBeenCalledWith('', 'tv'); + }); + + test('closes the main folder dialog once the change is done', async () => { + const user = userEvent.setup(); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, 'Main folder', 'TV shows'); + await user.click(await screen.findByRole('button', { name: 'Use for TV shows' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + test('cancelling the main folder dialog leaves the layout alone', async () => { + const user = userEvent.setup(); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, 'Main folder', 'TV shows'); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(mockSetFolderLayout).not.toHaveBeenCalled(); + }); + + test('switching a TV main folder back to Videos needs no confirmation', async () => { + const user = userEvent.setup(); + mockHook({ + folders: [{ name: '', layout: 'tv', isDefault: true, hasFiles: false, channels: 0 }], + }); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, 'Main folder', 'Videos'); + + expect(mockSetFolderLayout).toHaveBeenCalledWith('', 'videos'); + }); + + test('shows the refusal message from a failed change', async () => { + const user = userEvent.setup(); + mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, '__Shows', 'TV shows'); + + expect(await screen.findByRole('alert')).toHaveTextContent('A download is running'); + }); + + test('dismisses the refusal message', async () => { + const user = userEvent.setup(); + mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, '__Shows', 'TV shows'); + const alert = await screen.findByRole('alert'); + await user.click(within(alert).getByRole('button', { name: 'Close' })); + + expect(screen.queryByText('A download is running')).not.toBeInTheDocument(); + }); + + test('clears the refusal message after the next successful change', async () => { + const user = userEvent.setup(); + mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); + render(<LibraryFoldersList library={library} />); + + await chooseLayout(user, '__Shows', 'TV shows'); + await screen.findByText('A download is running'); + await chooseLayout(user, '__Shows', 'TV shows'); + + await waitFor(() => { + expect(screen.queryByText('A download is running')).not.toBeInTheDocument(); + }); + }); + + test('notes when the default folder is a TV folder', () => { + mockHook({ + folders: [ + { name: '', layout: 'videos', isDefault: false, hasFiles: false, channels: 0 }, + { name: 'Shows', layout: 'tv', isDefault: true, hasFiles: false, channels: 2 }, + ], + }); + render(<LibraryFoldersList library={library} />); + expect(screen.getByText(/The default subfolder is a TV folder/)).toBeInTheDocument(); + }); + + test('has no TV note when the default folder uses the Videos layout', () => { + render(<LibraryFoldersList library={library} />); + expect(screen.queryByText(/The default subfolder is a TV folder/)).not.toBeInTheDocument(); + }); + + test('shows a loading state before the folders arrive', () => { + mockHook({ folders: [], loading: true }); + render(<LibraryFoldersList library={library} />); + expect(screen.getByText('Loading library folders...')).toBeInTheDocument(); + }); + + test('keeps the rows visible while refetching', () => { + mockHook({ loading: true }); + render(<LibraryFoldersList library={library} />); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + }); + + test('shows the load error', () => { + mockHook({ folders: [], error: 'Failed to load library folders' }); + render(<LibraryFoldersList library={library} />); + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load library folders'); + }); + + test('retries loading from the error alert', async () => { + const user = userEvent.setup(); + mockHook({ folders: [], error: 'Failed to load library folders' }); + render(<LibraryFoldersList library={library} />); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + expect(mockRefetch).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx b/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx new file mode 100644 index 000000000..0fb5a39f7 --- /dev/null +++ b/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { MainFolderTvDialog } from '../MainFolderTvDialog'; + +function showDialog(overrides: Partial<React.ComponentProps<typeof MainFolderTvDialog>> = {}) { + const props = { + open: true, + onCancel: jest.fn(), + onConfirm: jest.fn(), + ...overrides, + }; + render(<MainFolderTvDialog {...props} />); + return props; +} + +describe('MainFolderTvDialog', () => { + test('renders nothing when closed', () => { + showDialog({ open: false }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('is titled as a question about the main folder', () => { + showDialog(); + expect( + screen.getByRole('dialog', { name: 'Use the main folder for TV shows?' }) + ).toBeInTheDocument(); + }); + + test('explains that main folder channels become TV shows', () => { + showDialog(); + expect( + screen.getByText(/Every channel saved directly in the main folder becomes a TV show/) + ).toBeInTheDocument(); + }); + + test('explains the Jellyfin and Emby subfolder caveat', () => { + showDialog(); + expect(screen.getByText(/They can't be excluded per library\./)).toBeInTheDocument(); + }); + + test('explains the Plex .plexignore file', () => { + showDialog(); + expect(screen.getByText(/Youtarr writes a \.plexignore file in the main folder/)).toBeInTheDocument(); + }); + + test('describes when to choose this layout', () => { + showDialog(); + expect(screen.getByText(/Choose this if all your content is TV-style/)).toBeInTheDocument(); + }); + + test('confirm calls onConfirm', async () => { + const user = userEvent.setup(); + const props = showDialog(); + await user.click(screen.getByRole('button', { name: 'Use for TV shows' })); + expect(props.onConfirm).toHaveBeenCalledTimes(1); + }); + + test('cancel calls onCancel without confirming', async () => { + const user = userEvent.setup(); + const props = showDialog(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onCancel).toHaveBeenCalledTimes(1); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); + + test('disables both buttons while busy', () => { + showDialog({ busy: true }); + expect(screen.getByRole('button', { name: 'Use for TV shows' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); +}); diff --git a/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx b/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx index c329d91be..1b5d4c58f 100644 --- a/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx +++ b/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; jest.mock('axios', () => ({ @@ -50,6 +51,29 @@ describe('VideoFilenameTemplate', () => { expect(handleChange).toHaveBeenCalledWith('%(title).64B'); }); + describe('TV show hint next to the Plex TV Series preset', () => { + it('points TV-style channels at the TV Show channel setting on hover', async () => { + const user = userEvent.setup(); + render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + + await user.hover(screen.getByRole('button', { name: 'About saving channels as TV shows' })); + + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'For TV-style channels, save the channel as a TV show instead: Channel Settings > TV Show. Episodes then get season folders and NFO files.' + ); + }); + + it('still applies the Plex TV Series preset when clicked', () => { + const handleChange = jest.fn(); + const tvSeriesPrefix = FILENAME_PRESETS.find((preset) => preset.label === 'Plex TV Series')!.prefix; + render(<VideoFilenameTemplate value="x" onChange={handleChange} token="tok" />); + + fireEvent.click(screen.getByRole('button', { name: /plex tv series/i })); + + expect(handleChange).toHaveBeenCalledWith(tvSeriesPrefix); + }); + }); + describe('Plex TV Series channel prefix tip', () => { const tvSeriesPrefix = FILENAME_PRESETS.find((preset) => preset.label === 'Plex TV Series')!.prefix; diff --git a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx index 828cdc8f8..cf6043f80 100644 --- a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx +++ b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx @@ -31,6 +31,9 @@ import { ResolutionSelect } from '../../shared/ResolutionSelect'; import { OptionSelect } from '../../shared/OptionSelect'; import { RatingSelect } from '../../shared/RatingSelect'; import { useSubfolders } from '../../../hooks/useSubfolders'; +import { useLibraryFolders } from '../../../hooks/useLibraryFolders'; +import { effectiveLibraryFolder } from '../../../utils/libraryLayouts'; +import { VIDEO_ONLY_HELPER_TEXT } from '../../shared/AudioFormatSelect'; import { RESOLUTION_OPTIONS, AUDIO_FORMAT_OPTIONS, SelectOption } from '../../../utils/downloadOptions'; const LARGE_DOWNLOAD_WARNING_THRESHOLD = 50; @@ -45,6 +48,8 @@ const DOWNLOAD_TYPE_OPTIONS: SelectOption[] = [ { value: VIDEO_ONLY_CHOICE, label: 'Video Only' }, ...AUDIO_FORMAT_OPTIONS, ]; +// TV folders are video-only, so a TV destination override offers no MP3 types. +const VIDEO_ONLY_DOWNLOAD_TYPE_OPTIONS: SelectOption[] = [DOWNLOAD_TYPE_OPTIONS[0]]; interface DownloadSettingsDialogProps { open: boolean; @@ -99,6 +104,11 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({ // Fetch available subfolders const { subfolders, loading: subfoldersLoading, createSubfolder } = useSubfolders(token); + // Only the manual Destination Override needs folder layouts; load them while open. + const { folders: libraryFolders, layoutOf } = useLibraryFolders(open && mode === 'manual' ? token : null); + const defaultLibraryFolder = libraryFolders.find((folder) => folder.isDefault)?.name ?? ''; + const overrideIsTvFolder = subfolderOverride !== null + && layoutOf(effectiveLibraryFolder(subfolderOverride, defaultLibraryFolder)) === 'tv'; const selectedDefaultOption = RESOLUTION_OPTIONS.find((option) => option.value === defaultResolution); const defaultQualityLabel = selectedDefaultOption @@ -129,12 +139,21 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({ : 'No override (per channel, else Video Only)'; const isMp3Format = audioFormat === 'video_mp3' || audioFormat === 'mp3_only'; - const downloadTypeHelperText = isMp3Format + const downloadTypeHelperText = overrideIsTvFolder + ? VIDEO_ONLY_HELPER_TEXT + : isMp3Format ? 'MP3 files are saved at 192kbps in the same folder as videos.' : audioFormat === null && defaultAudioFormatSource === 'global' ? 'Configured channels use their Download Type setting; all other videos download as Video Only.' : undefined; + // An MP3 type chosen before the override moved to a TV folder becomes Video Only. + useEffect(() => { + if (overrideIsTvFolder && isMp3Format) { + setAudioFormat(VIDEO_ONLY_CHOICE); + } + }, [overrideIsTvFolder, isMp3Format]); + // Auto-detect re-download need useEffect(() => { if (open && !hasUserInteracted) { @@ -483,6 +502,8 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({ subfolders={subfolders} loading={subfoldersLoading} createSubfolder={createSubfolder} + defaultSubfolderDisplay={defaultLibraryFolder || null} + layoutOf={layoutOf} label="Override Destination" helperText="Configured channels use their subfolder, unconfigured channels use global default." /> @@ -493,7 +514,7 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({ <OptionSelect className="mb-4 audio-control audio-control--download-type" - options={DOWNLOAD_TYPE_OPTIONS} + options={overrideIsTvFolder ? VIDEO_ONLY_DOWNLOAD_TYPE_OPTIONS : DOWNLOAD_TYPE_OPTIONS} label="Download Type" emptyLabel={downloadTypeEmptyLabel} value={audioFormat} diff --git a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx index 5de294b65..34af2255e 100644 --- a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx +++ b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx @@ -4,6 +4,24 @@ import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import DownloadSettingsDialog from '../DownloadSettingsDialog'; +jest.mock('../../../../hooks/useLibraryFolders', () => ({ + useLibraryFolders: jest.fn(), +})); + +const { useLibraryFolders } = require('../../../../hooks/useLibraryFolders'); + +const mockLibraryFolders = ( + folders: Array<{ name: string; layout: 'videos' | 'tv'; isDefault: boolean }> +) => { + const layouts = new Map(folders.map((folder) => [folder.name, folder.layout])); + useLibraryFolders.mockReturnValue({ + folders: folders.map((folder) => ({ ...folder, hasFiles: false, channels: 0 })), + loading: false, + error: null, + layoutOf: (name: string) => layouts.get(name) || 'videos', + }); +}; + describe('DownloadSettingsDialog', () => { const mockOnClose = jest.fn(); const mockOnConfirm = jest.fn(); @@ -17,6 +35,7 @@ describe('DownloadSettingsDialog', () => { beforeEach(() => { jest.clearAllMocks(); localStorage.clear(); + mockLibraryFolders([{ name: '', layout: 'videos', isDefault: true }]); }); describe('Rendering', () => { @@ -1159,6 +1178,83 @@ describe('DownloadSettingsDialog', () => { }); }); + describe('TV destination override', () => { + const openCustomSettings = () => { + fireEvent.click(screen.getByRole('checkbox', { name: /Use custom settings/i })); + }; + + const chooseDestination = async (user: ReturnType<typeof userEvent.setup>, name: RegExp) => { + await user.click(screen.getByLabelText('Override Destination')); + await user.click(screen.getByRole('option', { name })); + }; + + beforeEach(() => { + mockLibraryFolders([{ name: '', layout: 'tv', isDefault: true }]); + }); + + test('hides the MP3 download types for a TV destination', async () => { + const user = userEvent.setup(); + render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); + openCustomSettings(); + + await chooseDestination(user, /Root directory \(no subfolder\) \(TV\)/); + await user.click(screen.getByLabelText('Download Type')); + + expect(screen.getByRole('option', { name: 'Video Only' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'MP3 Only' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Video + MP3' })).not.toBeInTheDocument(); + }); + + test('explains that TV folders are video-only', async () => { + const user = userEvent.setup(); + render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); + openCustomSettings(); + + await chooseDestination(user, /Root directory \(no subfolder\) \(TV\)/); + + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + }); + + test('keeps the MP3 download types when no destination override is chosen', async () => { + const user = userEvent.setup(); + render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); + openCustomSettings(); + + await user.click(screen.getByLabelText('Download Type')); + + expect(screen.getByRole('option', { name: 'MP3 Only' })).toBeInTheDocument(); + }); + + test('resets a selected MP3 type to Video Only when the destination becomes a TV folder', async () => { + const user = userEvent.setup(); + render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); + openCustomSettings(); + + await user.click(screen.getByLabelText('Download Type')); + await user.click(screen.getByRole('option', { name: 'MP3 Only' })); + await chooseDestination(user, /Root directory \(no subfolder\) \(TV\)/); + fireEvent.click(screen.getByRole('button', { name: /Start Download/i })); + + expect(mockOnConfirm).toHaveBeenCalledWith( + expect.objectContaining({ subfolder: '##ROOT##', audioFormat: null }) + ); + }); + + test('resolves the global default destination through the default subfolder', async () => { + const user = userEvent.setup(); + mockLibraryFolders([ + { name: '', layout: 'videos', isDefault: false }, + { name: 'Shows', layout: 'tv', isDefault: true }, + ]); + render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); + openCustomSettings(); + + await chooseDestination(user, /Use Global Default Subfolder \(TV\)/); + + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + }); + }); + describe('Large Batch Warning', () => { const renderDialog = (props: { videoCount?: number }) => render(<DownloadSettingsDialog {...defaultProps} mode="manual" {...props} />); diff --git a/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx b/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx index 53e6452c0..6c08821c8 100644 --- a/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx @@ -19,6 +19,8 @@ import { AudioFormatSelect } from '../../shared/AudioFormatSelect'; import { RatingSelect } from '../../shared/RatingSelect'; import { useSubfolders } from '../../../hooks/useSubfolders'; import { useConfig } from '../../../hooks/useConfig'; +import { useLibraryFolders } from '../../../hooks/useLibraryFolders'; +import { effectiveLibraryFolder } from '../../../utils/libraryLayouts'; import { usePlaylistMutations } from '../../../hooks/usePlaylistMutations'; import { Playlist, PlaylistSortOrderSetting, PlaylistSubscribeSettings } from '../../../types/playlist'; @@ -71,6 +73,8 @@ const PlaylistSettingsDialog: React.FC<PlaylistSettingsDialogProps> = ({ const { subfolders, loading: subfoldersLoading, createSubfolder } = useSubfolders(token); const { config, refetch: refetchConfig } = useConfig(token); const { updateSettings, pending, error } = usePlaylistMutations({ token }); + const { layoutOf } = useLibraryFolders(open ? token : null); + const isTvFolder = layoutOf(effectiveLibraryFolder(form.default_sub_folder, config.defaultSubfolder)) === 'tv'; useEffect(() => { if (open) { @@ -151,6 +155,7 @@ const PlaylistSettingsDialog: React.FC<PlaylistSettingsDialogProps> = ({ loading={subfoldersLoading} createSubfolder={createSubfolder} defaultSubfolderDisplay={config.defaultSubfolder || null} + layoutOf={layoutOf} label="Default Subfolder" helperText="Where this playlist's videos are saved when the channel has no subfolder of its own." /> @@ -177,8 +182,9 @@ const PlaylistSettingsDialog: React.FC<PlaylistSettingsDialogProps> = ({ <AudioFormatSelect value={form.audio_format} onChange={(value) => update('audio_format', value)} + videoOnly={isTvFolder} helperText={ - form.audio_format + form.audio_format && !isTvFolder ? form.audio_format === 'mp3_only' ? MP3_HELPER_TEXT + MP3_ONLY_SYNC_HINT : MP3_HELPER_TEXT diff --git a/client/src/components/PlaylistPage/components/__tests__/PlaylistSettingsDialog.test.tsx b/client/src/components/PlaylistPage/components/__tests__/PlaylistSettingsDialog.test.tsx index dcb7175d6..dbb94e803 100644 --- a/client/src/components/PlaylistPage/components/__tests__/PlaylistSettingsDialog.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/PlaylistSettingsDialog.test.tsx @@ -11,19 +11,36 @@ jest.mock('../../../shared/SubfolderAutocomplete', () => ({ SubfolderAutocomplete: ({ value, onChange, + layoutOf, }: { value: string | null; onChange: (value: string | null) => void; + layoutOf?: (name: string) => string; }) => { const React = require('react'); return React.createElement( 'button', - { 'data-testid': 'subfolder-mock', onClick: () => onChange('Kids') }, + { + 'data-testid': 'subfolder-mock', + 'data-kids-layout': layoutOf ? layoutOf('Kids') : 'none', + onClick: () => onChange('Kids'), + }, `subfolder:${value ?? 'null'}` ); }, })); +// Library folders named here have the TV layout ('' = main folder). +let mockTvFolders: string[] = []; +jest.mock('../../../../hooks/useLibraryFolders', () => ({ + useLibraryFolders: () => ({ + folders: [], + loading: false, + error: null, + layoutOf: (name: string) => (mockTvFolders.includes(name) ? 'tv' : 'videos'), + }), +})); + // Stable return references: these mirror the real hooks' memoized values. // A fresh function/object per render would retrigger the dialog's open effect // (refetchConfig is in its deps) and loop forever. @@ -89,6 +106,7 @@ function setupDialog(overrides: Partial<React.ComponentProps<typeof PlaylistSett describe('PlaylistSettingsDialog', () => { beforeEach(() => { + mockTvFolders = []; mockMutationsReturn = { updateSettings: jest.fn().mockResolvedValue(true), pending: false, @@ -258,6 +276,39 @@ describe('PlaylistSettingsDialog', () => { expect(screen.queryByText(/applies the next time this playlist syncs/i)).not.toBeInTheDocument(); }); + describe('TV folders', () => { + test('passes the folder layouts to the subfolder picker', () => { + mockTvFolders = ['Kids']; + setupDialog(); + expect(screen.getByTestId('subfolder-mock')).toHaveAttribute('data-kids-layout', 'tv'); + }); + + test('hides the MP3 download types once a TV subfolder is chosen', async () => { + mockTvFolders = ['Kids']; + setupDialog(); + + fireEvent.click(screen.getByTestId('subfolder-mock')); + fireEvent.mouseDown(screen.getByLabelText('Download Type')); + + expect(await screen.findByRole('option', { name: 'Video Only (default)' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'MP3 Only' })).not.toBeInTheDocument(); + }); + + test('explains that TV folders are video-only for a playlist saving to a TV main folder', () => { + mockTvFolders = ['']; + setupDialog(); + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + }); + + test('keeps the MP3 download types for a Videos folder', async () => { + setupDialog(); + + fireEvent.mouseDown(screen.getByLabelText('Download Type')); + + expect(await screen.findByRole('option', { name: 'MP3 Only' })).toBeInTheDocument(); + }); + }); + test('opens reset confirmation from an established starting point', () => { const props = setupDialog({ playlist: { ...basePlaylist, auto_download_baseline_at: '2026-09-01T00:00:00Z' }, diff --git a/client/src/components/Subscriptions.tsx b/client/src/components/Subscriptions.tsx index 4a3511925..52f98cf81 100644 --- a/client/src/components/Subscriptions.tsx +++ b/client/src/components/Subscriptions.tsx @@ -39,6 +39,7 @@ import useMediaQuery from '../hooks/useMediaQuery'; import { useNavigate, useLocation } from 'react-router-dom'; import WebSocketContext, { Message } from '../contexts/WebSocketContext'; import { useConfig } from '../hooks/useConfig'; +import { useLibraryFolders } from '../hooks/useLibraryFolders'; import { Channel } from '../types/Channel'; import { useChannelList } from './Subscriptions/hooks/useChannelList'; import { ChannelLookupResult, useChannelMutations } from './Subscriptions/hooks/useChannelMutations'; @@ -76,6 +77,8 @@ type ViewMode = 'list' | 'grid'; type SortOrder = 'asc' | 'desc'; const AUTO_DOWNLOADS_COLUMN_LABEL = 'Auto downloads'; +const TV_FILTER_LABEL = 'TV shows only'; +const TV_FILTER_DESCRIPTION = 'Channels saved as TV shows'; interface SubscriptionsProps { token: string | null; @@ -99,6 +102,7 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { const [newSubscriptionUrl, setNewSubscriptionUrl] = useState(''); const [viewMode, setViewMode] = useState<ViewMode>('list'); const [selectedSubFolder, setSelectedSubFolder] = useState<string | null>(null); + const [tvOnly, setTvOnly] = useState(false); const [typeFilter, setTypeFilter] = useState<SubscriptionsFilterValue>(() => { const navState = location.state as { tab?: unknown } | null; return navState?.tab === 'playlists' ? 'playlists' : 'channels'; @@ -141,8 +145,9 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { searchTerm: filterValue, sortOrder, subFolder: selectedSubFolder || undefined, + layout: tvOnly ? ('tv' as const) : undefined, append: useInfiniteScroll, - }), [token, page, effectivePageSize, filterValue, sortOrder, selectedSubFolder, useInfiniteScroll]); + }), [token, page, effectivePageSize, filterValue, sortOrder, selectedSubFolder, tvOnly, useInfiniteScroll]); const { channels: serverChannels, @@ -154,6 +159,10 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { subFolders: apiSubFolders, } = useChannelList(channelListParams); + const { folders: libraryFolders } = useLibraryFolders(token); + // Offered once a TV folder exists; kept while active so it can be turned off. + const showTvFilter = tvOnly || libraryFolders.some((folder) => folder.layout === 'tv'); + const { playlists, total: playlistTotal, @@ -214,7 +223,7 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { const showDesktopListColumns = !isMobile && viewMode === 'list'; const listColumnLabels = ['Channel', 'Quality / Folder', AUTO_DOWNLOADS_COLUMN_LABEL, 'Filters']; - const folderControlActive = Boolean(selectedSubFolder); + const folderControlActive = Boolean(selectedSubFolder) || tvOnly; const availableFolderOptions = useMemo(() => { const folderSet = new Set<string>([DEFAULT_SUBFOLDER_KEY]); (apiSubFolders || []).forEach((folder) => folderSet.add(folder)); @@ -225,14 +234,16 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { return a.localeCompare(b); }); }, [apiSubFolders, pendingAdditions]); - const folderTooltip = selectedSubFolder - ? `Filtering by ${formatSubFolderLabel(selectedSubFolder)}` : - 'Filter or group by folder'; + const folderTooltip = tvOnly + ? 'Filtering by TV shows' + : selectedSubFolder + ? `Filtering by ${formatSubFolderLabel(selectedSubFolder)}` + : 'Filter or group by folder'; let listRowIndex = 0; useEffect(() => { setPage(1); - }, [filterValue, sortOrder, viewMode, isMobile, selectedSubFolder]); + }, [filterValue, sortOrder, viewMode, isMobile, selectedSubFolder, tvOnly]); useEffect(() => { if (page > pageCount) { @@ -449,8 +460,16 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { setFolderMenuAnchor(null); }; + // Folder and TV choices share one single-select menu. const handleSubFolderSelect = (value: string | null) => { setSelectedSubFolder(value); + setTvOnly(false); + setFolderMenuAnchor(null); + }; + + const handleTvOnlySelect = () => { + setSelectedSubFolder(null); + setTvOnly(true); setFolderMenuAnchor(null); }; @@ -585,11 +604,16 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { secondary={sortOrder === 'asc' ? 'Currently A → Z' : 'Currently Z → A'} /> </MenuItem> - {availableFolderOptions.length > 1 && [ + {(availableFolderOptions.length > 1 || showTvFilter) && [ <Divider key="folder-divider" style={{ margin: '4px 0' }} />, - <MenuItem key="folder-all" selected={!selectedSubFolder} onClick={() => { handleSubFolderSelect(null); handleMobileActionsClose(); }}> + <MenuItem key="folder-all" selected={!selectedSubFolder && !tvOnly} onClick={() => { handleSubFolderSelect(null); handleMobileActionsClose(); }}> <ListItemText primary="All folders" secondary="Show every channel" /> </MenuItem>, + ...(showTvFilter ? [ + <MenuItem key="folder-tv" selected={tvOnly} onClick={() => { handleTvOnlySelect(); handleMobileActionsClose(); }}> + <ListItemText primary={TV_FILTER_LABEL} secondary={TV_FILTER_DESCRIPTION} /> + </MenuItem>, + ] : []), ...availableFolderOptions.map((folder) => ( <MenuItem key={folder} @@ -818,9 +842,14 @@ const Subscriptions: React.FC<SubscriptionsProps> = ({ token }) => { </div> <Menu anchorEl={folderMenuAnchor} open={Boolean(folderMenuAnchor)} onClose={handleFolderMenuClose}> - <MenuItem selected={!selectedSubFolder} onClick={() => handleSubFolderSelect(null)}> + <MenuItem selected={!selectedSubFolder && !tvOnly} onClick={() => handleSubFolderSelect(null)}> <ListItemText primary="All folders" secondary="Show every channel" /> </MenuItem> + {showTvFilter && ( + <MenuItem selected={tvOnly} onClick={handleTvOnlySelect}> + <ListItemText primary={TV_FILTER_LABEL} secondary={TV_FILTER_DESCRIPTION} /> + </MenuItem> + )} <Divider style={{ margin: '4px 0' }} /> {availableFolderOptions.length === 0 ? ( <MenuItem disabled> diff --git a/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx b/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx index 62872d730..50d740fe6 100644 --- a/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx +++ b/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx @@ -134,6 +134,7 @@ const AddChannelSettingsDialog: React.FC<AddChannelSettingsDialogProps> = ({ defaultSubfolder={config.defaultSubfolder || null} subfolderLabel="Subfolder" subfolderHelperText="Choose where this channel's videos are saved" + showTvShowCaption /> <Typography variant="caption" color="text.secondary"> diff --git a/client/src/components/Subscriptions/components/ChannelCard.tsx b/client/src/components/Subscriptions/components/ChannelCard.tsx index a2fc04d26..2fb84076b 100644 --- a/client/src/components/Subscriptions/components/ChannelCard.tsx +++ b/client/src/components/Subscriptions/components/ChannelCard.tsx @@ -3,7 +3,7 @@ import { Link as RouterLink } from 'react-router-dom'; import { Avatar, Card, CardActionArea, CardContent, Chip, Tooltip, Typography } from '../../ui'; import { Delete as DeleteIcon, Edit as EditIcon, Image as ImageIcon, Folder as FolderIcon } from '../../../lib/icons'; import { Channel } from '../../../types/Channel'; -import { QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip } from './chips'; +import { QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip, TvChip } from './chips'; const THUMBNAIL_ASPECT_PADDING = '56.25%'; @@ -139,6 +139,7 @@ const ChannelCard: React.FC<ChannelCardProps> = ({ <Typography variant="body2" color="text.secondary" noWrap> {channel.sub_folder ? `/${channel.sub_folder}` : 'Default Folder'} </Typography> + {channel.layout === 'tv' && <TvChip />} </div> </div> {isPendingAddition && <Chip label="Pending" size="small" color="warning" />} diff --git a/client/src/components/Subscriptions/components/ChannelListRow.tsx b/client/src/components/Subscriptions/components/ChannelListRow.tsx index d659852e3..9995da10d 100644 --- a/client/src/components/Subscriptions/components/ChannelListRow.tsx +++ b/client/src/components/Subscriptions/components/ChannelListRow.tsx @@ -10,7 +10,7 @@ import { } from '../../ui'; import { Delete as DeleteIcon, Edit as EditIcon } from '../../../lib/icons'; import { Channel } from '../../../types/Channel'; -import { SubFolderChip, QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip } from './chips'; +import { SubFolderChip, QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip, TvChip } from './chips'; import RatingBadge from '../../shared/RatingBadge'; interface ChannelListRowProps { @@ -45,6 +45,7 @@ const ChannelListRow: React.FC<ChannelListRowProps> = ({ : '/images/channelthumb-default.jpg'; const canNavigate = Boolean(channel.channel_id) && !isPendingAddition; + const isTvChannel = channel.layout === 'tv'; const renderChannelHeader = () => { const headerProps = { @@ -84,6 +85,7 @@ const ChannelListRow: React.FC<ChannelListRowProps> = ({ <div style={{ marginTop: 2, display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}> <QualityChip videoQuality={channel.video_quality} globalPreferredResolution={globalPreferredResolution} /> <SubFolderChip subFolder={channel.sub_folder} /> + {isTvChannel && <TvChip />} <RatingBadge rating={channel.default_rating} /> <ProtectedChip autoRemovalProtected={channel.auto_removal_protected} @@ -214,6 +216,7 @@ const ChannelListRow: React.FC<ChannelListRowProps> = ({ > <QualityChip videoQuality={channel.video_quality} globalPreferredResolution={globalPreferredResolution} /> <SubFolderChip subFolder={channel.sub_folder} /> + {isTvChannel && <TvChip />} <RatingBadge rating={channel.default_rating} /> <ProtectedChip autoRemovalProtected={channel.auto_removal_protected} diff --git a/client/src/components/Subscriptions/components/SubscriptionSettingsFields.tsx b/client/src/components/Subscriptions/components/SubscriptionSettingsFields.tsx index 298b49189..9f649ea31 100644 --- a/client/src/components/Subscriptions/components/SubscriptionSettingsFields.tsx +++ b/client/src/components/Subscriptions/components/SubscriptionSettingsFields.tsx @@ -1,5 +1,8 @@ import React from 'react'; +import { Typography } from '../../ui'; import { useSubfolders } from '../../../hooks/useSubfolders'; +import { useLibraryFolders } from '../../../hooks/useLibraryFolders'; +import { effectiveLibraryFolder } from '../../../utils/libraryLayouts'; import { SubfolderAutocomplete } from '../../shared/SubfolderAutocomplete'; import { ResolutionSelect } from '../../shared/ResolutionSelect'; import { AudioFormatSelect } from '../../shared/AudioFormatSelect'; @@ -21,9 +24,12 @@ interface SubscriptionSettingsFieldsProps { /** Extra helper text appended when MP3 Only is chosen. */ mp3OnlyHint?: string; disabled?: boolean; + /** Note under the folder picker that a TV folder saves the subscription as a TV show (channels). */ + showTvShowCaption?: boolean; } const MP3_HELPER_TEXT = 'MP3 files are saved at 192kbps in the same folder as videos.'; +const TV_SHOW_CAPTION = 'Saved as a TV show (season folders and episode NFO files).'; /** Quality, download type, and subfolder pickers shared by the Add Channel and Add Playlist dialogs. */ const SubscriptionSettingsFields: React.FC<SubscriptionSettingsFieldsProps> = ({ @@ -36,8 +42,11 @@ const SubscriptionSettingsFields: React.FC<SubscriptionSettingsFieldsProps> = ({ subfolderHelperText, mp3OnlyHint = '', disabled = false, + showTvShowCaption = false, }) => { const { subfolders, loading: subfoldersLoading, createSubfolder } = useSubfolders(token); + const { layoutOf } = useLibraryFolders(token); + const isTvFolder = layoutOf(effectiveLibraryFolder(values.sub_folder, defaultSubfolder)) === 'tv'; return ( <div className="flex flex-col gap-4"> @@ -51,23 +60,32 @@ const SubscriptionSettingsFields: React.FC<SubscriptionSettingsFieldsProps> = ({ <AudioFormatSelect value={values.audio_format} onChange={(value) => onChange({ audio_format: value })} - helperText={values.audio_format + videoOnly={isTvFolder} + helperText={values.audio_format && !isTvFolder ? MP3_HELPER_TEXT + (values.audio_format === 'mp3_only' ? mp3OnlyHint : '') : undefined} disabled={disabled} /> - <SubfolderAutocomplete - mode="channel" - value={values.sub_folder} - onChange={(value) => onChange({ sub_folder: value })} - subfolders={subfolders} - loading={subfoldersLoading} - createSubfolder={createSubfolder} - defaultSubfolderDisplay={defaultSubfolder} - label={subfolderLabel} - helperText={subfolderHelperText} - disabled={disabled} - /> + <div> + <SubfolderAutocomplete + mode="channel" + value={values.sub_folder} + onChange={(value) => onChange({ sub_folder: value })} + subfolders={subfolders} + loading={subfoldersLoading} + createSubfolder={createSubfolder} + defaultSubfolderDisplay={defaultSubfolder} + layoutOf={layoutOf} + label={subfolderLabel} + helperText={subfolderHelperText} + disabled={disabled} + /> + {showTvShowCaption && isTvFolder && ( + <Typography variant="caption" color="text.secondary" className="block mt-1"> + {TV_SHOW_CAPTION} + </Typography> + )} + </div> </div> ); }; diff --git a/client/src/components/Subscriptions/components/__tests__/AddChannelSettingsDialog.test.tsx b/client/src/components/Subscriptions/components/__tests__/AddChannelSettingsDialog.test.tsx index 8d6b917e5..3cab3c5ee 100644 --- a/client/src/components/Subscriptions/components/__tests__/AddChannelSettingsDialog.test.tsx +++ b/client/src/components/Subscriptions/components/__tests__/AddChannelSettingsDialog.test.tsx @@ -20,6 +20,17 @@ jest.mock('../../../../hooks/useSubfolders', () => ({ }), })); +// Library folders named here have the TV layout ('' = main folder). +let mockTvFolders: string[] = []; +jest.mock('../../../../hooks/useLibraryFolders', () => ({ + useLibraryFolders: () => ({ + folders: [], + loading: false, + error: null, + layoutOf: (name: string) => (mockTvFolders.includes(name) ? 'tv' : 'videos'), + }), +})); + const { useConfig } = require('../../../../hooks/useConfig'); const channel: PendingChannel = { @@ -51,6 +62,7 @@ const renderDialog = (overrides: Partial<React.ComponentProps<typeof AddChannelS describe('AddChannelSettingsDialog', () => { beforeEach(() => { jest.clearAllMocks(); + mockTvFolders = []; useConfig.mockReturnValue({ config: { preferredResolution: '1080', defaultSubfolder: null, channelAutoDownload: true }, loading: false, @@ -130,6 +142,13 @@ describe('AddChannelSettingsDialog', () => { expect(screen.queryByText(/Automatic downloads are turned off/)).not.toBeInTheDocument(); }); + test('notes that a channel saved to a TV folder becomes a TV show', () => { + mockTvFolders = ['']; + renderDialog(); + + expect(screen.getByText('Saved as a TV show (season folders and episode NFO files).')).toBeInTheDocument(); + }); + test('cancel closes without adding the channel', async () => { const user = userEvent.setup(); const { onConfirm, onClose } = renderDialog(); diff --git a/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx b/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx index 97d5deca3..c71c0e1ae 100644 --- a/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx +++ b/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx @@ -83,6 +83,10 @@ jest.mock('../chips', () => ({ 'data-keep-recent-count': keepRecentCount ?? undefined, }, autoRemovalProtected ? 'Protected (All)' : `Protected (${keepRecentCount})`); }, + TvChip: function MockTvChip() { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'tv-chip' }, 'TV'); + }, })); describe('ChannelCard Component', () => { @@ -268,6 +272,20 @@ describe('ChannelCard Component', () => { renderWithProviders(<ChannelCard {...defaultProps} />); expect(screen.queryByTestId('protected-chip')).not.toBeInTheDocument(); }); + + test('renders TvChip for a channel in a TV folder', () => { + renderWithProviders( + <ChannelCard {...defaultProps} channel={{ ...mockChannel, layout: 'tv' }} /> + ); + expect(screen.getByTestId('tv-chip')).toBeInTheDocument(); + }); + + test('does not render TvChip for a channel in a Videos folder', () => { + renderWithProviders( + <ChannelCard {...defaultProps} channel={{ ...mockChannel, layout: 'videos' }} /> + ); + expect(screen.queryByTestId('tv-chip')).not.toBeInTheDocument(); + }); }); describe('Pending Addition State', () => { diff --git a/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx b/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx index 052da26be..2a7ed1df3 100644 --- a/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx +++ b/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx @@ -113,6 +113,10 @@ jest.mock('../chips', () => ({ autoRemovalProtected ? 'Protected (All)' : `Protected (${keepRecentCount})` ); }, + TvChip: function MockTvChip() { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'tv-chip' }, 'TV'); + }, })); describe('ChannelListRow', () => { @@ -204,6 +208,26 @@ describe('ChannelListRow', () => { expect(screen.queryByTestId('protected-chip')).not.toBeInTheDocument(); }); + + test('renders TvChip for a channel in a TV folder on desktop', () => { + renderWithProviders(<ChannelListRow {...defaultProps} channel={{ ...mockChannel, layout: 'tv' }} />); + + expect(screen.getByTestId('tv-chip')).toBeInTheDocument(); + }); + + test('renders TvChip for a channel in a TV folder on mobile', () => { + renderWithProviders( + <ChannelListRow {...defaultProps} isMobile channel={{ ...mockChannel, layout: 'tv' }} /> + ); + + expect(screen.getByTestId('tv-chip')).toBeInTheDocument(); + }); + + test('does not render TvChip for a channel in a Videos folder', () => { + renderWithProviders(<ChannelListRow {...defaultProps} channel={{ ...mockChannel, layout: 'videos' }} />); + + expect(screen.queryByTestId('tv-chip')).not.toBeInTheDocument(); + }); }); describe('Interactions', () => { diff --git a/client/src/components/Subscriptions/components/__tests__/NewPlaylistSettings.test.tsx b/client/src/components/Subscriptions/components/__tests__/NewPlaylistSettings.test.tsx index f8fe74712..011648813 100644 --- a/client/src/components/Subscriptions/components/__tests__/NewPlaylistSettings.test.tsx +++ b/client/src/components/Subscriptions/components/__tests__/NewPlaylistSettings.test.tsx @@ -19,6 +19,17 @@ jest.mock('../../../../hooks/useSubfolders', () => ({ }), })); +// Library folders named here have the TV layout ('' = main folder). +let mockTvFolders: string[] = []; +jest.mock('../../../../hooks/useLibraryFolders', () => ({ + useLibraryFolders: () => ({ + folders: [], + loading: false, + error: null, + layoutOf: (name: string) => (mockTvFolders.includes(name) ? 'tv' : 'videos'), + }), +})); + const { useConfig } = require('../../../../hooks/useConfig'); const values: NewPlaylistSettingsValues = { @@ -37,6 +48,7 @@ const renderSettings = (overrides: Partial<React.ComponentProps<typeof NewPlayli describe('NewPlaylistSettings', () => { beforeEach(() => { jest.clearAllMocks(); + mockTvFolders = []; useConfig.mockReturnValue({ config: { preferredResolution: '1080', defaultSubfolder: null, channelAutoDownload: true }, loading: false, @@ -75,6 +87,14 @@ describe('NewPlaylistSettings', () => { expect(screen.getByText(/sync to media servers as music playlists/)).toBeInTheDocument(); }); + test('keeps a playlist in a TV folder video-only without the TV show note', () => { + mockTvFolders = ['']; + renderSettings(); + + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + expect(screen.queryByText('Saved as a TV show (season folders and episode NFO files).')).not.toBeInTheDocument(); + }); + test('shows saved settings without letting them change when read-only', () => { renderSettings({ readOnly: true }); diff --git a/client/src/components/Subscriptions/components/__tests__/SubscriptionSettingsFields.test.tsx b/client/src/components/Subscriptions/components/__tests__/SubscriptionSettingsFields.test.tsx index f8f0618a2..73a6941ec 100644 --- a/client/src/components/Subscriptions/components/__tests__/SubscriptionSettingsFields.test.tsx +++ b/client/src/components/Subscriptions/components/__tests__/SubscriptionSettingsFields.test.tsx @@ -15,6 +15,17 @@ jest.mock('../../../../hooks/useSubfolders', () => ({ }), })); +// Library folders named here have the TV layout ('' = main folder). +let mockTvFolders: string[] = []; +jest.mock('../../../../hooks/useLibraryFolders', () => ({ + useLibraryFolders: () => ({ + folders: [], + loading: false, + error: null, + layoutOf: (name: string) => (mockTvFolders.includes(name) ? 'tv' : 'videos'), + }), +})); + const values: SubscriptionSettingsValues = { video_quality: null, audio_format: null, @@ -39,6 +50,10 @@ const renderFields = (overrides: Partial<React.ComponentProps<typeof Subscriptio }; describe('SubscriptionSettingsFields', () => { + beforeEach(() => { + mockTvFolders = []; + }); + test('shows the global quality in the default option', async () => { renderFields(); @@ -73,6 +88,61 @@ describe('SubscriptionSettingsFields', () => { expect(screen.getByText(/MP3 files are saved at 192kbps/)).toBeInTheDocument(); }); + describe('TV folders', () => { + const tvValues = { ...values, sub_folder: 'Kids' }; + const tvShowCaption = 'Saved as a TV show (season folders and episode NFO files).'; + + beforeEach(() => { + mockTvFolders = ['Kids']; + }); + + test('hides the MP3 download types for a TV folder', async () => { + renderFields({ values: tvValues }); + + fireEvent.mouseDown(screen.getByLabelText('Download Type')); + + expect(await screen.findByRole('option', { name: 'Video Only (default)' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'MP3 Only' })).not.toBeInTheDocument(); + }); + + test('explains that TV folders are video-only', () => { + renderFields({ values: tvValues }); + + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + }); + + test('resolves the global default folder through the default subfolder', () => { + renderFields({ defaultSubfolder: 'Kids' }); + + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + }); + + test('labels TV folders in the folder picker', () => { + renderFields({ values: tvValues }); + + expect(screen.getByLabelText('Subfolder')).toHaveValue('__Kids (TV)'); + }); + + test('notes that a channel in a TV folder is saved as a TV show', () => { + renderFields({ values: tvValues, showTvShowCaption: true }); + + expect(screen.getByText(tvShowCaption)).toBeInTheDocument(); + }); + + test('omits the TV show note when not requested', () => { + renderFields({ values: tvValues }); + + expect(screen.queryByText(tvShowCaption)).not.toBeInTheDocument(); + }); + + test('omits the TV show note for a Videos folder', () => { + mockTvFolders = []; + renderFields({ values: tvValues, showTvShowCaption: true }); + + expect(screen.queryByText(tvShowCaption)).not.toBeInTheDocument(); + }); + }); + test('disables every field when read-only', () => { renderFields({ disabled: true }); diff --git a/client/src/components/Subscriptions/components/chips/TvChip.tsx b/client/src/components/Subscriptions/components/chips/TvChip.tsx new file mode 100644 index 000000000..a82ff9f4b --- /dev/null +++ b/client/src/components/Subscriptions/components/chips/TvChip.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Chip } from '../../../../components/ui'; +import { Tv as TvIcon } from '../../../../lib/icons'; +import { SHARED_CHANNEL_META_CHIP_STYLE, SHARED_CHANNEL_META_DEFAULT_SURFACE_STYLE } from '../../../shared/chipStyles'; + +/** Marks a channel whose videos are saved as a TV show. */ +const TvChip: React.FC = () => ( + <Chip + data-testid="tv-chip" + size="small" + color="default" + icon={<TvIcon size={14} style={{ color: 'var(--channel-meta-chip-icon)' }} data-testid="TvIcon" />} + label="TV" + title="Saved as a TV show" + style={{ ...SHARED_CHANNEL_META_CHIP_STYLE, ...SHARED_CHANNEL_META_DEFAULT_SURFACE_STYLE }} + /> +); + +export default TvChip; diff --git a/client/src/components/Subscriptions/components/chips/__tests__/TvChip.test.tsx b/client/src/components/Subscriptions/components/chips/__tests__/TvChip.test.tsx new file mode 100644 index 000000000..5b4a5d06c --- /dev/null +++ b/client/src/components/Subscriptions/components/chips/__tests__/TvChip.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TvChip from '../TvChip'; + +describe('TvChip', () => { + test('labels the channel as a TV show', () => { + render(<TvChip />); + expect(screen.getByTestId('tv-chip')).toHaveTextContent('TV'); + }); +}); diff --git a/client/src/components/Subscriptions/components/chips/index.ts b/client/src/components/Subscriptions/components/chips/index.ts index 1a0702b61..147af3125 100644 --- a/client/src/components/Subscriptions/components/chips/index.ts +++ b/client/src/components/Subscriptions/components/chips/index.ts @@ -6,3 +6,4 @@ export { default as TitleFilterChip } from './TitleFilterChip'; export { default as DownloadFormatConfigIndicator } from './DownloadFormatConfigIndicator'; export { default as TerminatedChip } from './TerminatedChip'; export { default as ProtectedChip } from './ProtectedChip'; +export { default as TvChip } from './TvChip'; diff --git a/client/src/components/Subscriptions/hooks/__tests__/useChannelList.test.ts b/client/src/components/Subscriptions/hooks/__tests__/useChannelList.test.ts index e8478e592..fa1aaf53e 100644 --- a/client/src/components/Subscriptions/hooks/__tests__/useChannelList.test.ts +++ b/client/src/components/Subscriptions/hooks/__tests__/useChannelList.test.ts @@ -653,6 +653,79 @@ describe('useChannelList', () => { }), }); }); + + test('sends layout=tv when the TV filter is active', async () => { + axios.get.mockResolvedValue({ + data: { + channels: [], + total: 0, + totalPages: 0, + }, + }); + + renderHook(() => useChannelList({ ...defaultParams, layout: 'tv' })); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + expect(axios.get).toHaveBeenCalledWith('/getchannels', { + headers: expect.any(Object), + params: expect.objectContaining({ layout: 'tv' }), + }); + }); + + test('omits the layout param when the TV filter is off', async () => { + axios.get.mockResolvedValue({ + data: { + channels: [], + total: 0, + totalPages: 0, + }, + }); + + renderHook(() => useChannelList(defaultParams)); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + expect(axios.get.mock.calls[0][1].params).not.toHaveProperty('layout'); + }); + + test('refetches when the layout filter changes', async () => { + axios.get.mockResolvedValue({ + data: { + channels: [], + total: 0, + totalPages: 0, + }, + }); + + const { rerender } = renderHook( + ({ layout }: { layout?: 'tv' }) => + useChannelList({ + ...defaultParams, + layout, + }), + { initialProps: { layout: undefined as 'tv' | undefined } } + ); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + rerender({ layout: 'tv' as 'tv' | undefined }); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + expect(axios.get).toHaveBeenLastCalledWith('/getchannels', { + headers: expect.any(Object), + params: expect.objectContaining({ layout: 'tv' }), + }); + }); }); describe('Loading State Management', () => { diff --git a/client/src/components/Subscriptions/hooks/useChannelList.ts b/client/src/components/Subscriptions/hooks/useChannelList.ts index 63cfd2d1a..84386288e 100644 --- a/client/src/components/Subscriptions/hooks/useChannelList.ts +++ b/client/src/components/Subscriptions/hooks/useChannelList.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import axios from 'axios'; import { Channel } from '../../../types/Channel'; import { normalizeSubFolderKey } from '../../../utils/channelHelpers'; +import { LibraryLayout } from '../../../types/tvShows'; interface UseChannelListParams { token: string | null; @@ -10,6 +11,8 @@ interface UseChannelListParams { searchTerm: string; sortOrder: 'asc' | 'desc'; subFolder?: string; + /** 'tv' lists only channels that download to a TV folder */ + layout?: LibraryLayout; append?: boolean; } @@ -27,6 +30,7 @@ export const useChannelList = ({ searchTerm, sortOrder, subFolder, + layout, append = false, }: UseChannelListParams) => { const [channels, setChannels] = useState<Channel[]>([]); @@ -59,6 +63,7 @@ export const useChannelList = ({ search: searchTerm || undefined, sortOrder, subFolder: subFolder || undefined, + ...(layout === 'tv' ? { layout } : {}), }, }); @@ -91,7 +96,7 @@ export const useChannelList = ({ } finally { setLoading(false); } - }, [token, page, pageSize, searchTerm, sortOrder, subFolder, append]); + }, [token, page, pageSize, searchTerm, sortOrder, subFolder, layout, append]); useEffect(() => { fetchChannels(); diff --git a/client/src/components/__tests__/ChannelPage.story.tsx b/client/src/components/__tests__/ChannelPage.story.tsx index 04c1a2326..e843bfbf6 100644 --- a/client/src/components/__tests__/ChannelPage.story.tsx +++ b/client/src/components/__tests__/ChannelPage.story.tsx @@ -66,6 +66,26 @@ export const Default: Story = { http.get('/api/channels/subfolders', () => HttpResponse.json(['Default', 'MySubFolder']) ), + http.get('/api/channels/UC_TEST/tv', () => + HttpResponse.json({ + layout: 'videos', + libraryFolder: '', + show: null, + tvFolders: ['Shows'], + defaultFolder: '', + defaultFolderLayout: 'videos', + hasDownloads: false, + canSwitch: true, + }) + ), + http.get('/api/library-folders', () => + HttpResponse.json({ + folders: [ + { name: '', layout: 'videos', isDefault: true, hasFiles: true, channels: 1 }, + { name: 'Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 0 }, + ], + }) + ), http.get('/api/channels/UC_TEST/filter-preview', () => HttpResponse.json({ videos: [], diff --git a/client/src/components/__tests__/ChannelPage.test.tsx b/client/src/components/__tests__/ChannelPage.test.tsx index 6b98ed018..07df697f0 100644 --- a/client/src/components/__tests__/ChannelPage.test.tsx +++ b/client/src/components/__tests__/ChannelPage.test.tsx @@ -51,6 +51,18 @@ jest.mock('axios', () => ({ isAxiosError: jest.fn(), })); +const mockRefetchChannelTv = jest.fn(() => Promise.resolve()); +const mockChannelTv: { current: { layout: 'videos' | 'tv' } | null } = { current: null }; +jest.mock('../ChannelPage/hooks/useChannelTv', () => ({ + useChannelTv: () => ({ + tv: mockChannelTv.current, + loading: false, + error: null, + refetch: mockRefetchChannelTv, + switchLayout: jest.fn(), + }), +})); + const axios = require('axios'); // Mock fetch @@ -94,6 +106,41 @@ describe('ChannelPage Component', () => { }); dialogPropsStore.current = null; channelVideosPropsStore.current = null; + mockChannelTv.current = null; + }); + + describe('TV chip', () => { + const page = () => ( + <BrowserRouter> + <ChannelPage token={mockToken} /> + </BrowserRouter> + ); + + test('shows the TV chip for a channel saved as a TV show', async () => { + mockChannelTv.current = { layout: 'tv' }; + render(page()); + + expect(await screen.findByTestId('tv-chip')).toBeInTheDocument(); + }); + + test('hides the TV chip for a Videos channel', async () => { + mockChannelTv.current = { layout: 'videos' }; + render(page()); + + await screen.findByText('Tech Channel'); + expect(screen.queryByTestId('tv-chip')).not.toBeInTheDocument(); + }); + + test('refreshes the TV state after the settings dialog saves', async () => { + render(page()); + await screen.findByText('Tech Channel'); + + act(() => { + dialogPropsStore.current.onSettingsSaved?.({ sub_folder: 'Anime', video_quality: null }); + }); + + expect(mockRefetchChannelTv).toHaveBeenCalled(); + }); }); describe('Auto-download toggles', () => { diff --git a/client/src/components/__tests__/Subscriptions.test.tsx b/client/src/components/__tests__/Subscriptions.test.tsx index 7f7faa936..af8fdf4c0 100644 --- a/client/src/components/__tests__/Subscriptions.test.tsx +++ b/client/src/components/__tests__/Subscriptions.test.tsx @@ -171,11 +171,16 @@ jest.mock('../../hooks/usePlaylistMutations', () => ({ usePlaylistMutations: jest.fn(), })); +jest.mock('../../hooks/useLibraryFolders', () => ({ + useLibraryFolders: jest.fn(), +})); + const { useChannelList } = require('../Subscriptions/hooks/useChannelList'); const { useChannelMutations } = require('../Subscriptions/hooks/useChannelMutations'); const { useConfig } = require('../../hooks/useConfig'); const { usePlaylistList } = require('../../hooks/usePlaylistList'); const { usePlaylistMutations } = require('../../hooks/usePlaylistMutations'); +const { useLibraryFolders } = require('../../hooks/useLibraryFolders'); describe('Subscriptions Component', () => { const mockToken = 'test-token'; @@ -255,6 +260,13 @@ describe('Subscriptions Component', () => { error: null, pending: false, }); + + useLibraryFolders.mockReturnValue({ + folders: [], + loading: false, + error: null, + layoutOf: () => 'videos', + }); }); describe('Component Rendering', () => { @@ -1410,6 +1422,110 @@ describe('Subscriptions Component', () => { }); }); + describe('TV Filtering', () => { + const tvFolder = { name: 'Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 1 }; + + const mockTvFolders = () => { + useLibraryFolders.mockReturnValue({ + folders: [tvFolder], + loading: false, + error: null, + layoutOf: (name: string) => (name === 'Shows' ? 'tv' : 'videos'), + }); + }; + + const mockPagedChannels = () => { + useChannelList.mockReturnValue({ + channels: mockChannels, + total: 50, + totalPages: 3, + loading: false, + error: null, + refetch: mockRefetchChannels, + subFolders: ['music'], + }); + }; + + test('does not offer the TV filter without a TV folder', async () => { + const user = userEvent.setup(); + renderSubscriptions(); + + await user.click(screen.getByRole('button', { name: /filter or group by folder/i })); + + await screen.findByRole('menuitem', { name: /all folders/i }); + expect(screen.queryByRole('menuitem', { name: /tv shows only/i })).not.toBeInTheDocument(); + }); + + test('lists only TV channels when "TV shows only" is selected', async () => { + const user = userEvent.setup(); + mockTvFolders(); + renderSubscriptions(); + + await user.click(screen.getByRole('button', { name: /filter or group by folder/i })); + await user.click(await screen.findByRole('menuitem', { name: /tv shows only/i })); + + await waitFor(() => { + expect(useChannelList).toHaveBeenLastCalledWith( + expect.objectContaining({ layout: 'tv', subFolder: undefined }) + ); + }); + }); + + test('resets to the first page when the TV filter is selected', async () => { + const user = userEvent.setup(); + mockTvFolders(); + mockPagedChannels(); + renderSubscriptions(); + + const [nextButton] = screen.getAllByRole('button', { name: /go to page 2/i }); + await user.click(nextButton); + await waitFor(() => { + expect(useChannelList).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })); + }); + + await user.click(screen.getByRole('button', { name: /filter or group by folder/i })); + await user.click(await screen.findByRole('menuitem', { name: /tv shows only/i })); + + await waitFor(() => { + expect(useChannelList).toHaveBeenLastCalledWith( + expect.objectContaining({ layout: 'tv', page: 1 }) + ); + }); + }); + + test('selecting a folder turns the TV filter off', async () => { + const user = userEvent.setup(); + mockTvFolders(); + mockPagedChannels(); + renderSubscriptions(); + + await user.click(screen.getByRole('button', { name: /filter or group by folder/i })); + await user.click(await screen.findByRole('menuitem', { name: /tv shows only/i })); + await user.click(screen.getByRole('button', { name: /filtering by tv shows/i })); + await user.click(screen.getByText('__music/')); + + await waitFor(() => { + expect(useChannelList).toHaveBeenLastCalledWith( + expect.objectContaining({ layout: undefined, subFolder: 'music' }) + ); + }); + }); + + test('offers the TV filter in the mobile actions menu', async () => { + const user = userEvent.setup(); + (useMediaQuery as jest.Mock).mockReturnValue(true); + mockTvFolders(); + renderSubscriptions(); + + await user.click(screen.getByRole('button', { name: /actions/i })); + await user.click(await screen.findByRole('menuitem', { name: /tv shows only/i })); + + await waitFor(() => { + expect(useChannelList).toHaveBeenLastCalledWith(expect.objectContaining({ layout: 'tv' })); + }); + }); + }); + describe('Pagination', () => { test('displays pagination when multiple pages exist', () => { useChannelList.mockReturnValue({ diff --git a/client/src/components/shared/AudioFormatSelect.tsx b/client/src/components/shared/AudioFormatSelect.tsx index a8edb7e01..b4d376709 100644 --- a/client/src/components/shared/AudioFormatSelect.tsx +++ b/client/src/components/shared/AudioFormatSelect.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { OptionSelect } from './OptionSelect'; import { AUDIO_FORMAT_OPTIONS } from '../../utils/downloadOptions'; +export const VIDEO_ONLY_HELPER_TEXT = 'TV folders are video-only.'; + export interface AudioFormatSelectProps { value: string | null; onChange: (value: string | null) => void; @@ -11,15 +13,31 @@ export interface AudioFormatSelectProps { disabled?: boolean; className?: string; labelId?: string; + /** The destination is a TV folder: MP3 choices are hidden, except one already selected */ + videoOnly?: boolean; } /** Download-type dropdown (video only / video + MP3 / MP3 only). */ export function AudioFormatSelect({ label = 'Download Type', emptyLabel = 'Video Only (default)', + videoOnly = false, + helperText, ...rest }: AudioFormatSelectProps) { - return <OptionSelect options={AUDIO_FORMAT_OPTIONS} label={label} emptyLabel={emptyLabel} {...rest} />; + // A selected MP3 choice stays listed so the select can show it while the user changes it. + const options = videoOnly + ? AUDIO_FORMAT_OPTIONS.filter((option) => option.value === rest.value) + : AUDIO_FORMAT_OPTIONS; + return ( + <OptionSelect + options={options} + label={label} + emptyLabel={emptyLabel} + helperText={helperText ?? (videoOnly ? VIDEO_ONLY_HELPER_TEXT : undefined)} + {...rest} + /> + ); } export default AudioFormatSelect; diff --git a/client/src/components/shared/EpisodeChip.tsx b/client/src/components/shared/EpisodeChip.tsx new file mode 100644 index 000000000..eaa651857 --- /dev/null +++ b/client/src/components/shared/EpisodeChip.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Chip, Tooltip } from '../ui'; +import { Tv as TvIcon } from '../../lib/icons'; +import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from './chipStyles'; +import type { EpisodeInfo } from '../../types/tvShows'; + +interface EpisodeChipProps { + episode: EpisodeInfo | null | undefined; + compact?: boolean; +} + +/** SxxEyy of a video saved as a TV episode; nothing for other videos. */ +function EpisodeChip({ episode, compact = false }: EpisodeChipProps) { + if (!episode) return null; + const description = episode.showName ? `${episode.showName}, ${episode.code}` : episode.code; + const style = compact + ? { ...SHARED_STATUS_CHIP_SMALL_STYLE, ...SHARED_COMPACT_CHIP_OVERRIDES } + : SHARED_STATUS_CHIP_SMALL_STYLE; + return ( + <Tooltip title={`TV episode: ${description}`}> + <Chip + size="small" + icon={<TvIcon size={14} />} + label={episode.code} + variant="outlined" + style={style} + aria-label={`TV episode ${description}`} + data-testid="episode-chip" + /> + </Tooltip> + ); +} + +export default EpisodeChip; diff --git a/client/src/components/shared/SubfolderAutocomplete.tsx b/client/src/components/shared/SubfolderAutocomplete.tsx index 4fecfe6d5..45801cc28 100644 --- a/client/src/components/shared/SubfolderAutocomplete.tsx +++ b/client/src/components/shared/SubfolderAutocomplete.tsx @@ -15,6 +15,7 @@ import { } from '../../utils/channelHelpers'; import { AddSubfolderDialog } from './AddSubfolderDialog'; import { addSubfolderPrefix, stripSubfolderPrefix } from '../../utils/subfolderDisplay'; +import type { LayoutResolver } from '../../utils/libraryLayouts'; /** * Represents an option in the subfolder autocomplete @@ -52,6 +53,8 @@ interface SubfolderAutocompleteProps { createSubfolder?: (name: string) => Promise<void>; /** Whether to render the inline "Add Subfolder" action (default true). Set false when the parent provides its own add flow */ showAddAction?: boolean; + /** Folder layouts ('' = main folder): TV folders are labelled "(TV)" */ + layoutOf?: LayoutResolver; } const ADD_NEW_SENTINEL = '__ADD_NEW__'; @@ -75,6 +78,7 @@ export function SubfolderAutocomplete({ label = 'Subfolder', createSubfolder, showAddAction = true, + layoutOf, }: SubfolderAutocompleteProps) { // State for the Add Subfolder dialog const [addDialogOpen, setAddDialogOpen] = useState(false); @@ -104,12 +108,13 @@ export function SubfolderAutocomplete({ // Build options based on mode const options = useMemo((): SubfolderOption[] => { const opts: SubfolderOption[] = []; + const tvTag = (libraryFolder: string) => (layoutOf && layoutOf(libraryFolder) === 'tv' ? ' (TV)' : ''); // Add special options based on mode if (mode === 'global') { // "No Subfolder" option - maps to null (root directory) opts.push({ - label: 'No Subfolder (root)', + label: `No Subfolder (root)${tvTag('')}`, value: null, isSpecial: true, isAddNew: false, @@ -118,7 +123,7 @@ export function SubfolderAutocomplete({ } else if (mode === 'channel') { // "No Subfolder" option - maps to null (backwards compatible root) opts.push({ - label: 'No Subfolder (root)', + label: `No Subfolder (root)${tvTag('')}`, value: null, isSpecial: true, isAddNew: false, @@ -126,9 +131,9 @@ export function SubfolderAutocomplete({ }); // "Default Subfolder" option - maps to ##USE_GLOBAL_DEFAULT## - const defaultLabel = defaultSubfolderDisplay + const defaultLabel = (defaultSubfolderDisplay ? `Default Subfolder (__${defaultSubfolderDisplay})` - : 'Default Subfolder (root)'; + : 'Default Subfolder (root)') + tvTag(defaultSubfolderDisplay || ''); opts.push({ label: defaultLabel, value: GLOBAL_DEFAULT_SENTINEL, @@ -148,7 +153,7 @@ export function SubfolderAutocomplete({ // "Root directory" option - explicitly download to root (no subfolder) opts.push({ - label: 'Root directory (no subfolder)', + label: `Root directory (no subfolder)${tvTag('')}`, value: ROOT_SENTINEL, isSpecial: true, isAddNew: false, @@ -157,7 +162,7 @@ export function SubfolderAutocomplete({ // "Use Global Default" option - uses global default subfolder opts.push({ - label: 'Use Global Default Subfolder', + label: `Use Global Default Subfolder${tvTag(defaultSubfolderDisplay || '')}`, value: GLOBAL_DEFAULT_SENTINEL, isSpecial: true, isAddNew: false, @@ -168,7 +173,7 @@ export function SubfolderAutocomplete({ // Add existing subfolders (strip __ prefix from display, store clean value) allSubfolders.forEach((folder) => { const cleanValue = stripSubfolderPrefix(folder); - const displayLabel = addSubfolderPrefix(folder); + const displayLabel = addSubfolderPrefix(folder) + tvTag(cleanValue); opts.push({ label: displayLabel, value: cleanValue, @@ -182,7 +187,7 @@ export function SubfolderAutocomplete({ // This makes it reliably clickable in tests without depending on Radix Select portal events. return opts; - }, [mode, allSubfolders, defaultSubfolderDisplay]); + }, [mode, allSubfolders, defaultSubfolderDisplay, layoutOf]); // Find the current option based on value const currentOption = useMemo((): SubfolderOption | null => { diff --git a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx index 759aa1c26..ffcbf6347 100644 --- a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx +++ b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx @@ -134,6 +134,10 @@ function VideoTechnical({ video, metadata, loading }: VideoTechnicalProps) { metaTechDetails.push({ label: 'Downloaded', value: downloadRes }); } } + if (metadata?.episode) { + const { code, showName } = metadata.episode; + metaTechDetails.push({ label: 'Episode', value: showName ? `${code} (${showName})` : code }); + } if (metadata?.fps != null) { metaTechDetails.push({ label: 'FPS', value: `${metadata.fps}` }); } diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx index 8ed2c48aa..91a0e96de 100644 --- a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx @@ -143,6 +143,28 @@ describe('VideoTechnical', () => { expect(screen.queryByText('Aspect Ratio')).not.toBeInTheDocument(); }); + test('shows the episode code and show name for a TV episode', () => { + renderTech({}, { + ...baseMetadata, + episode: { showName: 'Mark Rober', season: 2024, episode: 3151200, code: 'S2024E03151200' }, + }); + expect(screen.getByText('Episode')).toBeInTheDocument(); + expect(screen.getByText('S2024E03151200 (Mark Rober)')).toBeInTheDocument(); + }); + + test('shows only the episode code when the show name is unknown', () => { + renderTech({}, { + ...baseMetadata, + episode: { showName: null, season: 1, episode: 20, code: 'S01E20' }, + }); + expect(screen.getByText('S01E20')).toBeInTheDocument(); + }); + + test('omits the Episode row for a video that is not a TV episode', () => { + renderTech({}, { ...baseMetadata, episode: null }); + expect(screen.queryByText('Episode')).not.toBeInTheDocument(); + }); + test('hides metadata-derived rows while loading', () => { renderTech( {}, diff --git a/client/src/components/shared/VideoModal/types.ts b/client/src/components/shared/VideoModal/types.ts index 45c663adb..6cbed0ece 100644 --- a/client/src/components/shared/VideoModal/types.ts +++ b/client/src/components/shared/VideoModal/types.ts @@ -1,4 +1,5 @@ import { VideoStatus } from '../../../utils/videoStatus'; +import type { EpisodeInfo } from '../../../types/tvShows'; export interface VideoModalData { youtubeId: string; @@ -63,6 +64,8 @@ export interface VideoExtendedMetadata { webpageUrl: string | null; relatedFiles: VideoRelatedFile[] | null; availableResolutions: number[] | null; + // Show and SxxEyy when the downloaded file is a TV episode (absent when metadata could not be read) + episode?: EpisodeInfo | null; } export interface VideoRelatedFile { diff --git a/client/src/components/shared/__tests__/AudioFormatSelect.test.tsx b/client/src/components/shared/__tests__/AudioFormatSelect.test.tsx index 8a2cfcff8..b620c4a97 100644 --- a/client/src/components/shared/__tests__/AudioFormatSelect.test.tsx +++ b/client/src/components/shared/__tests__/AudioFormatSelect.test.tsx @@ -47,4 +47,25 @@ describe('AudioFormatSelect', () => { screen.getByText('MP3 files are saved at 192kbps in the same folder as videos.') ).toBeInTheDocument(); }); + + describe('videoOnly', () => { + test('hides the MP3 choices', async () => { + render(<AudioFormatSelect value={null} onChange={jest.fn()} videoOnly />); + fireEvent.mouseDown(screen.getByLabelText('Download Type')); + await screen.findByRole('option', { name: 'Video Only (default)' }); + expect(screen.queryByRole('option', { name: 'MP3 Only' })).not.toBeInTheDocument(); + }); + + test('keeps an MP3 choice already selected so it can be changed', async () => { + render(<AudioFormatSelect value="mp3_only" onChange={jest.fn()} videoOnly />); + fireEvent.mouseDown(screen.getByLabelText('Download Type')); + expect(await screen.findByRole('option', { name: 'MP3 Only' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Video + MP3' })).not.toBeInTheDocument(); + }); + + test('explains why by default', () => { + render(<AudioFormatSelect value={null} onChange={jest.fn()} videoOnly />); + expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/shared/__tests__/EpisodeChip.test.tsx b/client/src/components/shared/__tests__/EpisodeChip.test.tsx new file mode 100644 index 000000000..869c972e9 --- /dev/null +++ b/client/src/components/shared/__tests__/EpisodeChip.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import EpisodeChip from '../EpisodeChip'; + +const EPISODE = { showName: 'Mark Rober', season: 2024, episode: 3151200, code: 'S2024E03151200' }; + +describe('EpisodeChip', () => { + test('shows the episode code', () => { + render(<EpisodeChip episode={EPISODE} />); + expect(screen.getByText('S2024E03151200')).toBeInTheDocument(); + }); + + test('names the show for assistive technology', () => { + render(<EpisodeChip episode={EPISODE} />); + expect(screen.getByLabelText('TV episode Mark Rober, S2024E03151200')).toBeInTheDocument(); + }); + + test('renders nothing for a video that is not an episode', () => { + render(<EpisodeChip episode={null} />); + expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx b/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx index f68b43697..9d13ca668 100644 --- a/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx +++ b/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx @@ -512,4 +512,52 @@ describe('SubfolderAutocomplete', () => { expect(mockOnChange).toHaveBeenCalledWith('Music'); }); }); + + describe('folder layouts', () => { + const layoutOf = (folder: string) => (folder === 'Sports' || folder === '' ? 'tv' : 'videos') as 'tv' | 'videos'; + + test('labels TV subfolders', async () => { + const user = userEvent.setup(); + render(<SubfolderAutocomplete mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} layoutOf={layoutOf} />); + + await user.click(screen.getByRole('combobox')); + + const listbox = screen.getByRole('listbox'); + expect(within(listbox).getByText('__Sports (TV)')).toBeInTheDocument(); + expect(within(listbox).getByText('__Music')).toBeInTheDocument(); + }); + + test('labels the default subfolder by its layout', async () => { + const user = userEvent.setup(); + render( + <SubfolderAutocomplete + mode="channel" + value={GLOBAL_DEFAULT_SENTINEL} + onChange={mockOnChange} + subfolders={defaultSubfolders} + defaultSubfolderDisplay="Sports" + layoutOf={layoutOf} + /> + ); + + await user.click(screen.getByRole('combobox')); + + expect(within(screen.getByRole('listbox')).getByText('Default Subfolder (__Sports) (TV)')).toBeInTheDocument(); + }); + + test('labels the root option when the main folder is TV', () => { + render(<SubfolderAutocomplete mode="channel" value={null} onChange={mockOnChange} subfolders={[]} layoutOf={layoutOf} />); + expect(screen.getByRole('combobox')).toHaveValue('No Subfolder (root) (TV)'); + }); + + test('shows no layout labels without a resolver', async () => { + const user = userEvent.setup(); + render(<SubfolderAutocomplete mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} />); + + await user.click(screen.getByRole('combobox')); + + expect(within(screen.getByRole('listbox')).getByText('__Sports')).toBeInTheDocument(); + }); + }); }); + diff --git a/client/src/config/configSchema.ts b/client/src/config/configSchema.ts index 60c1b84b1..e56ae6315 100644 --- a/client/src/config/configSchema.ts +++ b/client/src/config/configSchema.ts @@ -38,6 +38,8 @@ export const CONFIG_FIELDS = { videoCodec: { default: 'default', trackChanges: true }, defaultSubfolder: { default: '', trackChanges: true }, defaultSkipVideoFolder: { default: false, trackChanges: true }, + // Changed through the library folders API, never through a Settings save. + mainFolderLayout: { default: 'videos', trackChanges: false }, videoFilenamePrefix: { default: '%(uploader,channel,uploader_id).80B - %(title).64B', trackChanges: true, @@ -219,6 +221,7 @@ export const DEFAULT_CONFIG: ConfigState = { videoCodec: CONFIG_FIELDS.videoCodec.default, defaultSubfolder: CONFIG_FIELDS.defaultSubfolder.default, defaultSkipVideoFolder: CONFIG_FIELDS.defaultSkipVideoFolder.default, + mainFolderLayout: CONFIG_FIELDS.mainFolderLayout.default, videoFilenamePrefix: CONFIG_FIELDS.videoFilenamePrefix.default, plexApiKey: CONFIG_FIELDS.plexApiKey.default, plexYoutubeLibraryId: CONFIG_FIELDS.plexYoutubeLibraryId.default, diff --git a/client/src/hooks/__tests__/useLibraryFolders.test.ts b/client/src/hooks/__tests__/useLibraryFolders.test.ts new file mode 100644 index 000000000..43c9d037c --- /dev/null +++ b/client/src/hooks/__tests__/useLibraryFolders.test.ts @@ -0,0 +1,80 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; + +jest.mock('axios', () => ({ + get: jest.fn(), + put: jest.fn(), + isAxiosError: (e: unknown) => Boolean(e && (e as { isAxiosError?: boolean }).isAxiosError), +})); + +const axios = require('axios'); + +import { useLibraryFolders, LIBRARY_FOLDERS_UPDATED_EVENT } from '../useLibraryFolders'; +import { SUBFOLDERS_UPDATED_EVENT } from '../useSubfolders'; + +const FOLDERS = [ + { name: '', layout: 'videos', isDefault: true, hasFiles: true, channels: 3 }, + { name: 'TV Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 1 }, +]; + +describe('useLibraryFolders', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: { folders: FOLDERS } }); + }); + + test('does not fetch without a token', () => { + renderHook(() => useLibraryFolders(null)); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('loads the folders with the auth header', async () => { + const { result } = renderHook(() => useLibraryFolders('token')); + await waitFor(() => expect(result.current.folders).toEqual(FOLDERS)); + expect(axios.get).toHaveBeenCalledWith('/api/library-folders', { headers: { 'x-access-token': 'token' } }); + }); + + test('resolves layouts from the loaded folders', async () => { + const { result } = renderHook(() => useLibraryFolders('token')); + await waitFor(() => expect(result.current.layoutOf('tv shows')).toBe('tv')); + }); + + test('reports a load failure with the server message', async () => { + axios.get.mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'boom' } } }); + const { result } = renderHook(() => useLibraryFolders('token')); + await waitFor(() => expect(result.current.error).toBe('boom')); + }); + + test('changes a layout and announces it', async () => { + const updated = [FOLDERS[0], { ...FOLDERS[1], layout: 'videos' }]; + axios.put.mockResolvedValueOnce({ data: { changed: true, folders: updated } }); + const listener = jest.fn(); + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, listener); + const { result } = renderHook(() => useLibraryFolders('token')); + await waitFor(() => expect(result.current.folders).toEqual(FOLDERS)); + + await act(async () => { await result.current.setFolderLayout('TV Shows', 'videos'); }); + + expect(axios.put).toHaveBeenCalledWith( + '/api/library-folders', { name: 'TV Shows', layout: 'videos' }, { headers: { 'x-access-token': 'token' } } + ); + expect(listener).toHaveBeenCalled(); + window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, listener); + }); + + test('throws the refusal message when a change is refused', async () => { + axios.put.mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { error: 'holds downloads' } } }); + const { result } = renderHook(() => useLibraryFolders('token')); + await waitFor(() => expect(result.current.folders).toEqual(FOLDERS)); + + await expect(result.current.setFolderLayout('', 'tv')).rejects.toThrow('holds downloads'); + }); + + test('refetches when subfolders change', async () => { + renderHook(() => useLibraryFolders('token')); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + + act(() => { window.dispatchEvent(new Event(SUBFOLDERS_UPDATED_EVENT)); }); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/client/src/hooks/useLibraryFolders.ts b/client/src/hooks/useLibraryFolders.ts new file mode 100644 index 000000000..3dc39061e --- /dev/null +++ b/client/src/hooks/useLibraryFolders.ts @@ -0,0 +1,89 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import axios from 'axios'; +import { LibraryFolder, LibraryFoldersResponse, LibraryLayout } from '../types/tvShows'; +import { buildLayoutResolver, LayoutResolver } from '../utils/libraryLayouts'; +import { SUBFOLDERS_UPDATED_EVENT } from './useSubfolders'; + +export const LIBRARY_FOLDERS_UPDATED_EVENT = 'library-folders-updated'; + +interface LibraryFolderChangeResponse extends LibraryFoldersResponse { + changed: boolean; +} + +export interface UseLibraryFoldersResult { + folders: LibraryFolder[]; + loading: boolean; + error: string | null; + /** Layout of a library folder ('' = main folder); videos until loaded */ + layoutOf: LayoutResolver; + refetch: () => Promise<void>; + /** Change a folder's layout; throws with the server's refusal message */ + setFolderLayout: (name: string, layout: LibraryLayout) => Promise<void>; +} + +function errorMessage(err: unknown, fallback: string): string { + if (axios.isAxiosError(err)) { + const data = err.response?.data as { error?: string } | undefined; + if (data?.error) return data.error; + } + return fallback; +} + +/** Library folders (main folder and subfolders) with their layouts. */ +export function useLibraryFolders(token: string | null): UseLibraryFoldersResult { + const [folders, setFolders] = useState<LibraryFolder[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const fetchFolders = useCallback(async () => { + if (!token) return; + setLoading(true); + setError(null); + try { + const response = await axios.get<LibraryFoldersResponse>('/api/library-folders', { + headers: { 'x-access-token': token }, + }); + setFolders(Array.isArray(response.data?.folders) ? response.data.folders : []); + } catch (err) { + setError(errorMessage(err, 'Failed to load library folders')); + } finally { + setLoading(false); + } + }, [token]); + + const setFolderLayout = useCallback(async (name: string, layout: LibraryLayout) => { + if (!token) return; + let response; + try { + response = await axios.put<LibraryFolderChangeResponse>( + '/api/library-folders', + { name, layout }, + { headers: { 'x-access-token': token } } + ); + } catch (err) { + throw new Error(errorMessage(err, 'Failed to change the folder layout')); + } + if (Array.isArray(response.data?.folders)) setFolders(response.data.folders); + window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); + }, [token]); + + useEffect(() => { + fetchFolders(); + }, [fetchFolders]); + + useEffect(() => { + const handler = () => { fetchFolders(); }; + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + window.addEventListener(SUBFOLDERS_UPDATED_EVENT, handler); + return () => { + window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + window.removeEventListener(SUBFOLDERS_UPDATED_EVENT, handler); + }; + }, [fetchFolders]); + + const layoutOf = useMemo(() => buildLayoutResolver(folders), [folders]); + + return { folders, loading, error, layoutOf, refetch: fetchFolders, setFolderLayout }; +} + +export default useLibraryFolders; diff --git a/client/src/lib/icons.ts b/client/src/lib/icons.ts index aba45917e..d9856d996 100644 --- a/client/src/lib/icons.ts +++ b/client/src/lib/icons.ts @@ -120,6 +120,7 @@ export { Moon, Sun, Monitor, + Tv, Smartphone, Tablet, Code, diff --git a/client/src/types/Channel.ts b/client/src/types/Channel.ts index 3f3481d5d..7d4b4143a 100644 --- a/client/src/types/Channel.ts +++ b/client/src/types/Channel.ts @@ -1,3 +1,4 @@ +import type { LibraryLayout } from './tvShows'; export type ChannelTabType = 'videos' | 'shorts' | 'streams'; // Public-video download stats for one channel tab. total is YouTube's count @@ -37,4 +38,6 @@ export interface Channel { auto_removal_keep_recent_count?: number | null; // Only on /getchannels entries. tab_download_stats?: TabDownloadStatsByTab; + // Layout of the folder the channel downloads to. Only on /getchannels entries. + layout?: LibraryLayout; } diff --git a/client/src/types/ChannelVideo.ts b/client/src/types/ChannelVideo.ts index ac3358d2c..5de17d817 100644 --- a/client/src/types/ChannelVideo.ts +++ b/client/src/types/ChannelVideo.ts @@ -1,3 +1,4 @@ +import type { EpisodeInfo } from './tvShows'; /*{ "title": "Five Nights at Freddy’s SCARY Truth…", "id": "WEywwDLeZE0", @@ -40,4 +41,6 @@ export interface ChannelVideo { // Actual downloaded pixel dimensions, e.g. "1920x1080"; "0x0" = probe failed video_resolution?: string | null; watchedBy?: string[]; + // Show and SxxEyy when the downloaded file is a TV episode + episode?: EpisodeInfo | null; } diff --git a/client/src/types/VideoData.ts b/client/src/types/VideoData.ts index 3bac3f618..bd0443888 100644 --- a/client/src/types/VideoData.ts +++ b/client/src/types/VideoData.ts @@ -1,3 +1,4 @@ +import type { EpisodeInfo } from './tvShows'; /*export interface VideoData { youtubeId: string; youTubeChannelName: string; @@ -28,6 +29,8 @@ export interface VideoData { // Actual downloaded pixel dimensions, e.g. "1920x1080"; "0x0" = probe failed video_resolution?: string | null; watchedBy?: string[]; + // Show and SxxEyy when the downloaded file is a TV episode + episode?: EpisodeInfo | null; } export interface EnabledChannel { diff --git a/client/src/types/tvShows.ts b/client/src/types/tvShows.ts new file mode 100644 index 000000000..1f5a3f886 --- /dev/null +++ b/client/src/types/tvShows.ts @@ -0,0 +1,51 @@ +/** Layout of a library folder: movie-style videos or TV shows. */ +export type LibraryLayout = 'videos' | 'tv'; + +/** A library folder from GET /api/library-folders. */ +export interface LibraryFolder { + /** '' for the main downloads folder, else the subfolder name without __ */ + name: string; + layout: LibraryLayout; + /** The default subfolder (the main folder when no default subfolder is set) */ + isDefault: boolean; + /** Holds downloaded files, so its layout can't change yet */ + hasFiles: boolean; + /** Enabled channels that download to this folder */ + channels: number; +} + +export interface LibraryFoldersResponse { + folders: LibraryFolder[]; +} + +/** A channel's show, as stored on the server. */ +export interface ChannelTvShow { + name: string; + folderName: string; + libraryFolder: string; + path: string; +} + +/** GET /api/channels/:channelId/tv */ +export interface ChannelTvState { + layout: LibraryLayout; + /** The library folder the channel downloads to ('' = main folder) */ + libraryFolder: string; + /** The channel's show; null for a Videos channel or a TV channel before its first episode */ + show: ChannelTvShow | null; + /** Library folders with the TV layout ('' = main folder) */ + tvFolders: string[]; + defaultFolder: string; + defaultFolderLayout: LibraryLayout; + hasDownloads: boolean; + canSwitch: boolean; +} + +/** Episode details on a video in listing and detail responses. */ +export interface EpisodeInfo { + showName: string | null; + season: number; + episode: number; + /** e.g. "S2024E03151200" or "S01E20" */ + code: string; +} diff --git a/client/src/utils/__tests__/libraryLayouts.test.ts b/client/src/utils/__tests__/libraryLayouts.test.ts new file mode 100644 index 000000000..f51589fc5 --- /dev/null +++ b/client/src/utils/__tests__/libraryLayouts.test.ts @@ -0,0 +1,49 @@ +import { + effectiveLibraryFolder, + buildLayoutResolver, + libraryFolderLabel, + VIDEOS_EVERYWHERE, +} from '../libraryLayouts'; + +describe('libraryLayouts utils', () => { + describe('effectiveLibraryFolder', () => { + test('follows the default subfolder for the global default sentinel', () => { + expect(effectiveLibraryFolder('##USE_GLOBAL_DEFAULT##', 'Kids')).toBe('Kids'); + }); + + test('maps the root sentinel to the main folder', () => { + expect(effectiveLibraryFolder('##ROOT##', 'Kids')).toBe(''); + }); + + test('maps an empty value to the main folder', () => { + expect(effectiveLibraryFolder(null, 'Kids')).toBe(''); + }); + + test('returns a named subfolder trimmed', () => { + expect(effectiveLibraryFolder(' TV ', null)).toBe('TV'); + }); + }); + + describe('buildLayoutResolver', () => { + const layoutOf = buildLayoutResolver([ + { name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 0 }, + { name: 'TV Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 2 }, + ]); + + test('resolves folders ignoring case', () => { + expect(layoutOf('tv shows')).toBe('tv'); + }); + + test('treats unknown folders as videos', () => { + expect(layoutOf('Other')).toBe('videos'); + }); + }); + + test('labels the main folder and subfolders', () => { + expect([libraryFolderLabel(''), libraryFolderLabel('TV')]).toEqual(['Main folder', '__TV']); + }); + + test('resolves everything to videos before folders load', () => { + expect(VIDEOS_EVERYWHERE('TV')).toBe('videos'); + }); +}); diff --git a/client/src/utils/libraryLayouts.ts b/client/src/utils/libraryLayouts.ts new file mode 100644 index 000000000..e759e1eeb --- /dev/null +++ b/client/src/utils/libraryLayouts.ts @@ -0,0 +1,32 @@ +import { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } from './channelHelpers'; +import { LibraryFolder, LibraryLayout } from '../types/tvShows'; + +export type LayoutResolver = (libraryFolder: string) => LibraryLayout; + +/** Every folder is a Videos folder until the folder list has loaded. */ +export const VIDEOS_EVERYWHERE: LayoutResolver = () => 'videos'; + +/** + * Library folder ('' = main folder) a sub_folder value saves into. Mirrors + * the server's resolveEffectiveSubfolder: the global default sentinel follows + * the default subfolder, root and empty values mean the main folder. + */ +export function effectiveLibraryFolder( + subFolderValue: string | null | undefined, + defaultSubfolder: string | null | undefined +): string { + if (subFolderValue === ROOT_SENTINEL) return ''; + if (subFolderValue === GLOBAL_DEFAULT_SENTINEL) return (defaultSubfolder || '').trim(); + return (subFolderValue || '').trim(); +} + +/** Resolver over the folder list, comparing names ignoring case like the server. */ +export function buildLayoutResolver(folders: LibraryFolder[]): LayoutResolver { + const layouts = new Map(folders.map((folder) => [folder.name.trim().toLowerCase(), folder.layout])); + return (libraryFolder) => layouts.get((libraryFolder || '').trim().toLowerCase()) || 'videos'; +} + +/** "Main folder" or "__Name". */ +export function libraryFolderLabel(name: string): string { + return name ? `__${name}` : 'Main folder'; +} diff --git a/config/config.example.json b/config/config.example.json index 9ed58b5df..d739751d3 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -13,6 +13,7 @@ "videoCodec": "default", "defaultSubfolder": "", "defaultSkipVideoFolder": false, + "mainFolderLayout": "videos", "videoFilenamePrefix": "%(uploader,channel,uploader_id).80B - %(title).64B", "plexIP": "", "plexPort": "32400", diff --git a/docs/CONFIG.md b/docs/CONFIG.md index cd37edd9c..adc4e2f2e 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -158,17 +158,28 @@ Youtarr must be running at the scheduled time; missed occurrences are not replay - **Type**: `boolean` - **Default**: `false` - **Description**: When `true`, new downloads are saved directly in the channel folder (flat structure) instead of an individual per-video subfolder, for every channel that has not chosen its own File Structure setting. +- **TV folders**: Ignored for folders with the TV layout (see Main Folder Layout), where episodes always sit directly in `Season NN` folders. - **Channel Override Semantics** (channel setting `skip_video_folder`, edited via the channel's "Video File Structure" select): - **"Use global setting"** (NULL in database): channel follows this global default - **"Flat (no video subfolders)"** (`true`): channel always uses flat structure - **"Video subfolders"** (`false`): channel always uses per-video subfolders, even when the global default is flat - **Note**: Only affects new downloads; existing files are not moved. The manual download dialog can override the structure for a single download ("Force flat" or "Force individual video subfolders"); its default option ("Use channel/global settings") follows the channel setting and this global default. +### Main Folder Layout +- **Config Key**: `mainFolderLayout` +- **Type**: `string` +- **Default**: `"videos"` +- **Options**: `"videos"`, `"tv"` +- **Description**: Layout of the main downloads folder, for files saved directly in it rather than in a `__subfolder`. `"videos"` saves each video movie-style, as before. `"tv"` saves channels as TV shows (`<show>/Season NN/SxxEyy - Title [id].ext`) for a TV-type media server library. Each `__subfolder` has its own layout, set in **Settings > Core > File Structure > Library folders**. +- **Note**: Changed only through the Library folders list, which refuses the change while the folder holds downloaded files or a download is running. A Settings save keeps the stored value. Switching the main folder to TV writes a `.plexignore` containing `__*/*` to the main folder, so a Plex TV library pointed there skips the subfolders. +- **TV folders are video-only**: TV library scanners skip audio files. A channel or playlist whose download type is MP3 cannot be saved to a TV folder, a folder with MP3 channels or playlists cannot switch to TV, and a download request that names a TV folder together with an MP3 type is refused. When the destination is only known per video (a pasted URL whose channel saves to a TV folder, an MP3 playlist with a video from such a channel), the MP3 type is downgraded to video for that video, so the episode is saved as video; an MP3 playlist's such videos therefore have no audio file and are left out of its music playlist sync (`unsyncable_count`). + ### Video Filename Template - **Config Key**: `videoFilenamePrefix` - **Type**: `string` - **Default**: `"%(uploader,channel,uploader_id).80B - %(title).64B"`. The title is capped at 64 bytes because the prefix appears twice in the full path (per-video folder + filename) and Plex on Windows silently skips files whose full path reaches 260 characters. Installs that saved settings under an older default keep their persisted value (`.74B`/`.76B`) until the setting is edited. - **Description**: User-customizable prefix for downloaded video filenames AND per-video directory names. Youtarr always appends ` [VIDEO_ID].EXT` to filenames and ` - VIDEO_ID` to per-video folder names so it can re-find your videos on disk; those suffixes are not configurable. +- **TV folders**: Not used for episodes in folders with the TV layout; they are always named `SxxEyy - <title> [VIDEO_ID].EXT` inside `<show>/Season NN/`. - **Syntax**: Uses [yt-dlp's output template syntax](https://github.com/yt-dlp/yt-dlp#output-template). Common tokens: `%(title)s`, `%(uploader)s`, `%(channel)s`, `%(upload_date>%Y-%m-%d)s`, `%(channel_id)s`, `%(display_id)s`. Use `.NB` to byte-truncate values (e.g. `%(title).64B`) or `.Ns` for character truncation (e.g. `%(title).40s`); recommended to keep paths under Windows' 260-char limit. - **Validation**: Empty values, path separators (`/`, `\`), `..`, ASCII control characters, values longer than 160 characters, malformed yt-dlp percent syntax, and invalid truncation like `%(title).40` are rejected. Escape literal percent signs as `%%`. Trailing whitespace is trimmed on save. - **Scope**: Global setting. Applies only to NEW downloads; existing files are not renamed. @@ -393,6 +404,7 @@ Sync is one-way (server -> Youtarr). Non-owner Plex users come from the server's - **Type**: `boolean` - **Default**: `true` - **Description**: Generate channel poster images for media servers +- **TV folders**: For a channel saved as a TV show, the poster goes in its show folder. - **Note**: Creates poster.jpg in each channel directory ### Write Video NFO Files @@ -400,6 +412,7 @@ Sync is one-way (server -> Youtarr). Non-owner Plex users come from the server's - **Type**: `boolean` - **Default**: `true` - **Description**: Generate NFO metadata files for Kodi/Jellyfin/Emby +- **TV folders**: Episodes in folders with the TV layout always get an episode NFO (`<episodedetails>`) and their show a `tvshow.nfo`, whatever this setting says: without them Jellyfin names episodes after the file name, and the Plex NFO agents need them. - **Note**: Creates .nfo XML files with video metadata ### Write Video Fanart @@ -414,6 +427,7 @@ Sync is one-way (server -> Youtarr). Non-owner Plex users come from the server's - **Type**: `boolean` - **Default**: `false` - **Description**: Generate backdrop image files for Emby and Jellyfin background art +- **TV folders**: For a channel saved as a TV show, the channel `backdrop.jpg` goes in its show folder. - **Note**: Creates `backdrop.jpg` in each channel directory (from the channel's YouTube banner) and a `-backdrop.jpg` file alongside each video (copy of the video thumbnail). When enabled, channel-level backdrops are backfilled for existing channel folders; video-level backdrops are created for new downloads only. ### Prefix Channel Name In Embedded Title @@ -421,6 +435,7 @@ Sync is one-way (server -> Youtarr). Non-owner Plex users come from the server's - **Type**: `boolean` - **Default**: `true` - **Description**: Write the MP4's embedded title tag as `Channel - Title` instead of just `Title` +- **TV folders**: Never applied to TV episodes, whose embedded title is always the episode title. - **Note**: Plex reads the embedded title tag (it does not read `.nfo` files). In an "Other Videos" library the prefix gives each video its channel context. In a TV Shows library the channel is already the show name, so turn this off to keep episode titles clean. The channel name is still written to the artist, album (Plex Collection), copyright (Plex Studio), and TV network tags, and the `.nfo` title is never prefixed. Only applies to new downloads; existing files are not re-tagged. ## Cookie Config diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 5c0d5026b..d4131562f 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -33,7 +33,9 @@ Youtarr uses MariaDB/MySQL for storing: | `playlists` | `Playlist` | Subscribed YouTube playlists with per-playlist sync targets and seeded settings. `auto_download_baseline_at` (DATETIME, nullable): saved starting time for automatic following. `auto_download_baseline_id` (INTEGER, nullable): last known playlist entry id for new setups, avoiding same-second timestamp ambiguity. Existing timestamp-only cutoffs are preserved on upgrade. `auto_download_setup_error` (STRING, nullable): persisted first-setup failure (`PLAYLIST_TOO_LARGE` or `PLAYLIST_REFRESH_INCOMPLETE`), cleared when a baseline is successfully established. `sort_order` (STRING NOT NULL, default `'default'`): saved output order for the `.m3u` file and media server sync; `'reversed'` flips the YouTube playlist order. `title_filter_regex` (TEXT, nullable): matched against video titles on refresh as a case-insensitive JavaScript regex; the settings endpoint rejects a pattern that does not compile. It also seeds auto-created source channels, whose title filter runs through yt-dlp's Python matching (case sensitive). | | `playlistvideos` | `PlaylistVideo` | One row per (playlist, video) with the YouTube playlist position. `first_seen_at`: immutable local discovery time, backfilled from row creation on upgrade. `downloaded_at`: recorded download/job time, independently updated. `auto_download_requested`: explicitly selected existing videos pending successful download, saved only with auto-download enabled and an established baseline. `auto_download_last_attempt_at` (DATETIME(3), nullable): scheduling time for an explicit saved batch or a selected scheduled retry, written before queue submission. Used to rotate older saved requests, independently of discovery and download dates; legacy requests with no known attempt remain null and join the bounded retry pool. First initialization preserves pending requests; successful downloads clear their own flags, and an explicit starting-point reset clears the remaining requests. Legacy `added_at` is retained for pre-upgrade timestamp cutoffs; it is no longer overwritten on download. | | `playlist_sync_state` | `PlaylistSyncState` | Per-(playlist, server) sync state: server playlist id, last_synced_at, last_error | -| `subfolders` | `Subfolder` | Durable registry of known subfolder names (id, name unique, created_at, updated_at). Backfilled from channels, playlists, and video file paths by the `add-subfolders-table` migration; kept current by register-on-create and register-on-download-override. | +| `subfolders` | `Subfolder` | Durable registry of known subfolder names (id, name unique, created_at, updated_at). Backfilled from channels, playlists, and video file paths by the `add-subfolders-table` migration; kept current by register-on-create and register-on-download-override. `layout` (STRING(10) NOT NULL, default `'videos'`): `'videos'` saves movie-style, `'tv'` saves channels as TV shows; the main folder's layout is the `mainFolderLayout` config field. | +| `tv_shows` | `TvShow` | TV shows Youtarr writes into TV-layout folders. `channel_id` (owner's YouTube channel id; not a foreign key, untracked channels get shows too), `kind` (`'channel'`: the whole channel as one show, created on the channel's switch to TV or its first download into a TV folder; `'title'`: reserved for title-pattern shows), `name`, `folder_name` and `library_folder` (`''` = main folder): the show's location, pinned at creation so uploader renames and default-folder changes never start a second show folder, `external_key` (written to `tvshow.nfo`; the channel id for channel shows), `previous_videos_folder` (the channel's `sub_folder` before it switched to TV, used when switching back), plus `position`, `exclude_terms`, `poster_source`, `retired_at` for title shows. Unique `(library_folder, folder_name)` (case-insensitive under `utf8mb4_unicode_ci`); a taken or invalid folder name gets the channel id appended. | +| `video_classifications` | `VideoClassification` | One row per video that belongs to a show (`youtube_id` primary key): `channel_id`, `show_id` (foreign key `video_classifications_show_id_fk` to `tv_shows`, RESTRICT), `status` (`assigned`; `pending_number`/`duplicate`/`unsupported`/`error` are reserved for title shows), `season`/`episode` (channel shows: UTC upload year and MMDDHHMM, bumped to the next free number on a collision), `source` (`date` for channel shows), `timestamp_source` (`timestamp` or `upload_date`), `pattern_id`, `episode_title`, `file_stem` (the on-disk name, reused on re-download), `title_opt_out`. Unique `(show_id, season, episode)`; rows without a number hold NULLs and never collide. Rows are kept when a video's files are deleted, so a re-download returns to the same number. | | `video_watch_status` | `VideoWatchStatus` | Per-video, per-media-server, per-user watch state pulled by the watch status sync. Absence of a row means never synced/unknown, not unwatched. Columns: `video_id`, `server_type` (`plex`/`jellyfin`/`emby`), `server_user_id` (Plex owner is `'1'`), `played`, `play_count`, `position_ms`, `percent_watched`, `last_watched_at`, `last_synced_at`. Unique index on `(video_id, server_type, server_user_id)`. | | `media_server_users` | `MediaServerUser` | Media-server account directory populated during watch status sync: `server_type`, `server_user_id`, `server_user_name`. Unique index on `(server_type, server_user_id)`. Used to display which users watched a video. | | `watch_status_sync_cursors` | `WatchStatusSyncCursor` | Durable per-server watch-status sync cursor (unique `server_type`, `cursor` DATETIME). Today only Plex uses it: the newest play-history event scanned, so incremental pulls never permanently skip events. Deleting a row forces a full history re-scan on the next sync. | diff --git a/migrations/20261002221349-add-tv-shows.js b/migrations/20261002221349-add-tv-shows.js new file mode 100644 index 000000000..418210a8a --- /dev/null +++ b/migrations/20261002221349-add-tv-shows.js @@ -0,0 +1,118 @@ +'use strict'; + +const { + addColumnIfMissing, + removeColumnIfExists, + createTableIfNotExists, + dropTableIfExists, + addIndexIfMissing, +} = require('./helpers'); + +const TABLE_OPTIONS = { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' }; +// Named explicitly: MariaDB 12.1+ names an unnamed constraint `1`. +const SHOW_FK_NAME = 'video_classifications_show_id_fk'; + +async function hasForeignKey(queryInterface, tableName) { + const [rows] = await queryInterface.sequelize.query( + `SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = :tableName AND CONSTRAINT_TYPE = 'FOREIGN KEY'`, + { replacements: { tableName } } + ); + return rows.length > 0; +} + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Layout of each __subfolder: 'videos' (movie-style) or 'tv'. The main + // folder's layout lives in config (mainFolderLayout). + await addColumnIfMissing(queryInterface, 'subfolders', 'layout', { + type: Sequelize.STRING(10), + allowNull: false, + defaultValue: 'videos', + }); + + await createTableIfNotExists(queryInterface, 'tv_shows', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + // YouTube channel id of the owner. Not a foreign key: untracked channels + // get channel shows too. + channel_id: { type: Sequelize.STRING(64), allowNull: false }, + // channel | title + kind: { type: Sequelize.STRING(16), allowNull: false }, + name: { type: Sequelize.STRING(255), allowNull: false }, + // Pinned when the show is created; only a reorganize changes it. + folder_name: { type: Sequelize.STRING(255), allowNull: false }, + // '' = the main downloads folder, else the subfolder name without __. + library_folder: { type: Sequelize.STRING(100), allowNull: false, defaultValue: '' }, + position: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + // JSON array of title-show exclude terms. + exclude_terms: { type: Sequelize.TEXT, allowNull: true }, + poster_source: { type: Sequelize.STRING(255), allowNull: true }, + // Written to tvshow.nfo: the channel id for channel shows, a UUID for + // title shows. Never changes after creation. + external_key: { type: Sequelize.STRING(64), allowNull: false }, + // The channel's sub_folder value before it switched to TV. + previous_videos_folder: { type: Sequelize.STRING(255), allowNull: true }, + retired_at: { type: Sequelize.DATE, allowNull: true }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + + await addIndexIfMissing(queryInterface, 'tv_shows', ['library_folder', 'folder_name'], { + name: 'tv_shows_location_unique', + unique: true, + }); + await addIndexIfMissing(queryInterface, 'tv_shows', ['channel_id'], { + name: 'tv_shows_channel_id_idx', + }); + + await createTableIfNotExists(queryInterface, 'video_classifications', { + youtube_id: { type: Sequelize.STRING(20), primaryKey: true, allowNull: false }, + // Owner channel's YouTube id. + channel_id: { type: Sequelize.STRING(64), allowNull: false }, + show_id: { type: Sequelize.INTEGER, allowNull: false }, + // assigned | pending_number | duplicate | unsupported | error + status: { type: Sequelize.STRING(16), allowNull: false }, + season: { type: Sequelize.INTEGER, allowNull: true }, + episode: { type: Sequelize.INTEGER, allowNull: true }, + // date | title | order | manual | adopted + source: { type: Sequelize.STRING(16), allowNull: true }, + // timestamp | upload_date (date-numbered episodes only) + timestamp_source: { type: Sequelize.STRING(16), allowNull: true }, + // Title-show pattern that matched; no foreign key until that table exists. + pattern_id: { type: Sequelize.INTEGER, allowNull: true }, + episode_title: { type: Sequelize.STRING(512), allowNull: true }, + // On-disk file name without extension; changes only when the show or number changes. + file_stem: { type: Sequelize.STRING(255), allowNull: true }, + title_opt_out: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + + if (!(await hasForeignKey(queryInterface, 'video_classifications'))) { + await queryInterface.addConstraint('video_classifications', { + fields: ['show_id'], + type: 'foreign key', + name: SHOW_FK_NAME, + references: { table: 'tv_shows', field: 'id' }, + onUpdate: 'cascade', + onDelete: 'restrict', + }); + } + + // Unnumbered rows hold NULL season and episode, which never collide. + await addIndexIfMissing(queryInterface, 'video_classifications', ['show_id', 'season', 'episode'], { + name: 'video_classifications_episode_unique', + unique: true, + }); + await addIndexIfMissing(queryInterface, 'video_classifications', ['channel_id'], { + name: 'video_classifications_channel_id_idx', + }); + }, + + async down(queryInterface) { + await dropTableIfExists(queryInterface, 'video_classifications'); + await dropTableIfExists(queryInterface, 'tv_shows'); + await removeColumnIfExists(queryInterface, 'subfolders', 'layout'); + }, +}; diff --git a/server/__tests__/server.additional-routes.test.js b/server/__tests__/server.additional-routes.test.js index 027572b4c..6dd099133 100644 --- a/server/__tests__/server.additional-routes.test.js +++ b/server/__tests__/server.additional-routes.test.js @@ -265,6 +265,22 @@ const createServerModule = ({ register: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), })); + jest.doMock('../modules/tvShows/libraryFolders', () => ({ + listLibraryFolders: jest.fn().mockResolvedValue([]), + setFolderLayout: jest.fn(), + checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('../modules/tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), + listTvFolders: jest.fn().mockResolvedValue([]), + })); + jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); + jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/tvShows/channelLayout', () => ({ + getChannelTvState: jest.fn(), + resolveLayoutTarget: jest.fn(), + })); jest.doMock('../modules/videoDeletionModule', () => ({ deleteVideos: jest.fn().mockResolvedValue({ deleted: [], failed: [] }), deleteVideosByYoutubeIds: jest.fn().mockResolvedValue({ deleted: [], failed: [] }) diff --git a/server/__tests__/server.apikeys.test.js b/server/__tests__/server.apikeys.test.js index 3411402d3..6125a22ef 100644 --- a/server/__tests__/server.apikeys.test.js +++ b/server/__tests__/server.apikeys.test.js @@ -275,6 +275,22 @@ const createServerModule = ({ register: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), })); + jest.doMock('../modules/tvShows/libraryFolders', () => ({ + listLibraryFolders: jest.fn().mockResolvedValue([]), + setFolderLayout: jest.fn(), + checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('../modules/tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), + listTvFolders: jest.fn().mockResolvedValue([]), + })); + jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); + jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/tvShows/channelLayout', () => ({ + getChannelTvState: jest.fn(), + resolveLayoutTarget: jest.fn(), + })); jest.doMock('../modules/archiveModule', () => ({ getAutoRemovalDryRun: jest.fn().mockResolvedValue({ videos: [], totalSize: 0 }) })); diff --git a/server/__tests__/server.auth-sessions.test.js b/server/__tests__/server.auth-sessions.test.js index 738a17033..02da1c021 100644 --- a/server/__tests__/server.auth-sessions.test.js +++ b/server/__tests__/server.auth-sessions.test.js @@ -233,6 +233,22 @@ const createServerModule = ({ register: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), })); + jest.doMock('../modules/tvShows/libraryFolders', () => ({ + listLibraryFolders: jest.fn().mockResolvedValue([]), + setFolderLayout: jest.fn(), + checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('../modules/tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), + listTvFolders: jest.fn().mockResolvedValue([]), + })); + jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); + jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/tvShows/channelLayout', () => ({ + getChannelTvState: jest.fn(), + resolveLayoutTarget: jest.fn(), + })); jest.doMock('../modules/archiveModule', () => ({ getAutoRemovalDryRun: jest.fn().mockResolvedValue({ videos: [], totalSize: 0 }) })); diff --git a/server/__tests__/server.core.test.js b/server/__tests__/server.core.test.js index 07ff3be72..8915c1947 100644 --- a/server/__tests__/server.core.test.js +++ b/server/__tests__/server.core.test.js @@ -255,6 +255,22 @@ const createServerModule = ({ register: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), })); + jest.doMock('../modules/tvShows/libraryFolders', () => ({ + listLibraryFolders: jest.fn().mockResolvedValue([]), + setFolderLayout: jest.fn(), + checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('../modules/tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), + listTvFolders: jest.fn().mockResolvedValue([]), + })); + jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); + jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/tvShows/channelLayout', () => ({ + getChannelTvState: jest.fn(), + resolveLayoutTarget: jest.fn(), + })); jest.doMock('../modules/archiveModule', () => ({ getAutoRemovalDryRun: jest.fn().mockResolvedValue({ videos: [], totalSize: 0 }) })); diff --git a/server/__tests__/server.plex-routes.test.js b/server/__tests__/server.plex-routes.test.js index fab05a3dd..a5d0b4550 100644 --- a/server/__tests__/server.plex-routes.test.js +++ b/server/__tests__/server.plex-routes.test.js @@ -151,6 +151,22 @@ const setupServer = async ({ authEnabled = 'false', passwordHash = null } = {}) register: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), })); + jest.doMock('../modules/tvShows/libraryFolders', () => ({ + listLibraryFolders: jest.fn().mockResolvedValue([]), + setFolderLayout: jest.fn(), + checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('../modules/tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), + listTvFolders: jest.fn().mockResolvedValue([]), + })); + jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); + jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/tvShows/channelLayout', () => ({ + getChannelTvState: jest.fn(), + resolveLayoutTarget: jest.fn(), + })); jest.doMock('../modules/archiveModule', () => ({ getAutoRemovalDryRun: jest.fn().mockResolvedValue({ videos: [], totalSize: 0 }) })); diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 25455788e..19b9d4fe5 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -417,6 +417,22 @@ const createServerModule = ({ register: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), })); + jest.doMock('../modules/tvShows/libraryFolders', () => ({ + listLibraryFolders: jest.fn().mockResolvedValue([]), + setFolderLayout: jest.fn(), + checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined), + })); + jest.doMock('../modules/tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), + listTvFolders: jest.fn().mockResolvedValue([]), + })); + jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); + jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/tvShows/channelLayout', () => ({ + getChannelTvState: jest.fn(), + resolveLayoutTarget: jest.fn(), + })); jest.doMock('../modules/webSocketServer.js', () => jest.fn()); jest.doMock('node-cron', () => cronMock); jest.doMock('../modules/mediaServers/watchStatusScheduler', () => ({ scheduleTask: jest.fn(), subscribe: jest.fn() })); @@ -1388,7 +1404,8 @@ describe('server routes - channels', () => { expect(channelModuleMock.updateChannelsByDelta).toHaveBeenCalledWith({ enableUrls: ['https://youtube.com/@new'], disableUrls: ['https://youtube.com/@old'], - channelSettingsModule: expect.any(Object) + channelSettingsModule: expect.any(Object), + isDownloadRunning: expect.any(Function) }); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ status: 'success' }); @@ -1419,7 +1436,8 @@ describe('server routes - channels', () => { { url: 'https://youtube.com/@channel2', channel_id: 'UC456' } ], disableUrls: [], - channelSettingsModule: expect.any(Object) + channelSettingsModule: expect.any(Object), + isDownloadRunning: expect.any(Function) }); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ status: 'success' }); diff --git a/server/models/index.js b/server/models/index.js index 4710c5f9a..679d37fa1 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -14,6 +14,8 @@ const VideoWatchStatus = require('./videowatchstatus'); const MediaServerUser = require('./mediaserveruser'); const WatchStatusSyncCursor = require('./watchstatussynccursor'); const ScheduledTaskRun = require('./scheduledtaskrun'); +const TvShow = require('./tvshow'); +const VideoClassification = require('./videoclassification'); Job.hasMany(JobVideo, { foreignKey: 'job_id', as: 'jobVideos' }); Job.hasMany(JobVideoDownload, { foreignKey: 'job_id', as: 'jobVideoDownloads' }); @@ -34,6 +36,9 @@ PlaylistSyncState.belongsTo(Playlist, { foreignKey: 'playlist_id', targetKey: 'i Video.hasMany(VideoWatchStatus, { foreignKey: 'video_id', as: 'watchStatuses' }); VideoWatchStatus.belongsTo(Video, { foreignKey: 'video_id', as: 'video' }); +TvShow.hasMany(VideoClassification, { foreignKey: 'show_id', as: 'classifications' }); +VideoClassification.belongsTo(TvShow, { foreignKey: 'show_id', as: 'show' }); + module.exports = { Job, JobVideo, @@ -50,4 +55,6 @@ module.exports = { MediaServerUser, WatchStatusSyncCursor, ScheduledTaskRun, + TvShow, + VideoClassification, }; diff --git a/server/models/subfolder.js b/server/models/subfolder.js index 370b28c8d..c84fc42eb 100644 --- a/server/models/subfolder.js +++ b/server/models/subfolder.js @@ -8,6 +8,8 @@ Subfolder.init( id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, // Stored clean (no __ prefix). Unique under utf8mb4_unicode_ci (case-insensitive). name: { type: DataTypes.STRING(100), allowNull: false, unique: true }, + // 'videos' (movie-style) or 'tv'. + layout: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'videos' }, }, { sequelize, diff --git a/server/models/tvshow.js b/server/models/tvshow.js new file mode 100644 index 000000000..66dbb68a2 --- /dev/null +++ b/server/models/tvshow.js @@ -0,0 +1,32 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class TvShow extends Model {} + +TvShow.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + // YouTube channel id of the owner (not a foreign key: untracked channels get shows too). + channel_id: { type: DataTypes.STRING(64), allowNull: false }, + kind: { type: DataTypes.STRING(16), allowNull: false }, + name: { type: DataTypes.STRING(255), allowNull: false }, + folder_name: { type: DataTypes.STRING(255), allowNull: false }, + // '' = main downloads folder, else the subfolder name without __. + library_folder: { type: DataTypes.STRING(100), allowNull: false, defaultValue: '' }, + position: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + exclude_terms: { type: DataTypes.TEXT, allowNull: true }, + poster_source: { type: DataTypes.STRING(255), allowNull: true }, + external_key: { type: DataTypes.STRING(64), allowNull: false }, + previous_videos_folder: { type: DataTypes.STRING(255), allowNull: true }, + retired_at: { type: DataTypes.DATE, allowNull: true }, + }, + { + sequelize, + modelName: 'TvShow', + tableName: 'tv_shows', + timestamps: true, + underscored: true, + } +); + +module.exports = TvShow; diff --git a/server/models/videoclassification.js b/server/models/videoclassification.js new file mode 100644 index 000000000..9d7912b67 --- /dev/null +++ b/server/models/videoclassification.js @@ -0,0 +1,30 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class VideoClassification extends Model {} + +VideoClassification.init( + { + youtube_id: { type: DataTypes.STRING(20), primaryKey: true, allowNull: false }, + channel_id: { type: DataTypes.STRING(64), allowNull: false }, + show_id: { type: DataTypes.INTEGER, allowNull: false }, + status: { type: DataTypes.STRING(16), allowNull: false }, + season: { type: DataTypes.INTEGER, allowNull: true }, + episode: { type: DataTypes.INTEGER, allowNull: true }, + source: { type: DataTypes.STRING(16), allowNull: true }, + timestamp_source: { type: DataTypes.STRING(16), allowNull: true }, + pattern_id: { type: DataTypes.INTEGER, allowNull: true }, + episode_title: { type: DataTypes.STRING(512), allowNull: true }, + file_stem: { type: DataTypes.STRING(255), allowNull: true }, + title_opt_out: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + }, + { + sequelize, + modelName: 'VideoClassification', + tableName: 'video_classifications', + timestamps: true, + underscored: true, + } +); + +module.exports = VideoClassification; diff --git a/server/modules/__tests__/channelModule.test.js b/server/modules/__tests__/channelModule.test.js index 801b663b1..93cc668aa 100644 --- a/server/modules/__tests__/channelModule.test.js +++ b/server/modules/__tests__/channelModule.test.js @@ -27,6 +27,13 @@ jest.mock('../m3uGenerator', () => ({ generateChannelM3UInBackground: jest.fn(), deleteChannelM3UInBackground: jest.fn(), })); +jest.mock('../tvShows/channelFolders', () => ({ + isTvChannel: jest.fn().mockResolvedValue(false), + resolveChannelDirectory: jest.fn(), +})); +jest.mock('../tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), +})); jest.mock('../downloadModule', () => ({ doChannelDownloads: jest.fn(), @@ -50,6 +57,8 @@ jest.mock('../ytDlpRunner', () => ({ // other cases. const flushConstructorTasks = () => new Promise(setImmediate); +jest.mock('../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + describe('channelModule facade', () => { describe('constructor', () => { test('schedules the auto-download task and subscribes to config changes', async () => { diff --git a/server/modules/__tests__/channelPoster.test.js b/server/modules/__tests__/channelPoster.test.js index 34f78d9a1..8c4d346b9 100644 --- a/server/modules/__tests__/channelPoster.test.js +++ b/server/modules/__tests__/channelPoster.test.js @@ -59,6 +59,12 @@ describe('Channel Poster Functionality', () => { jest.doMock('../messageEmitter', () => ({ emitMessage: jest.fn() })); + jest.doMock('../tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue((folder) => (folder === 'TV' ? 'tv' : 'videos')), + })); + jest.doMock('../tvShows/showStore', () => ({ + findChannelShow: jest.fn().mockResolvedValue({ library_folder: 'TV', folder_name: 'Pinned Show' }), + })); // Re-import after mocking fs = require('fs-extra'); @@ -297,6 +303,29 @@ describe('Channel Poster Functionality', () => { ); }); + it('backfills poster.jpg into the show folder of a TV channel', async () => { + configModule.getConfig.mockReturnValue({ writeChannelPosters: true, writeBackdropImages: false }); + + const channels = [ + { channel_id: 'UC123', uploader: 'Test Channel', sub_folder: 'TV' } + ]; + + fs.existsSync.mockImplementation((path) => { + if (path === '/videos') return true; + if (path === '/videos/__TV/Pinned Show') return true; + if (path === '/images/channelthumb-UC123.jpg') return true; + return false; + }); + + await channelModule.backfillChannelImages(channels); + + expect(fs.copySync).toHaveBeenCalledWith( + '/images/channelthumb-UC123.jpg', + '/videos/__TV/Pinned Show/poster.jpg', + { overwrite: true } + ); + }); + it('poster backfill still runs independently of the backdrop setting', async () => { configModule.getConfig.mockReturnValue({ writeChannelPosters: true, writeBackdropImages: false }); diff --git a/server/modules/__tests__/channelSettingsModule.test.js b/server/modules/__tests__/channelSettingsModule.test.js index 73f523d65..be1dd3503 100644 --- a/server/modules/__tests__/channelSettingsModule.test.js +++ b/server/modules/__tests__/channelSettingsModule.test.js @@ -58,6 +58,10 @@ jest.mock('../titleFilterRegex', () => ({ checkSyntax: jest.fn(), matchTitles: jest.fn(), })); +jest.mock('../tvShows/channelLayout', () => ({ + checkChannelSettingsChange: jest.fn(), + applyChannelFolderChange: jest.fn(), +})); describe('ChannelSettingsModule', () => { let channelSettingsModule; @@ -718,6 +722,59 @@ describe('ChannelSettingsModule', () => { }; Channel.findOne.mockResolvedValue(channel); jobModule.getAllJobs.mockReturnValue({}); + const channelLayout = require('../tvShows/channelLayout'); + channelLayout.checkChannelSettingsChange.mockResolvedValue({ involvesTv: false }); + channelLayout.applyChannelFolderChange.mockResolvedValue(null); + }); + + describe('TV layout', () => { + let channelLayout; + beforeEach(() => { + channelLayout = require('../tvShows/channelLayout'); + }); + + test('passes the new folder, audio format and download check to the layout guard', async () => { + const isDownloadRunning = () => false; + await channelSettingsModule.updateChannelSettings('UC123456', { sub_folder: ' TV ', audio_format: null }, { isDownloadRunning }); + + expect(channelLayout.checkChannelSettingsChange).toHaveBeenCalledWith(expect.objectContaining({ + newSubFolder: 'TV', newAudioFormat: null, isDownloadRunning, + })); + }); + + test('saves nothing when the layout guard refuses', async () => { + const refusal = Object.assign(new Error('has downloads'), { status: 409 }); + channelLayout.checkChannelSettingsChange.mockRejectedValue(refusal); + + await expect(channelSettingsModule.updateChannelSettings('UC123456', { sub_folder: 'TV' })) + .rejects.toBe(refusal); + const channel = await Channel.findOne(); + expect(channel.update).not.toHaveBeenCalled(); + }); + + test('gives the channel its show instead of moving files when TV is involved', async () => { + const change = { involvesTv: true, newLayout: 'tv' }; + channelLayout.checkChannelSettingsChange.mockResolvedValue(change); + const moveSpy = jest.spyOn(channelSettingsModule, 'moveChannelFolder'); + + await channelSettingsModule.updateChannelSettings('UC123456', { sub_folder: 'TV' }); + + expect(moveSpy).not.toHaveBeenCalled(); + expect(channelLayout.applyChannelFolderChange).toHaveBeenCalledWith(expect.objectContaining({ + previousSubFolder: mockChannel.sub_folder, change, + })); + moveSpy.mockRestore(); + }); + + test('rolls the folder back and reports the error when the show update fails', async () => { + channelLayout.checkChannelSettingsChange.mockResolvedValue({ involvesTv: true, newLayout: 'tv' }); + channelLayout.applyChannelFolderChange.mockRejectedValue(new Error('No free show folder name')); + + await expect(channelSettingsModule.updateChannelSettings('UC123456', { sub_folder: 'TV' })) + .rejects.toThrow('No free show folder name'); + const channel = await Channel.findOne(); + expect(channel.update).toHaveBeenLastCalledWith({ sub_folder: mockChannel.sub_folder }); + }); }); test('should throw error when channel not found', async () => { diff --git a/server/modules/__tests__/m3uGenerator.test.js b/server/modules/__tests__/m3uGenerator.test.js index 9b2b0cd53..1ba5a3208 100644 --- a/server/modules/__tests__/m3uGenerator.test.js +++ b/server/modules/__tests__/m3uGenerator.test.js @@ -51,6 +51,9 @@ describe('m3uGenerator', () => { jest.doMock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), })); + jest.doMock('../tvShows/channelFolders', () => ({ + isTvChannel: jest.fn().mockResolvedValue(false), + })); fs = require('fs'); ({ Playlist, PlaylistVideo, Video, Channel } = require('../../models')); @@ -186,6 +189,14 @@ describe('m3uGenerator', () => { m3u_sort_order: 'oldest_first', }; + test('writes no channel m3u for a TV channel', async () => { + Channel.findOne.mockResolvedValue({ ...baseChannel }); + require('../tvShows/channelFolders').isTvChannel.mockResolvedValue(true); + + await expect(m3uGenerator.generateChannelM3U('UC1')).resolves.toBe(false); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + test('writes channel m3u with relative paths, oldest first', async () => { Channel.findOne.mockResolvedValue({ ...baseChannel }); Video.findAll.mockResolvedValue([ diff --git a/server/modules/__tests__/manualDownloadGrouper.test.js b/server/modules/__tests__/manualDownloadGrouper.test.js index 814856bf8..0366da54b 100644 --- a/server/modules/__tests__/manualDownloadGrouper.test.js +++ b/server/modules/__tests__/manualDownloadGrouper.test.js @@ -7,22 +7,29 @@ jest.mock('../configModule', () => ({ config: { preferredResolution: '1080' }, getDefaultSubfolder: jest.fn().mockReturnValue(null), })); +jest.mock('../tvShows/libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); +jest.mock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); const Video = require('../../models/video'); const ChannelVideo = require('../../models/channelvideo'); const Channel = require('../../models/channel'); +const configModule = require('../configModule'); +const libraryLayouts = require('../tvShows/libraryLayouts'); const grouper = require('../manualDownloadGrouper'); const URL_A = 'https://www.youtube.com/watch?v=aaaaaaaaaaa'; const URL_B = 'https://www.youtube.com/watch?v=bbbbbbbbbbb'; const HD_CHANNEL = { channel_id: 'UChd', video_quality: '720', audio_format: null, skip_video_folder: true }; const MP3_CHANNEL = { channel_id: 'UCmp3', video_quality: null, audio_format: 'mp3_only', skip_video_folder: null }; +const TV_CHANNEL = { channel_id: 'UCtv', video_quality: null, audio_format: null, skip_video_folder: null, sub_folder: 'TV' }; beforeEach(() => { jest.clearAllMocks(); Video.findAll.mockResolvedValue([]); ChannelVideo.findAll.mockResolvedValue([]); Channel.findAll.mockResolvedValue([]); + configModule.getDefaultSubfolder.mockReturnValue(null); + libraryLayouts.getLayoutResolver.mockResolvedValue((folder) => (folder === 'TV' ? 'tv' : 'videos')); }); test('one global-settings group when nothing is attributable', async () => { @@ -125,3 +132,55 @@ test('an unparseable URL keeps its original string and joins the global group', { resolution: '1080', audioFormat: null, urls: [weird] }, ]); }); + +describe('TV folders are video-only', () => { + test('downgrades an MP3 override to video for the URL of a channel in a TV folder', async () => { + Channel.findAll.mockResolvedValue([TV_CHANNEL]); + const groups = await grouper.buildGroups({ + urls: [URL_A], + overrideSettings: { audioFormat: 'mp3_only' }, + videoChannelMap: { aaaaaaaaaaa: 'UCtv' }, + }); + expect(groups).toEqual([{ resolution: '1080', audioFormat: null, urls: [URL_A] }]); + }); + + test('keeps an MP3 override for the URL of a channel in a videos folder', async () => { + Channel.findAll.mockResolvedValue([{ ...HD_CHANNEL, sub_folder: 'Music' }]); + const groups = await grouper.buildGroups({ + urls: [URL_A], + overrideSettings: { audioFormat: 'mp3_only' }, + videoChannelMap: { aaaaaaaaaaa: 'UChd' }, + }); + expect(groups[0].audioFormat).toBe('mp3_only'); + }); + + test('splits MP3 URLs by destination layout', async () => { + Channel.findAll.mockResolvedValue([TV_CHANNEL, { ...MP3_CHANNEL, sub_folder: 'Music' }]); + const groups = await grouper.buildGroups({ + urls: [URL_A, URL_B], + overrideSettings: { audioFormat: 'mp3_only' }, + videoChannelMap: { aaaaaaaaaaa: 'UCtv', bbbbbbbbbbb: 'UCmp3' }, + }); + expect(groups).toContainEqual({ resolution: '1080', audioFormat: null, urls: [URL_A] }); + expect(groups).toContainEqual({ resolution: '1080', audioFormat: 'mp3_only', urls: [URL_B] }); + }); + + test('downgrades MP3 for an unattributed URL when the default subfolder is TV', async () => { + configModule.getDefaultSubfolder.mockReturnValue('TV'); + const groups = await grouper.buildGroups({ urls: [URL_A], overrideSettings: { audioFormat: 'video_mp3' } }); + expect(groups[0].audioFormat).toBeNull(); + }); + + test('downgrades MP3 for a destination override that is a TV folder', async () => { + const groups = await grouper.buildGroups({ + urls: [URL_A], + overrideSettings: { audioFormat: 'mp3_only', subfolder: 'TV' }, + }); + expect(groups[0].audioFormat).toBeNull(); + }); + + test('reads the folder layouts once per batch', async () => { + await grouper.buildGroups({ urls: [URL_A, URL_B], overrideSettings: { audioFormat: 'mp3_only' } }); + expect(libraryLayouts.getLayoutResolver).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/modules/__tests__/playlistDownloadGrouper.test.js b/server/modules/__tests__/playlistDownloadGrouper.test.js index ec49e3a0b..414dc6b9b 100644 --- a/server/modules/__tests__/playlistDownloadGrouper.test.js +++ b/server/modules/__tests__/playlistDownloadGrouper.test.js @@ -3,8 +3,11 @@ jest.mock('../configModule', () => ({ config: { preferredResolution: '1080' }, getDefaultSubfolder: jest.fn().mockReturnValue(null), })); +jest.mock('../tvShows/libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); +jest.mock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); const Channel = require('../../models/channel'); +const libraryLayouts = require('../tvShows/libraryLayouts'); const grouper = require('../playlistDownloadGrouper'); const playlist = { @@ -19,6 +22,7 @@ const playlist = { beforeEach(() => { jest.clearAllMocks(); Channel.findAll.mockResolvedValue([]); + libraryLayouts.getLayoutResolver.mockResolvedValue((folder) => (folder === 'TV' ? 'tv' : 'videos')); }); test('groups contain only command settings (no subFolder/rating)', async () => { @@ -131,3 +135,40 @@ test('routing values (subfolder/rating) never split groups', async () => { ); expect(groups).toHaveLength(1); }); + +describe('TV folders are video-only', () => { + const mp3Playlist = { ...playlist, audio_format: 'mp3_only' }; + + test('loads the channel subfolder with its command settings', async () => { + await grouper.buildGroups(mp3Playlist, [{ youtube_id: 'a', channel_id: 'UCc' }], {}); + expect(Channel.findAll).toHaveBeenCalledWith(expect.objectContaining({ + attributes: expect.arrayContaining(['sub_folder']), + })); + }); + + test('downgrades the playlist MP3 type for a video of a channel in a TV folder', async () => { + Channel.findAll.mockResolvedValue([ + { channel_id: 'UCtv', video_quality: null, audio_format: null, skip_video_folder: null, sub_folder: 'TV' }, + ]); + const groups = await grouper.buildGroups(mp3Playlist, [ + { youtube_id: 'a', channel_id: 'UCtv' }, + { youtube_id: 'b', channel_id: 'UCnone' }, + ], {}); + expect(groups).toContainEqual({ resolution: '720', audioFormat: null, skipVideoFolder: false, youtubeIds: ['a'] }); + expect(groups).toContainEqual({ resolution: '720', audioFormat: 'mp3_only', skipVideoFolder: false, youtubeIds: ['b'] }); + }); + + test('downgrades MP3 for untracked videos when the playlist folder is TV', async () => { + const groups = await grouper.buildGroups({ ...mp3Playlist, default_sub_folder: 'TV' }, [ + { youtube_id: 'a', channel_id: 'UCnone' }, + ], {}); + expect(groups[0].audioFormat).toBeNull(); + }); + + test('downgrades an MP3 download override into a TV folder', async () => { + const groups = await grouper.buildGroups(playlist, [{ youtube_id: 'a', channel_id: 'UCnone' }], { + audioFormat: 'video_mp3', subfolder: 'TV', + }); + expect(groups[0].audioFormat).toBeNull(); + }); +}); diff --git a/server/modules/__tests__/subfolderModule.test.js b/server/modules/__tests__/subfolderModule.test.js index 6278a2cd6..80a0f117b 100644 --- a/server/modules/__tests__/subfolderModule.test.js +++ b/server/modules/__tests__/subfolderModule.test.js @@ -8,6 +8,8 @@ jest.mock('../../models/subfolder', () => ({ })); jest.mock('../../models/channel', () => ({ count: jest.fn(), findAll: jest.fn() })); jest.mock('../../models/playlist', () => ({ count: jest.fn(), findAll: jest.fn() })); +jest.mock('../../models/tvshow', () => ({ findAll: jest.fn() })); +jest.mock('../../models/videoclassification', () => ({ findAll: jest.fn() })); jest.mock('../configModule', () => ({ getDefaultSubfolder: jest.fn(), getConfig: jest.fn(), @@ -17,11 +19,18 @@ jest.mock('../filesystem', () => ({ buildSubfolderSegment: (n) => (n ? `__${n}` : null), directoryHasFiles: jest.fn(), removeIfEmpty: jest.fn(), + resolveEffectiveSubfolder: jest.requireActual('../filesystem/pathBuilder').resolveEffectiveSubfolder, +})); +// Folder layouts for the show count: TV and Kids TV are TV folders. +jest.mock('../tvShows/libraryLayouts', () => ({ + getLayoutResolver: jest.fn().mockResolvedValue((folder) => (/^(tv|kids tv)$/i.test(folder) ? 'tv' : 'videos')), })); const Subfolder = require('../../models/subfolder'); const Channel = require('../../models/channel'); const Playlist = require('../../models/playlist'); +const TvShow = require('../../models/tvshow'); +const VideoClassification = require('../../models/videoclassification'); const configModule = require('../configModule'); const filesystem = require('../filesystem'); @@ -30,6 +39,8 @@ beforeEach(() => { jest.clearAllMocks(); configModule.getDefaultSubfolder.mockReturnValue(null); configModule.getConfig.mockReturnValue({ plexSubfolderLibraryMappings: [] }); + VideoClassification.findAll.mockResolvedValue([]); + TvShow.findAll.mockResolvedValue([]); subfolderModule = require('../subfolderModule'); }); @@ -70,7 +81,7 @@ describe('getUsage', () => { { name: 'Spare', displayName: '__Spare', - usage: { channels: 0, playlists: 0, isDefault: false, plexMapped: false, hasFiles: false }, + usage: { channels: 0, playlists: 0, shows: 0, isDefault: false, plexMapped: false, hasFiles: false }, deletable: true, }, ]); @@ -88,6 +99,17 @@ describe('getUsage', () => { expect(item.deletable).toBe(false); }); + test('counts TV shows with numbered episodes and blocks deletion', async () => { + Subfolder.findAll.mockResolvedValue([{ name: 'TV' }]); + VideoClassification.findAll.mockResolvedValue([{ show_id: 1 }, { show_id: 2 }]); + TvShow.findAll.mockResolvedValue([{ library_folder: 'tv' }, { library_folder: 'Other' }]); + + const [item] = await subfolderModule.getUsage(); + + expect(item.usage.shows).toBe(1); + expect(item.deletable).toBe(false); + }); + test('flags the global default and is not deletable', async () => { Subfolder.findAll.mockResolvedValue([]); configModule.getDefaultSubfolder.mockReturnValue('Default'); @@ -178,6 +200,41 @@ describe('delete', () => { await expect(subfolderModule.delete('used')).rejects.toMatchObject({ status: 409 }); }); + test('409 when it holds a TV show with numbered episodes', async () => { + VideoClassification.findAll.mockResolvedValue([{ show_id: 4 }]); + TvShow.findAll.mockResolvedValue([{ library_folder: 'TV' }]); + await expect(subfolderModule.delete('tv')).rejects.toThrow('holds 1 TV show(s)'); + }); + + test('still counts the show of a channel that downloads to the folder', async () => { + VideoClassification.findAll.mockResolvedValue([{ show_id: 4 }]); + TvShow.findAll.mockResolvedValue([{ library_folder: 'TV', channel_id: 'UC1' }]); + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', sub_folder: 'TV' }]); + await expect(subfolderModule.delete('TV')).rejects.toThrow('holds 1 TV show(s)'); + }); + + test('ignores the show of a channel that has moved back to a videos folder', async () => { + VideoClassification.findAll.mockResolvedValue([{ show_id: 4 }]); + TvShow.findAll.mockResolvedValue([{ library_folder: 'TV', channel_id: 'UC1' }]); + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', sub_folder: 'Kids' }]); + await expect(subfolderModule.delete('TV')).resolves.toBeUndefined(); + }); + + test('counts the show of an untracked channel, which has no folder of its own', async () => { + VideoClassification.findAll.mockResolvedValue([{ show_id: 4 }]); + TvShow.findAll.mockResolvedValue([{ library_folder: 'TV', channel_id: 'UCuntracked' }]); + Channel.findAll.mockResolvedValue([]); + await expect(subfolderModule.delete('TV')).rejects.toThrow('holds 1 TV show(s)'); + }); + + test('ignores retired shows when counting', async () => { + VideoClassification.findAll.mockResolvedValue([{ show_id: 4 }]); + await subfolderModule.delete('TV'); + expect(TvShow.findAll).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: [4], retired_at: null }, + })); + }); + test('409 when the directory still holds files', async () => { filesystem.directoryHasFiles.mockResolvedValue(true); await expect(subfolderModule.delete('Full')).rejects.toMatchObject({ status: 409 }); diff --git a/server/modules/__tests__/videoDownloadPostProcessFiles.test.js b/server/modules/__tests__/videoDownloadPostProcessFiles.test.js index 28d3a5fcb..1483b9439 100644 --- a/server/modules/__tests__/videoDownloadPostProcessFiles.test.js +++ b/server/modules/__tests__/videoDownloadPostProcessFiles.test.js @@ -88,6 +88,16 @@ const mockVideoPersistence = { jest.mock('../videoPersistence', () => mockVideoPersistence); +// The TV branch is decided by episodePlacement (tested on its own); null +// keeps the movie-style path every other test here exercises. +const mockEpisodePlacement = { + planEpisode: jest.fn(() => Promise.resolve(null)), + moveEpisodeFiles: jest.fn(() => Promise.resolve([])), + writeEpisodeMetadata: jest.fn(() => Promise.resolve()), +}; + +jest.mock('../tvShows/episodePlacement', () => mockEpisodePlacement); + jest.mock('../../logger'); // downloadSettingsResolver is intentionally not mocked: it is a pure function, so these @@ -118,7 +128,7 @@ const ChannelVideo = require('../../models/channelvideo'); const flushPromises = () => new Promise((resolve) => queueMicrotask(resolve)); -async function settleAsync(iterations = 5) { +async function settleAsync(iterations = 10) { for (let i = 0; i < iterations; i += 1) { await flushPromises(); } @@ -143,6 +153,9 @@ describe('videoDownloadPostProcessFiles', () => { logger.error.mockClear(); JobVideoDownload.update.mockResolvedValue([0]); mockVideoPersistence.persistDownloadedVideoForJob.mockResolvedValue(null); + mockEpisodePlacement.planEpisode.mockResolvedValue(null); + mockEpisodePlacement.moveEpisodeFiles.mockResolvedValue([]); + mockEpisodePlacement.writeEpisodeMetadata.mockResolvedValue(); Channel.findOne.mockResolvedValue(null); Channel.findAll.mockResolvedValue([]); ChannelVideo.findAll.mockResolvedValue([]); @@ -282,6 +295,205 @@ describe('videoDownloadPostProcessFiles', () => { expect(getEmbeddedTitle(videoPath)).toBe('Video Title'); }); + describe('TV episodes', () => { + const stem = 'S2024E01310000 - Video Title [abc123]'; + const showDir = '/library/__TV/Channel'; + const seasonDir = '/library/__TV/Channel/Season 2024'; + const episodePath = `${seasonDir}/${stem}.mp4`; + const placement = { + show: { id: 5, channel_id: 'channel123', name: 'Channel Show', folder_name: 'Channel', library_folder: 'TV', external_key: 'channel123' }, + assignment: { season: 2024, episode: 1310000, dateNumbered: true, episodeTitle: 'Video Title', fileStem: stem }, + showDir, + seasonDir, + stem, + }; + + beforeEach(() => { + mockEpisodePlacement.planEpisode.mockResolvedValue(placement); + tempPathManager.isTempPath.mockReturnValue(true); + fs.existsSync.mockImplementation((p) => p === jsonPath || p === episodePath); + }); + + it('asks for a placement with the resolved owner and folder', async () => { + await loadModule(); + await settleAsync(); + + expect(mockEpisodePlacement.planEpisode).toHaveBeenCalledWith(expect.objectContaining({ + youtubeId: 'abc123', + ownerChannelId: 'channel123', + resolvedSubfolder: null, + baseDir: '/library', + })); + }); + + it('moves the files into the season folder under the episode stem', async () => { + await loadModule(); + await settleAsync(); + + expect(mockEpisodePlacement.moveEpisodeFiles).toHaveBeenCalledWith({ + sourceDir: '/library/Channel', youtubeId: 'abc123', seasonDir, stem, + }); + }); + + it('records the episode path as the final file path', async () => { + await loadModule(); + await settleAsync(); + + expect(JobVideoDownload.update).toHaveBeenCalledWith( + { status: 'completed', file_path: episodePath }, + expect.any(Object) + ); + const savedInfo = fs.writeFileSync.mock.calls + .filter(([target]) => target === '/mock/jobs/info/abc123.info.json') + .map(([, content]) => JSON.parse(content)) + .pop(); + expect(savedInfo._actual_filepath).toBe(episodePath); + }); + + it('tags the file as a TV episode without the channel prefix or season atoms', async () => { + await loadModule(); + await settleAsync(); + + const args = getAtomicParsleyArgs(videoPath); + expect(args).toEqual(expect.arrayContaining([ + '--title', 'Video Title', '--TVShowName', 'Channel Show', '--TVEpisode', 'abc123', '--stik', 'TV Show', + ])); + expect(args).not.toEqual(expect.arrayContaining(['--TVSeasonNum'])); + expect(args).not.toEqual(expect.arrayContaining(['--TVEpisodeNum'])); + }); + + it('writes episode NFO files instead of the movie NFO', async () => { + await loadModule(); + await settleAsync(); + + expect(nfoGenerator.writeVideoNfoFile).not.toHaveBeenCalled(); + expect(mockEpisodePlacement.writeEpisodeMetadata).toHaveBeenCalledWith(expect.objectContaining({ + placement, + info: expect.objectContaining({ id: 'abc123' }), + })); + }); + + it('writes episode NFO files even when video NFO files are off', async () => { + configModule.__setConfig({ writeChannelPosters: false, writeVideoNfoFiles: false }); + + await loadModule(); + await settleAsync(); + + expect(mockEpisodePlacement.writeEpisodeMetadata).toHaveBeenCalled(); + }); + + it('copies the channel banner into the show folder', async () => { + configModule.__setConfig({ writeChannelPosters: false, writeVideoNfoFiles: true, writeBackdropImages: true }); + const bannerCache = '/mock/images/channelbanner-channel123.jpg'; + fs.existsSync.mockImplementation((p) => p === jsonPath || p === episodePath || p === bannerCache); + + await loadModule(); + await settleAsync(); + + expect(fs.copySync).toHaveBeenCalledWith(bannerCache, `${showDir}/backdrop.jpg`, { overwrite: true }); + }); + + it('uses the show owner\'s banner for a video uploaded by another channel', async () => { + configModule.__setConfig({ writeChannelPosters: false, writeVideoNfoFiles: true, writeBackdropImages: true }); + mockEpisodePlacement.planEpisode.mockResolvedValue({ ...placement, show: { ...placement.show, channel_id: 'owner456' } }); + const ownerBanner = '/mock/images/channelbanner-owner456.jpg'; + fs.existsSync.mockImplementation((p) => p === jsonPath || p === episodePath || p === ownerBanner); + + await loadModule(); + await settleAsync(); + + expect(fs.copySync).toHaveBeenCalledWith(ownerBanner, `${showDir}/backdrop.jpg`, { overwrite: true }); + }); + + // Settings keep MP3 types away from TV folders; MP3 output that still + // arrives (a pasted URL of a TV channel's video, an MP3 playlist) is placed + // as the episode, never movie-style, so a folder never mixes layouts. + describe('MP3 output that reaches a TV folder', () => { + const audioPath = '/library/Channel/Video Title [abc123].mp3'; + const companionVideoPath = '/library/Channel/Video Title [abc123].mp4'; + const episodeAudioPath = `${seasonDir}/${stem}.mp3`; + const savedInfo = () => fs.writeFileSync.mock.calls + .filter(([target]) => target === '/mock/jobs/info/abc123.info.json') + .map(([, content]) => JSON.parse(content)) + .pop(); + + beforeEach(() => { + process.argv = ['node', 'script', audioPath]; + }); + + it('places an MP3-only download under the episode stem', async () => { + fs.existsSync.mockImplementation((p) => p === jsonPath || p === episodeAudioPath); + + await loadModule(); + await settleAsync(); + + expect(savedInfo()._actual_audio_filepath).toBe(episodeAudioPath); + expect(JobVideoDownload.update).toHaveBeenCalledWith( + { status: 'completed', file_path: episodeAudioPath }, + expect.any(Object) + ); + }); + + it('warns that an MP3-only episode has no video for the media server', async () => { + fs.existsSync.mockImplementation((p) => p === jsonPath || p === episodeAudioPath); + + await loadModule(); + await settleAsync(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ id: 'abc123', seasonDir }), + expect.stringMatching(/MP3/) + ); + }); + + it('does not warn for a Video + MP3 download, whose MP4 is the episode', async () => { + fs.existsSync.mockImplementation((p) => [jsonPath, companionVideoPath, episodeAudioPath].includes(p)); + + await loadModule(); + await settleAsync(); + + expect(logger.warn).not.toHaveBeenCalledWith(expect.anything(), expect.stringMatching(/MP3/)); + }); + + it('places a Video + MP3 download\'s MP4 under the episode stem too', async () => { + fs.existsSync.mockImplementation((p) => [jsonPath, companionVideoPath, episodeAudioPath].includes(p)); + + await loadModule(); + await settleAsync(); + + expect(savedInfo()._actual_video_filepath).toBe(episodePath); + }); + + it('tags the MP4 of a Video + MP3 download as the episode', async () => { + fs.existsSync.mockImplementation((p) => [jsonPath, companionVideoPath, episodeAudioPath].includes(p)); + + await loadModule(); + await settleAsync(); + + expect(getAtomicParsleyArgs(companionVideoPath)).toEqual(expect.arrayContaining(['--stik', 'TV Show'])); + }); + }); + + it('fails the video when its files cannot be moved', async () => { + mockEpisodePlacement.moveEpisodeFiles.mockRejectedValue(new Error('EACCES')); + + await loadModule(); + await settleAsync(); + + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('fails the video rather than saving it movie-style when it cannot be placed', async () => { + mockEpisodePlacement.planEpisode.mockRejectedValue(new Error('db down')); + + await loadModule(); + await settleAsync(); + + expect(process.exit).toHaveBeenCalledWith(1); + expect(fs.move).not.toHaveBeenCalled(); + }); + }); + describe('video_mp3 dual-format downloads', () => { const audioPath = '/library/Channel/Video Title [abc123].mp3'; const companionVideoPath = '/library/Channel/Video Title [abc123].mp4'; diff --git a/server/modules/__tests__/videoMetadataModule.test.js b/server/modules/__tests__/videoMetadataModule.test.js index b39ac19d1..188847cbf 100644 --- a/server/modules/__tests__/videoMetadataModule.test.js +++ b/server/modules/__tests__/videoMetadataModule.test.js @@ -6,6 +6,7 @@ describe('VideoMetadataModule', () => { let mockVideo; let mockChannelVideo; let mockLogger; + let mockEpisodeInfo; let mockConfigModule; let mockYtDlpRunner; let mockYoutubeApi; @@ -85,6 +86,8 @@ describe('VideoMetadataModule', () => { jest.doMock('../ytDlpRunner', () => mockYtDlpRunner); jest.doMock('../youtubeApi', () => mockYoutubeApi); jest.doMock('../channelVideoReanchor', () => mockChannelVideoReanchor); + mockEpisodeInfo = { getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) }; + jest.doMock('../tvShows/episodeInfo', () => mockEpisodeInfo); videoMetadataModule = require('../videoMetadataModule'); }); @@ -151,6 +154,31 @@ describe('VideoMetadataModule', () => { expect(result.thumbnails).toBeUndefined(); }); + test('reports the episode of a downloaded TV episode', async () => { + const episode = { showName: 'Show', season: 2024, episode: 3151200, code: 'S2024E03151200' }; + const filePath = '/tv/Show/Season 2024/S2024E03151200 - T [abc123].mp4'; + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue(JSON.stringify({ upload_date: '20240315', _actual_filepath: filePath })); + mockVideo.findOne.mockResolvedValue(null); + mockEpisodeInfo.getEpisodeInfoMap.mockResolvedValueOnce(new Map([['abc123', episode]])); + + const result = await videoMetadataModule.getVideoMetadata('abc123'); + + expect(mockEpisodeInfo.getEpisodeInfoMap).toHaveBeenCalledWith([{ youtubeId: 'abc123', filePath }]); + expect(result.episode).toEqual(episode); + }); + + test('reports no episode when the episode lookup fails', async () => { + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue(JSON.stringify({ upload_date: '20240315' })); + mockVideo.findOne.mockResolvedValue(null); + mockEpisodeInfo.getEpisodeInfoMap.mockRejectedValueOnce(new Error('db')); + + const result = await videoMetadataModule.getVideoMetadata('abc123'); + + expect(result.episode).toBeNull(); + }); + test('does not include a downloadedTier field (tier is derived client-side)', async () => { const rawInfoJson = { width: 608, diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index 078555e56..7c0ab4916 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -1,6 +1,8 @@ /* eslint-env jest */ const { Sequelize } = require('sequelize'); +jest.mock('../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + describe('VideosModule', () => { let VideosModule; let mockSequelize; @@ -892,6 +894,22 @@ describe('VideosModule', () => { expect(result.videos[1].watchedBy).toEqual([]); }); + test('attaches the episode of each video that is a TV episode', async () => { + const episode = { showName: 'Show', season: 2024, episode: 3151200, code: 'S2024E03151200' }; + mockVideo.count.mockResolvedValue(2); + mockVideo.findAll.mockResolvedValue([ + { id: 1, youtubeId: 'abc123', filePath: '/tv/S2024E03151200 - T [abc123].mp4', removed: false }, + { id: 2, youtubeId: 'def456', filePath: null, removed: false }, + ]); + mockVideo.aggregate.mockResolvedValue([]); + const episodeInfo = require('../tvShows/episodeInfo'); + episodeInfo.getEpisodeInfoMap.mockResolvedValueOnce(new Map([['abc123', episode]])); + + const result = await VideosModule.getVideosPaginated({ page: 1, limit: 12 }); + + expect(result.videos.map((v) => v.episode)).toEqual([episode, null]); + }); + test('passes an empty id list when the page has no videos', async () => { mockVideo.count.mockResolvedValue(0); mockVideo.findAll.mockResolvedValue([]); diff --git a/server/modules/channel/__tests__/channelCatalog.test.js b/server/modules/channel/__tests__/channelCatalog.test.js index d1e45dfb3..e5e5c568f 100644 --- a/server/modules/channel/__tests__/channelCatalog.test.js +++ b/server/modules/channel/__tests__/channelCatalog.test.js @@ -15,6 +15,10 @@ jest.mock('../../m3uGenerator', () => ({ })); jest.mock('../tabDownloadStats', () => ({ getForChannels: jest.fn().mockResolvedValue(new Map()) })); +jest.mock('../../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); +jest.mock('../../tvShows/libraryLayouts', () => ({ getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), listTvFolders: jest.fn().mockResolvedValue([]) })); +jest.mock('../../tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); + describe('channelCatalog', () => { let channelCatalog; let Channel; @@ -194,6 +198,7 @@ describe('channelCatalog', () => { terminated_at: null, auto_removal_protected: false, auto_removal_keep_recent_count: null, + layout: 'videos', } ], total: 25, @@ -204,6 +209,36 @@ describe('channelCatalog', () => { }); }); + test('marks each channel with the layout of its folder', async () => { + const libraryLayouts = require('../../tvShows/libraryLayouts'); + libraryLayouts.getLayoutResolver.mockResolvedValueOnce((folder) => (folder === 'TV' ? 'tv' : 'videos')); + Channel.findAndCountAll.mockResolvedValueOnce({ + rows: [{ channel_id: 'UC1', sub_folder: 'TV' }, { channel_id: 'UC2', sub_folder: 'Kids' }], + count: 2, + }); + + const result = await channelCatalog.getChannelsPaginated(); + + expect(result.channels.map((c) => c.layout)).toEqual(['tv', 'videos']); + }); + + test('filters to channels in TV folders', async () => { + const libraryLayouts = require('../../tvShows/libraryLayouts'); + libraryLayouts.listTvFolders.mockResolvedValueOnce(['TV']); + + await channelCatalog.getChannelsPaginated({ layout: 'tv' }); + + const { where } = Channel.findAndCountAll.mock.calls[0][0]; + expect(where[Op.and]).toEqual([{ [Op.or]: [{ sub_folder: { [Op.in]: ['TV'] } }] }]); + }); + + test('matches no channel for the TV filter when no folder is TV', async () => { + await channelCatalog.getChannelsPaginated({ layout: 'tv' }); + + const { where } = Channel.findAndCountAll.mock.calls[0][0]; + expect(where[Op.and]).toEqual([{ id: null }]); + }); + test('applies search filtering when a term is provided', async () => { await channelCatalog.getChannelsPaginated({ searchTerm: 'Tech' }); @@ -430,11 +465,25 @@ describe('channelCatalog', () => { channelSettingsModule, }); - expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCpending', settings); + expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCpending', settings, expect.any(Object)); expect(channelSettingsModule.updateChannelSettings.mock.invocationCallOrder[0]) .toBeLessThan(mockChannel.update.mock.invocationCallOrder[0]); }); + test('passes the running-download check to the settings save', async () => { + const mockChannel = { channel_id: 'UCpending', update: jest.fn().mockResolvedValue(true) }; + Channel.findOne = jest.fn().mockResolvedValue(mockChannel); + const isDownloadRunning = () => false; + + await channelCatalog.updateChannelsByDelta({ + enableUrls: [{ url: 'https://youtube.com/@pending', channel_id: 'UCpending', settings }], + channelSettingsModule, + isDownloadRunning, + }); + + expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCpending', settings, { isDownloadRunning }); + }); + test('leaves settings untouched for an add item without settings', async () => { const mockChannel = { channel_id: 'UCplain', update: jest.fn().mockResolvedValue(true) }; Channel.findOne = jest.fn().mockResolvedValue(mockChannel); @@ -458,7 +507,7 @@ describe('channelCatalog', () => { channelSettingsModule, }); - expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCfetched', settings); + expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCfetched', settings, expect.any(Object)); }); test('leaves an unchanged subfolder out so active downloads do not block the save', async () => { @@ -477,7 +526,7 @@ describe('channelCatalog', () => { channelSettingsModule, }); - expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCpending', { video_quality: '720' }); + expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCpending', { video_quality: '720' }, expect.any(Object)); }); test('passes a changed subfolder through', async () => { @@ -489,7 +538,7 @@ describe('channelCatalog', () => { channelSettingsModule, }); - expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCpending', { sub_folder: 'Kids' }); + expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith('UCpending', { sub_folder: 'Kids' }, expect.any(Object)); }); test('provisions a fetched channel disabled and enables it only after applying settings', async () => { diff --git a/server/modules/channel/__tests__/channelVideoQuery.test.js b/server/modules/channel/__tests__/channelVideoQuery.test.js index 7aacf17b2..7c535ebab 100644 --- a/server/modules/channel/__tests__/channelVideoQuery.test.js +++ b/server/modules/channel/__tests__/channelVideoQuery.test.js @@ -9,6 +9,8 @@ jest.mock('../../mediaServers/watchStatusQueries', () => ({ getWatchedByMap: jes jest.mock('../../../db', () => mockFactories.mockDb()); jest.mock('../../fileCheckModule', () => mockFactories.mockFileCheckModule()); +jest.mock('../../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + describe('channelVideoQuery', () => { let channelVideoQuery; let ChannelVideo; @@ -58,6 +60,23 @@ describe('channelVideoQuery', () => { expect(result[2].removed).toBe(false); }); + test('adds the episode of a downloaded TV episode, and null otherwise', async () => { + const Video = require('../../../models/video'); + const episodeInfo = require('../../tvShows/episodeInfo'); + const episode = { showName: 'Show', season: 2024, episode: 3151200, code: 'S2024E03151200' }; + Video.findAll = jest.fn().mockResolvedValue([ + { id: 1, youtubeId: 'video1', removed: false, filePath: '/tv/S2024E03151200 - T [video1].mp4' }, + ]); + episodeInfo.getEpisodeInfoMap.mockResolvedValueOnce(new Map([['video1', episode]])); + + const result = await channelVideoQuery.enrichVideosWithDownloadStatus([{ youtube_id: 'video1' }, { youtube_id: 'video2' }]); + + expect(episodeInfo.getEpisodeInfoMap).toHaveBeenCalledWith([ + { youtubeId: 'video1', filePath: '/tv/S2024E03151200 - T [video1].mp4' }, + ]); + expect(result.map((v) => v.episode)).toEqual([episode, null]); + }); + test('carries video_resolution from the Videos row onto the enriched video', async () => { const Video = require('../../../models/video'); diff --git a/server/modules/channel/__tests__/channelVideosService.test.js b/server/modules/channel/__tests__/channelVideosService.test.js index 43238db0b..9320e3168 100644 --- a/server/modules/channel/__tests__/channelVideosService.test.js +++ b/server/modules/channel/__tests__/channelVideosService.test.js @@ -19,6 +19,8 @@ jest.mock('../../../db', () => mockFactories.mockDb()); // yt-dlp --dump-json output: one JSON document per line. const toEntryLines = (entries) => entries.map((entry) => `${JSON.stringify(entry)}\n`).join(''); +jest.mock('../../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); + describe('channelVideosService', () => { let channelVideosService; let Channel; diff --git a/server/modules/channel/channelCatalog.js b/server/modules/channel/channelCatalog.js index d4e8a7e9b..4ab679efd 100644 --- a/server/modules/channel/channelCatalog.js +++ b/server/modules/channel/channelCatalog.js @@ -6,6 +6,9 @@ const channelThumbnails = require('./channelThumbnails'); const channelProvisioning = require('./channelProvisioning'); const tabDownloadStats = require('./tabDownloadStats'); const m3uGenerator = require('../m3uGenerator'); +const channelFolders = require('../tvShows/channelFolders'); +const { getLayoutResolver } = require('../tvShows/libraryLayouts'); +const { LAYOUT_TV, LAYOUT_VIDEOS } = require('../tvShows/constants'); const SUB_FOLDER_DEFAULT_KEY = '__default__'; @@ -138,6 +141,7 @@ class ChannelCatalog { sortBy = 'name', sortOrder = 'asc', subFolder = null, + layout = null, } = {}) { const parsedPage = parseInt(page, 10); const parsedPageSize = parseInt(pageSize, 10); @@ -179,6 +183,11 @@ class ChannelCatalog { const direction = typeof sortOrder === 'string' && sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; try { + // TV filter: channels whose folder (explicit, global default or root) is TV. + if (layout === LAYOUT_TV) { + whereClause[Op.and] = [(await channelFolders.tvChannelCondition()) || { id: null }]; + } + const { rows, count } = await Channel.findAndCountAll({ where: whereClause, limit: safePageSize, @@ -200,6 +209,12 @@ class ChannelCatalog { return new Map(); }); + // Layout is decoration too: unreadable layouts show every channel as videos. + const layoutOf = await getLayoutResolver().catch((err) => { + logger.warn({ err }, 'Failed to read library folder layouts'); + return () => LAYOUT_VIDEOS; + }); + const totalPages = count > 0 ? Math.ceil(count / safePageSize) : 0; const normalizedSubFolders = distinctSubFolders .map((entry) => entry.sub_folder) @@ -212,7 +227,10 @@ class ChannelCatalog { }); return { - channels: rows.map((channel) => channelMappers.mapChannelListEntry(channel, statsByChannel.get(channel.channel_id))), + channels: rows.map((channel) => ({ + ...channelMappers.mapChannelListEntry(channel, statsByChannel.get(channel.channel_id)), + layout: layoutOf(channelFolders.effectiveLibraryFolder(channel.sub_folder)), + })), total: count, page: safePage, pageSize: safePageSize, @@ -302,7 +320,7 @@ class ChannelCatalog { * @returns {Promise<void>} * @throws {Error} code INVALID_CHANNEL_SETTINGS when any add item's settings are invalid; nothing is changed */ - async updateChannelsByDelta({ enableUrls = [], disableUrls = [], channelSettingsModule } = {}) { + async updateChannelsByDelta({ enableUrls = [], disableUrls = [], channelSettingsModule, isDownloadRunning } = {}) { // Handle both string URLs and objects with url/channel_id/settings const toEnable = (enableUrls || []).map((item) => { if (typeof item === 'string') { @@ -359,7 +377,8 @@ class ChannelCatalog { const provisioned = await Channel.findOne({ where: { channel_id: channelInfo.id } }); await channelSettingsModule.updateChannelSettings( channelInfo.id, - withoutUnchangedSubFolder(settings, provisioned) + withoutUnchangedSubFolder(settings, provisioned), + { isDownloadRunning } ); } await Channel.update({ enabled: true }, { where: { channel_id: channelInfo.id } }); @@ -371,7 +390,8 @@ class ChannelCatalog { if (settings) { await channelSettingsModule.updateChannelSettings( foundChannel.channel_id, - withoutUnchangedSubFolder(settings, foundChannel) + withoutUnchangedSubFolder(settings, foundChannel), + { isDownloadRunning } ); } await foundChannel.update({ enabled: true }); diff --git a/server/modules/channel/channelThumbnails.js b/server/modules/channel/channelThumbnails.js index cb902a41b..c03184894 100644 --- a/server/modules/channel/channelThumbnails.js +++ b/server/modules/channel/channelThumbnails.js @@ -12,6 +12,8 @@ const { resolveChannelFolderName, } = require('../filesystem'); const channelYtdlpExecutor = require('./channelYtdlpExecutor'); +const channelFolders = require('../tvShows/channelFolders'); +const { getLayoutResolver } = require('../tvShows/libraryLayouts'); class ChannelThumbnails { /** @@ -222,6 +224,7 @@ class ChannelThumbnails { return; } + const layoutOf = await getLayoutResolver(); for (const channel of channels) { if (!channel.channel_id) continue; @@ -230,15 +233,20 @@ class ChannelThumbnails { // Channels can live under a __subfolder (explicit or via the global // default); resolve the real folder path the same way downloads do. + // A TV channel's art goes in its show folder instead. let channelFolderPath; try { - const subfolder = resolveEffectiveSubfolder(channel.sub_folder, configModule.getDefaultSubfolder()); - channelFolderPath = buildChannelPath(outputDir, subfolder, channelFolderName); + if (await channelFolders.isTvChannel(channel, layoutOf)) { + channelFolderPath = (await channelFolders.resolveChannelDirectory(channel, { layoutOf })).dir; + } else { + const subfolder = resolveEffectiveSubfolder(channel.sub_folder, configModule.getDefaultSubfolder()); + channelFolderPath = buildChannelPath(outputDir, subfolder, channelFolderName); + } } catch (pathErr) { logger.warn({ err: pathErr, channelId: channel.channel_id }, 'Skipping channel with unresolvable folder path during image backfill'); continue; } - if (!fs.existsSync(channelFolderPath)) continue; + if (!channelFolderPath || !fs.existsSync(channelFolderPath)) continue; if (shouldWritePosters) { this.copyChannelImageIfMissing( diff --git a/server/modules/channel/channelVideoQuery.js b/server/modules/channel/channelVideoQuery.js index e650d7fa2..2c65539cd 100644 --- a/server/modules/channel/channelVideoQuery.js +++ b/server/modules/channel/channelVideoQuery.js @@ -3,6 +3,7 @@ const watchStatusQueries = require('../mediaServers/watchStatusQueries'); const fileCheckModule = require('../fileCheckModule'); const ratingMapper = require('../ratingMapper'); const { PUBLISHED_AT_SOURCE } = require('../constants/publishedAtSource'); +const episodeInfo = require('../tvShows/episodeInfo'); class ChannelVideoQuery { /** @@ -90,6 +91,9 @@ class ChannelVideoQuery { const watchedByVideoId = await watchStatusQueries.getWatchedByMap( downloadedVideos.map((v) => v.id) ); + const episodesByVideoId = await episodeInfo.getEpisodeInfoMap( + downloadedVideos.map((v) => ({ youtubeId: v.youtubeId, filePath: v.filePath })) + ); return videos.map((video) => { const plainVideoObject = video.toJSON ? video.toJSON() : video; @@ -114,6 +118,7 @@ class ChannelVideoQuery { ? new Date(status.last_downloaded_at).toISOString() : null; plainVideoObject.watchedBy = watchedByVideoId.get(status.id) || []; + plainVideoObject.episode = episodesByVideoId.get(videoId) || null; } else { // Video never downloaded plainVideoObject.added = false; @@ -125,6 +130,7 @@ class ChannelVideoQuery { plainVideoObject.protected = false; plainVideoObject.video_resolution = null; plainVideoObject.watchedBy = []; + plainVideoObject.episode = null; } // Replace thumbnail with template format (unless video is removed from YouTube) diff --git a/server/modules/channelSettingsModule.js b/server/modules/channelSettingsModule.js index b28d719fa..259e3d5c2 100644 --- a/server/modules/channelSettingsModule.js +++ b/server/modules/channelSettingsModule.js @@ -13,6 +13,7 @@ const { validateSubFolderName } = require('./filesystem/subfolderValidation'); const subfolderModule = require('./subfolderModule'); const m3uGenerator = require('./m3uGenerator'); const titleRegex = require('./titleFilterRegex'); +const channelLayout = require('./tvShows/channelLayout'); const { GLOBAL_DEFAULT_SENTINEL, buildChannelPath, @@ -649,9 +650,11 @@ class ChannelSettingsModule { * Update channel settings * @param {string} channelId - Channel ID * @param {Object} settings - Settings to update { sub_folder?, video_quality? } + * @param {Object} [options] + * @param {() => boolean} [options.isDownloadRunning] - Refuses switching between Videos and TV while true * @returns {Promise<Object>} - Updated settings and move result */ - async updateChannelSettings(channelId, settings) { + async updateChannelSettings(channelId, settings, options = {}) { const channel = await Channel.findOne({ where: { channel_id: channelId } }); @@ -828,6 +831,14 @@ class ChannelSettingsModule { // Check if subfolder changed const subFolderChanged = settings.sub_folder !== undefined && oldSubFolder !== newSubFolder; + // TV layout: refuse changes that would mix layouts in a folder (throws with .status) + const layoutChange = await channelLayout.checkChannelSettingsChange({ + channel, + newSubFolder: settings.sub_folder !== undefined ? newSubFolder : undefined, + newAudioFormat: settings.audio_format, + isDownloadRunning: options.isDownloadRunning, + }); + // Prepare update payload const updateData = {}; if (settings.sub_folder !== undefined) { @@ -917,8 +928,10 @@ class ChannelSettingsModule { // Move the channel folder if subfolder changed // If this fails, we'll roll back the database change + // A change involving a TV folder has no files to move (checked above); + // the channel's show is created or pointed at the new folder instead. let moveResult = null; - if (subFolderChanged) { + if (subFolderChanged && !layoutChange.involvesTv) { try { moveResult = await this.moveChannelFolder(updatedChannel, oldSubFolder, newSubFolder); } catch (moveError) { @@ -943,6 +956,24 @@ class ChannelSettingsModule { await subfolderModule.register(newSubFolder); } + // A show left at the old location would keep receiving episodes (its stored + // location wins), so a failed show update rolls the folder change back. + if (subFolderChanged && layoutChange.involvesTv) { + try { + await channelLayout.applyChannelFolderChange({ + channel: updatedChannel, previousSubFolder: oldSubFolder, change: layoutChange, + }); + } catch (showError) { + logger.error({ err: showError, channelId }, 'Could not update the channel\'s TV show; rolling back the folder change'); + try { + await updatedChannel.update({ sub_folder: oldSubFolder }); + } catch (rollbackError) { + logger.error({ err: rollbackError, channelId }, 'Could not roll back the folder change after a TV show update failure'); + } + throw showError; + } + } + // Keep the channel .m3u in sync without ever failing the save. Audio format // changes the mp3-vs-video entry preference, so it regenerates too. Compare // values, not payload presence: the dialog always sends every field. diff --git a/server/modules/download/__tests__/downloadSettingsResolver.test.js b/server/modules/download/__tests__/downloadSettingsResolver.test.js index 5ae7e3b25..bf3b82a4d 100644 --- a/server/modules/download/__tests__/downloadSettingsResolver.test.js +++ b/server/modules/download/__tests__/downloadSettingsResolver.test.js @@ -154,3 +154,59 @@ describe('resolveFinalSubfolder', () => { expect(resolver.resolveFinalSubfolder({ hardOverride: null, channelRecord: null, softFallback: null, globalDefault: 'GD' })).toBe('GD'); }); }); + +describe('predictFinalSubfolder', () => { + test('dialog override wins over the channel', () => { + expect(resolver.predictFinalSubfolder({ + override: { subfolder: 'Dialog' }, channel: { sub_folder: 'Kids' }, playlist: {}, globalDefault: 'GD', + })).toBe('Dialog'); + }); + + test('resolves a channel on the global default through the default subfolder', () => { + expect(resolver.predictFinalSubfolder({ + override: {}, channel: { sub_folder: GLOBAL_DEFAULT_SENTINEL }, playlist: {}, globalDefault: 'GD', + })).toBe('GD'); + }); + + test('an untracked channel falls back to the playlist default', () => { + expect(resolver.predictFinalSubfolder({ + override: {}, channel: null, playlist: { default_sub_folder: 'PL' }, globalDefault: 'GD', + })).toBe('PL'); + }); + + test('a playlist saving to root keeps untracked videos out of the global default', () => { + expect(resolver.predictFinalSubfolder({ + override: {}, channel: null, playlist: { default_sub_folder: null }, globalDefault: 'GD', + })).toBeNull(); + }); + + test('nothing in context means the global default', () => { + expect(resolver.predictFinalSubfolder({ override: {}, channel: null, playlist: {}, globalDefault: 'GD' })).toBe('GD'); + }); +}); + +describe('coerceAudioFormatForLayout', () => { + const layoutOf = (folder) => (folder === 'TV' || folder === '' ? 'tv' : 'videos'); + + test('downgrades MP3 Only to video for a TV subfolder', () => { + expect(resolver.coerceAudioFormatForLayout({ audioFormat: 'mp3_only', subfolder: 'TV', layoutOf })).toBeNull(); + }); + + test('downgrades Video + MP3 to video for a TV subfolder', () => { + expect(resolver.coerceAudioFormatForLayout({ audioFormat: 'video_mp3', subfolder: 'TV', layoutOf })).toBeNull(); + }); + + test('downgrades MP3 for the main folder when it is TV', () => { + expect(resolver.coerceAudioFormatForLayout({ audioFormat: 'mp3_only', subfolder: null, layoutOf })).toBeNull(); + }); + + test('keeps MP3 for a videos subfolder', () => { + expect(resolver.coerceAudioFormatForLayout({ audioFormat: 'mp3_only', subfolder: 'Music', layoutOf })).toBe('mp3_only'); + }); + + test('leaves video-only alone without consulting layouts', () => { + const spy = jest.fn(layoutOf); + expect(resolver.coerceAudioFormatForLayout({ audioFormat: null, subfolder: 'TV', layoutOf: spy })).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/server/modules/download/downloadSettingsResolver.js b/server/modules/download/downloadSettingsResolver.js index 9ef3426e0..447da6a96 100644 --- a/server/modules/download/downloadSettingsResolver.js +++ b/server/modules/download/downloadSettingsResolver.js @@ -1,5 +1,6 @@ const configModule = require('../configModule'); const { resolveEffectiveSubfolder, ROOT_SENTINEL } = require('../filesystem'); +const { LAYOUT_TV, isMp3Format } = require('../tvShows/constants'); const DEFAULT_RESOLUTION = '1080'; @@ -103,6 +104,41 @@ class DownloadSettingsResolver { } return globalDefault || null; } + + /** + * Pre-download estimate of the finalize-time subfolder, for decisions that + * depend on the destination's layout: resolveFinalSubfolder's precedence, fed + * the channel the grouper could attribute (null when untracked) and the + * routing directives it forwards. The finalizer may still know better once + * the .info.json names the real channel. + * + * @returns {string|null} Subfolder name, or null for the main folder + */ + predictFinalSubfolder({ override = {}, channel = null, playlist = {}, globalDefault = null } = {}) { + const directives = this.buildRoutingDirectives({ override, playlist }); + return this.resolveFinalSubfolder({ + hardOverride: directives.subfolderOverride || null, + channelRecord: channel, + softFallback: directives.subfolderFallback || null, + globalDefault, + }); + } + + /** + * TV folders are video-only. Saved settings that would send MP3 there are + * refused at save time; a download-time MP3 type (dialog override, playlist + * default) whose destination has the TV layout is downgraded to video-only. + * + * @param {Object} params + * @param {string|null} params.audioFormat - Resolved download type (null = video-only) + * @param {string|null} params.subfolder - Destination subfolder, null for the main folder + * @param {(libraryFolder: string) => string} params.layoutOf - Layout of a library folder ('' = main) + * @returns {string|null} + */ + coerceAudioFormatForLayout({ audioFormat, subfolder, layoutOf }) { + if (!isMp3Format(audioFormat)) return audioFormat; + return layoutOf(subfolder || '') === LAYOUT_TV ? null : audioFormat; + } } module.exports = new DownloadSettingsResolver(); diff --git a/server/modules/filesystem/__tests__/fileOperations.replaceFile.test.js b/server/modules/filesystem/__tests__/fileOperations.replaceFile.test.js new file mode 100644 index 000000000..352c99cf5 --- /dev/null +++ b/server/modules/filesystem/__tests__/fileOperations.replaceFile.test.js @@ -0,0 +1,95 @@ +// Real filesystem: the replacement must never leave a window in which the +// destination is missing, which a mocked mover could not show. +jest.mock('../../../logger', () => ({ debug: jest.fn(), warn: jest.fn(), info: jest.fn(), error: jest.fn() })); + +const fs = require('fs'); +const fsExtra = require('fs-extra'); +const os = require('os'); +const path = require('path'); +const { replaceFileWithRetries } = require('../fileOperations'); + +function enospc() { + return Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }); +} + +// Mirrors fs-extra's move across filesystems: with overwrite it removes the +// destination first, then the copy fails. +function failingMove(beforeFailing = () => {}) { + return jest.spyOn(fsExtra, 'move').mockImplementation(async (from, to, opts) => { + if (opts && opts.overwrite) await fsExtra.remove(to); + beforeFailing(to); + throw enospc(); + }); +} + +describe('filesystem/fileOperations replaceFileWithRetries', () => { + let root; + let src; + let dest; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'replace-file-')); + src = path.join(root, 'incoming', 'S01E01 [abcdefghijk].mp4'); + dest = path.join(root, 'Season 01', 'S01E01 [abcdefghijk].mp4'); + fs.mkdirSync(path.dirname(src), { recursive: true }); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(src, 'new'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('replaces the destination with the source and removes the source', async () => { + fs.writeFileSync(dest, 'old'); + + await replaceFileWithRetries(src, dest); + + expect(fs.readFileSync(dest, 'utf8')).toBe('new'); + expect(fs.existsSync(src)).toBe(false); + }); + + it('creates a destination that does not exist yet', async () => { + await replaceFileWithRetries(src, dest); + + expect(fs.readFileSync(dest, 'utf8')).toBe('new'); + }); + + it('leaves no staging file behind after a replacement', async () => { + fs.writeFileSync(dest, 'old'); + + await replaceFileWithRetries(src, dest); + + expect(fs.readdirSync(path.dirname(dest))).toEqual([path.basename(dest)]); + }); + + it('keeps the destination when the transfer fails', async () => { + fs.writeFileSync(dest, 'old'); + failingMove(); + + await expect(replaceFileWithRetries(src, dest, { retries: 0 })).rejects.toThrow('no space left on device'); + + expect(fs.readFileSync(dest, 'utf8')).toBe('old'); + expect(fs.readFileSync(src, 'utf8')).toBe('new'); + }); + + it('removes a partial staging file when the transfer fails', async () => { + fs.writeFileSync(dest, 'old'); + failingMove((to) => fs.writeFileSync(to, 'partial')); + + await expect(replaceFileWithRetries(src, dest, { retries: 0 })).rejects.toThrow('no space left on device'); + + expect(fs.readdirSync(path.dirname(dest))).toEqual([path.basename(dest)]); + }); + + it('keeps the destination when the final rename fails', async () => { + fs.writeFileSync(dest, 'old'); + jest.spyOn(fs.promises, 'rename').mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' })); + + await expect(replaceFileWithRetries(src, dest)).rejects.toThrow('permission denied'); + + expect(fs.readFileSync(dest, 'utf8')).toBe('old'); + expect(fs.readdirSync(path.dirname(dest))).toEqual([path.basename(dest)]); + }); +}); diff --git a/server/modules/filesystem/fileOperations.js b/server/modules/filesystem/fileOperations.js index 23f9676b2..b279829c6 100644 --- a/server/modules/filesystem/fileOperations.js +++ b/server/modules/filesystem/fileOperations.js @@ -73,6 +73,34 @@ async function moveWithRetries(src, dest, { retries = 5, delayMs = 200, overwrit } } +// Staging name of a file being replaced; yt-dlp's in-progress suffix, which +// media servers and the library scans ignore. +const REPLACE_STAGING_SUFFIX = '.part'; + +/** + * Move a file over an existing destination without a moment in which the + * destination is missing. fs-extra's overwrite removes the destination before + * it copies across filesystems, so a copy that fails (ENOSPC on a NAS) would + * lose the file being replaced. The data is moved to a sibling staging file + * first and an atomic rename then replaces the destination; on failure the + * staging file is removed and the destination is untouched. + * + * @param {string} src - Source file path + * @param {string} dest - Destination file path (may not exist yet) + * @param {Object} [options] - moveWithRetries options (retries, delayMs) + * @returns {Promise<void>} + */ +async function replaceFileWithRetries(src, dest, options = {}) { + const staging = `${dest}${REPLACE_STAGING_SUFFIX}`; + try { + await moveWithRetries(src, staging, { ...options, overwrite: true }); + await fsPromises.rename(staging, dest); + } catch (err) { + await safeRemove(staging); + throw err; + } +} + /** * Remove a file or directory, ignoring ENOENT errors (already deleted) * Safe for cleanup operations where the file may already be gone @@ -255,6 +283,7 @@ async function isDirectory(dirPath) { module.exports = { sleep, moveWithRetries, + replaceFileWithRetries, safeRemove, safeCopy, copySyncWithFallback, diff --git a/server/modules/m3uGenerator.js b/server/modules/m3uGenerator.js index 37d038853..a51b310d6 100644 --- a/server/modules/m3uGenerator.js +++ b/server/modules/m3uGenerator.js @@ -9,6 +9,7 @@ const { resolveChannelFolderName, } = require('./filesystem/pathBuilder'); const configModule = require('./configModule'); +const channelFolders = require('./tvShows/channelFolders'); const M3U_FOLDER_NAME = '__playlists__'; // Empty marker file that tells the Jellyfin and Emby library scanners to skip @@ -106,6 +107,11 @@ class M3uGenerator { if (!channel.m3u_enabled || !channel.enabled) { return false; } + // Jellyfin and Emby ignore .m3u files in a Shows library, and Kodi may + // scan one as a video. + if (await channelFolders.isTvChannel(channel)) { + return false; + } if (!resolveChannelFolderName(channel)) { logger.warn({ channelId }, 'generateChannelM3U: channel has no folder name'); return false; diff --git a/server/modules/manualDownloadGrouper.js b/server/modules/manualDownloadGrouper.js index 45b123017..0095478a3 100644 --- a/server/modules/manualDownloadGrouper.js +++ b/server/modules/manualDownloadGrouper.js @@ -1,9 +1,11 @@ const Video = require('../models/video'); const ChannelVideo = require('../models/channelvideo'); const configModule = require('./configModule'); +const logger = require('../logger'); const playlistDownloadGrouper = require('./playlistDownloadGrouper'); const downloadSettingsResolver = require('./download/downloadSettingsResolver'); const youtubeUrlParser = require('./youtubeUrlParser'); +const { getLayoutResolver } = require('./tvShows/libraryLayouts'); /** * Buckets manually pasted URLs by their resolved pre-download settings @@ -15,7 +17,9 @@ const youtubeUrlParser = require('./youtubeUrlParser'); * settings, mirroring finalize-time routing. File structure and routing * settings (subfolder, rating) are intentionally not resolved here; they * resolve per-video at finalize. See downloadSettingsResolver and - * videoDownloadPostProcessFiles. + * videoDownloadPostProcessFiles. The one exception is the destination's + * layout: TV folders are video-only, so an MP3 type is downgraded for URLs + * whose predicted folder is TV. */ class ManualDownloadGrouper { extractYoutubeId(url) { @@ -53,9 +57,13 @@ class ManualDownloadGrouper { [claimed[id], ownChannelById.get(id), ...(listersById.get(id) || [])].filter(Boolean); const allCandidates = [...new Set(ids.flatMap(candidatesFor))]; - const channelMap = await playlistDownloadGrouper.loadChannelMap(allCandidates); + const [channelMap, layoutOf] = await Promise.all([ + playlistDownloadGrouper.loadChannelMap(allCandidates), + getLayoutResolver(), + ]); const groups = new Map(); + let downgraded = 0; for (const url of urls) { const id = idByUrl.get(url); const ownerId = id ? candidatesFor(id).find((candidate) => channelMap.has(candidate)) : undefined; @@ -66,12 +74,11 @@ class ManualDownloadGrouper { playlist: {}, config: configModule.config, }); - // Preserve the executor's audio contract: an explicitly provided - // audioFormat wins even when it is null (= force video-only). The - // generic resolver treats null as "no override". - const audioFormat = overrideSettings.audioFormat !== undefined - ? overrideSettings.audioFormat - : resolved.audioFormat; + const audio = playlistDownloadGrouper.resolveAudioFormat({ + overrideSettings, resolved, channel, playlist: {}, layoutOf, + }); + if (audio.downgraded) downgraded += 1; + const { audioFormat } = audio; const { resolution } = resolved; const key = JSON.stringify({ resolution, audioFormat }); if (!groups.has(key)) { @@ -79,6 +86,9 @@ class ManualDownloadGrouper { } groups.get(key).urls.push(url); } + if (downgraded > 0) { + logger.warn({ downgraded }, 'MP3 download type downgraded to video for URLs saved to TV folders'); + } return Array.from(groups.values()); } } diff --git a/server/modules/playlistDownloadGrouper.js b/server/modules/playlistDownloadGrouper.js index ea2ff5ad7..796e6c25e 100644 --- a/server/modules/playlistDownloadGrouper.js +++ b/server/modules/playlistDownloadGrouper.js @@ -1,6 +1,8 @@ const Channel = require('../models/channel'); const configModule = require('./configModule'); +const logger = require('../logger'); const downloadSettingsResolver = require('./download/downloadSettingsResolver'); +const { getLayoutResolver } = require('./tvShows/libraryLayouts'); /** * Buckets playlist videos by their resolved COMMAND settings (resolution, @@ -8,6 +10,8 @@ const downloadSettingsResolver = require('./download/downloadSettingsResolver'); * Routing settings (subfolder, rating) are intentionally not resolved here: they * are applied per-video at finalize time by the post-processor, which reads the * real channel from the downloaded .info.json. See downloadSettingsResolver. + * The one exception is the destination's layout: TV folders are video-only, so + * an MP3 type is downgraded for videos whose predicted folder is TV. */ class PlaylistDownloadGrouper { async loadChannelMap(channelIds) { @@ -19,16 +23,43 @@ class PlaylistDownloadGrouper { // through to playlist -> global. const channels = await Channel.findAll({ where: { channel_id: ids, enabled: true }, - attributes: ['channel_id', 'video_quality', 'audio_format', 'skip_video_folder'], + attributes: ['channel_id', 'video_quality', 'audio_format', 'skip_video_folder', 'sub_folder'], }); const map = new Map(); channels.forEach((c) => map.set(c.channel_id, c)); return map; } + /** + * Download type for one video: the executor's audio contract (an explicitly + * provided audioFormat wins even when it is null = force video-only; the + * generic resolver treats null as "no override"), then the TV downgrade. + * @returns {{audioFormat: string|null, downgraded: boolean}} + */ + resolveAudioFormat({ overrideSettings, resolved, channel, playlist, layoutOf }) { + const requested = overrideSettings.audioFormat !== undefined + ? overrideSettings.audioFormat + : resolved.audioFormat; + const audioFormat = downloadSettingsResolver.coerceAudioFormatForLayout({ + audioFormat: requested, + subfolder: downloadSettingsResolver.predictFinalSubfolder({ + override: overrideSettings, + channel, + playlist, + globalDefault: configModule.getDefaultSubfolder(), + }), + layoutOf, + }); + return { audioFormat, downgraded: audioFormat !== requested }; + } + async buildGroups(playlist, entries, overrideSettings = {}) { - const channelMap = await this.loadChannelMap(entries.map((e) => e.channel_id)); + const [channelMap, layoutOf] = await Promise.all([ + this.loadChannelMap(entries.map((e) => e.channel_id)), + getLayoutResolver(), + ]); const groups = new Map(); + let downgraded = 0; for (const entry of entries) { const channel = entry.channel_id ? channelMap.get(entry.channel_id) || null : null; @@ -38,12 +69,9 @@ class PlaylistDownloadGrouper { playlist, config: configModule.config, }); - // Preserve the executor's audio contract: an explicitly provided - // audioFormat wins even when it is null (= force video-only). The - // generic resolver treats null as "no override". - const audioFormat = overrideSettings.audioFormat !== undefined - ? overrideSettings.audioFormat - : resolved.audioFormat; + const audio = this.resolveAudioFormat({ overrideSettings, resolved, channel, playlist, layoutOf }); + if (audio.downgraded) downgraded += 1; + const { audioFormat } = audio; const { resolution, skipVideoFolder } = resolved; const key = JSON.stringify({ resolution, audioFormat, skipVideoFolder }); @@ -53,6 +81,9 @@ class PlaylistDownloadGrouper { groups.get(key).youtubeIds.push(entry.youtube_id); } + if (downgraded > 0) { + logger.warn({ playlistId: playlist.playlist_id, downgraded }, 'MP3 download type downgraded to video for videos saved to TV folders'); + } return Array.from(groups.values()); } } diff --git a/server/modules/subfolderModule.js b/server/modules/subfolderModule.js index 0bfa0ede2..2db338c35 100644 --- a/server/modules/subfolderModule.js +++ b/server/modules/subfolderModule.js @@ -3,9 +3,13 @@ const logger = require('../logger'); const Subfolder = require('../models/subfolder'); const Channel = require('../models/channel'); const Playlist = require('../models/playlist'); +const TvShow = require('../models/tvshow'); +const VideoClassification = require('../models/videoclassification'); const configModule = require('./configModule'); -const { buildSubfolderSegment, directoryHasFiles, removeIfEmpty } = require('./filesystem'); +const { buildSubfolderSegment, directoryHasFiles, removeIfEmpty, resolveEffectiveSubfolder } = require('./filesystem'); const { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } = require('./filesystem/constants'); +const { getLayoutResolver } = require('./tvShows/libraryLayouts'); +const { LAYOUT_TV } = require('./tvShows/constants'); const SENTINELS = new Set([GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL]); @@ -22,7 +26,7 @@ function makeError(message, status) { /** * Single rule for why a subfolder can't be deleted, shared by delete() and * getUsage() so they can't drift. - * @param {{channels:number, playlists:number, isDefault:boolean, plexMapped:boolean, hasFiles:boolean}} usage + * @param {{channels:number, playlists:number, shows:number, isDefault:boolean, plexMapped:boolean, hasFiles:boolean}} usage * @returns {string|null} reason, or null when the subfolder is safe to delete */ function deletionBlockReason(usage) { @@ -32,6 +36,9 @@ function deletionBlockReason(usage) { if (usage.playlists > 0) { return `Subfolder is in use by ${usage.playlists} playlist(s)`; } + if (usage.shows > 0) { + return `Subfolder holds ${usage.shows} TV show(s) with numbered episodes`; + } if (usage.isDefault) { return 'Subfolder is the global default and cannot be deleted'; } @@ -44,6 +51,56 @@ function deletionBlockReason(usage) { return null; } +/** + * Channels whose current folder has the TV layout, among the given ids. A + * tracked channel that moved back to a videos folder keeps its show row (for + * the way back) but no longer uses the show's folder. + * @param {string[]} channelIds + * @returns {Promise<{isTracked: (id: string) => boolean, isTv: (id: string) => boolean}>} + */ +async function tvChannelLookup(channelIds) { + const [channels, layoutOf] = await Promise.all([ + Channel.findAll({ where: { channel_id: channelIds }, attributes: ['channel_id', 'sub_folder'] }), + getLayoutResolver(), + ]); + const defaultSubfolder = configModule.getDefaultSubfolder(); + const tvByChannel = new Map(channels.map((channel) => [ + channel.channel_id, + layoutOf(resolveEffectiveSubfolder(channel.sub_folder, defaultSubfolder) || '') === LAYOUT_TV, + ])); + return { isTracked: (id) => tvByChannel.has(id), isTv: (id) => tvByChannel.get(id) === true }; +} + +/** + * TV shows with numbered episodes, per library folder (lowercased). Their + * numbers are kept even when files are deleted, so a re-download returns to + * the same episode; a show without any only pins a location. A show whose + * tracked channel has since moved to a videos folder is not counted; an + * untracked channel's show always is, since the folder is all it has. + * @returns {Promise<Map<string, number>>} + */ +async function tallyNumberedShows() { + const rows = await VideoClassification.findAll({ + attributes: ['show_id'], + group: ['show_id'], + raw: true, + }); + const showIds = rows.map((row) => row.show_id); + const counts = new Map(); + if (showIds.length === 0) return counts; + const shows = await TvShow.findAll({ + where: { id: showIds, retired_at: null }, + attributes: ['library_folder', 'channel_id'], + }); + const channels = await tvChannelLookup([...new Set(shows.map((show) => show.channel_id).filter(Boolean))]); + for (const show of shows) { + if (channels.isTracked(show.channel_id) && !channels.isTv(show.channel_id)) continue; + const key = String(show.library_folder || '').trim().toLowerCase(); + if (key) counts.set(key, (counts.get(key) || 0) + 1); + } + return counts; +} + class SubfolderModule { /** * Names currently mapped to a Plex library (clean, non-null only). @@ -104,16 +161,17 @@ class SubfolderModule { * Compute the usage of a single subfolder name (per-name queries). Used by * delete() where only one name is in play. * @param {string} clean - Clean subfolder name (no __ prefix) - * @returns {Promise<{channels:number, playlists:number, isDefault:boolean, plexMapped:boolean, hasFiles:boolean}>} + * @returns {Promise<{channels:number, playlists:number, shows:number, isDefault:boolean, plexMapped:boolean, hasFiles:boolean}>} */ async _usageForName(clean) { const channels = await Channel.count({ where: { sub_folder: clean } }); const playlists = await Playlist.count({ where: { default_sub_folder: clean } }); + const shows = (await tallyNumberedShows()).get(clean.toLowerCase()) || 0; const def = configModule.getDefaultSubfolder(); const isDefault = !!(def && def.toLowerCase() === clean.toLowerCase()); const plexMapped = this._plexMappingSubfolders().some((s) => s.toLowerCase() === clean.toLowerCase()); const hasFiles = await directoryHasFiles(path.join(configModule.directoryPath, buildSubfolderSegment(clean))); - return { channels, playlists, isDefault, plexMapped, hasFiles }; + return { channels, playlists, shows, isDefault, plexMapped, hasFiles }; } /** @@ -135,9 +193,10 @@ class SubfolderModule { add(configModule.getDefaultSubfolder()); this._plexMappingSubfolders().forEach(add); - const [channelTally, playlistTally] = await Promise.all([ + const [channelTally, playlistTally, showTally] = await Promise.all([ this._tally(Channel, 'sub_folder'), this._tally(Playlist, 'default_sub_folder'), + tallyNumberedShows(), ]); const def = configModule.getDefaultSubfolder(); const defaultKey = def ? def.trim().toLowerCase() : null; @@ -151,6 +210,7 @@ class SubfolderModule { const usage = { channels: channelTally.get(key) || 0, playlists: playlistTally.get(key) || 0, + shows: showTally.get(key) || 0, isDefault: defaultKey === key, plexMapped: plexKeys.has(key), hasFiles, diff --git a/server/modules/tvShows/__tests__/channelFolders.test.js b/server/modules/tvShows/__tests__/channelFolders.test.js new file mode 100644 index 000000000..68912eb94 --- /dev/null +++ b/server/modules/tvShows/__tests__/channelFolders.test.js @@ -0,0 +1,114 @@ +jest.mock('../../configModule', () => ({ + getDefaultSubfolder: jest.fn(), + directoryPath: '/data', +})); +jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn(), listTvFolders: jest.fn() })); +jest.mock('../showStore', () => ({ findChannelShow: jest.fn() })); + +const path = require('path'); +const { Op } = require('sequelize'); + +describe('channelFolders', () => { + let channelFolders; + let configModule; + let libraryLayouts; + let showStore; + const layoutOf = (folder) => (folder === 'TV' ? 'tv' : 'videos'); + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + configModule = require('../../configModule'); + libraryLayouts = require('../libraryLayouts'); + showStore = require('../showStore'); + channelFolders = require('../channelFolders'); + configModule.getDefaultSubfolder.mockReturnValue(null); + libraryLayouts.getLayoutResolver.mockResolvedValue(layoutOf); + showStore.findChannelShow.mockResolvedValue(null); + }); + + describe('effectiveLibraryFolder', () => { + it('resolves the global default sentinel through config', () => { + configModule.getDefaultSubfolder.mockReturnValue('TV'); + expect(channelFolders.effectiveLibraryFolder('##USE_GLOBAL_DEFAULT##')).toBe('TV'); + }); + + it('returns the main folder for root', () => { + expect(channelFolders.effectiveLibraryFolder('##ROOT##')).toBe(''); + }); + }); + + describe('resolveChannelDirectory', () => { + const channel = { channel_id: 'UC1', sub_folder: 'Kids', folder_name: 'Mark Rober' }; + + it('returns the channel folder of a videos-layout channel', async () => { + await expect(channelFolders.resolveChannelDirectory(channel)).resolves.toEqual({ + layout: 'videos', dir: path.join('/data', '__Kids', 'Mark Rober'), + }); + }); + + it('returns the pinned show folder of a TV channel', async () => { + showStore.findChannelShow.mockResolvedValue({ library_folder: 'TV', folder_name: 'Pinned Name' }); + await expect(channelFolders.resolveChannelDirectory({ ...channel, sub_folder: 'TV' })).resolves.toEqual({ + layout: 'tv', dir: path.join('/data', '__TV', 'Pinned Name'), + }); + }); + + it('returns no folder for a TV channel without a show yet', async () => { + await expect(channelFolders.resolveChannelDirectory({ ...channel, sub_folder: 'TV' })).resolves.toEqual({ + layout: 'tv', dir: null, + }); + }); + + it('uses a passed resolver instead of reading layouts again', async () => { + await channelFolders.resolveChannelDirectory(channel, { layoutOf }); + expect(libraryLayouts.getLayoutResolver).not.toHaveBeenCalled(); + }); + }); + + describe('showDirectory', () => { + it('places a main-folder show directly under the downloads folder', () => { + expect(channelFolders.showDirectory({ library_folder: '', folder_name: 'Show' })).toBe(path.join('/data', 'Show')); + }); + }); + + describe('tvChannelCondition', () => { + it('is null when no folder is TV', async () => { + libraryLayouts.listTvFolders.mockResolvedValue([]); + await expect(channelFolders.tvChannelCondition()).resolves.toBeNull(); + }); + + it('matches channels in a TV subfolder', async () => { + libraryLayouts.listTvFolders.mockResolvedValue(['TV']); + await expect(channelFolders.tvChannelCondition()).resolves.toEqual({ + [Op.or]: [{ sub_folder: { [Op.in]: ['TV'] } }], + }); + }); + + it('matches channels on the global default when the default is TV', async () => { + configModule.getDefaultSubfolder.mockReturnValue('tv'); + libraryLayouts.listTvFolders.mockResolvedValue(['TV']); + const condition = await channelFolders.tvChannelCondition(); + expect(condition[Op.or]).toContainEqual({ sub_folder: '##USE_GLOBAL_DEFAULT##' }); + }); + + it('matches root channels, and default channels without a default subfolder, when the main folder is TV', async () => { + libraryLayouts.listTvFolders.mockResolvedValue(['']); + const condition = await channelFolders.tvChannelCondition(); + expect(condition[Op.or]).toEqual([ + { sub_folder: '##USE_GLOBAL_DEFAULT##' }, + { sub_folder: { [Op.or]: [null, '', '##ROOT##'] } }, + ]); + }); + }); + + describe('isTvChannel', () => { + it('is true for a channel whose folder is TV', async () => { + await expect(channelFolders.isTvChannel({ sub_folder: 'TV' })).resolves.toBe(true); + }); + + it('is false for a channel whose folder is videos', async () => { + await expect(channelFolders.isTvChannel({ sub_folder: 'Kids' })).resolves.toBe(false); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/channelLayout.test.js b/server/modules/tvShows/__tests__/channelLayout.test.js new file mode 100644 index 000000000..153a0838d --- /dev/null +++ b/server/modules/tvShows/__tests__/channelLayout.test.js @@ -0,0 +1,198 @@ +jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/video', () => ({ count: jest.fn() })); +jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn().mockResolvedValue([]) })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: '/data' })); +jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn(), listTvFolders: jest.fn() })); +jest.mock('../showStore', () => ({ + findChannelShow: jest.fn(), + createChannelShow: jest.fn(), + relocateChannelShow: jest.fn(), +})); +jest.mock('../../filesystem/showFolderCleanup', () => ({ cleanupOrphanShowFolder: jest.fn().mockResolvedValue([]) })); + +const path = require('path'); + +const LAYOUTS = { TV: 'tv', 'Kids TV': 'tv' }; +const layoutOf = (folder) => LAYOUTS[folder] || 'videos'; + +describe('channelLayout', () => { + let channelLayout; + let Video; + let configModule; + let libraryLayouts; + let showStore; + const channel = { channel_id: 'UC1', title: 'Mark Rober', folder_name: 'Mark Rober', sub_folder: 'Kids', audio_format: null }; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + Video = require('../../../models/video'); + configModule = require('../../configModule'); + libraryLayouts = require('../libraryLayouts'); + showStore = require('../showStore'); + channelLayout = require('../channelLayout'); + Video.count.mockResolvedValue(0); + configModule.getDefaultSubfolder.mockReturnValue('Kids'); + libraryLayouts.getLayoutResolver.mockResolvedValue(layoutOf); + libraryLayouts.listTvFolders.mockResolvedValue(['TV']); + showStore.findChannelShow.mockResolvedValue(null); + }); + + describe('checkChannelSettingsChange', () => { + const check = (overrides) => channelLayout.checkChannelSettingsChange({ channel, ...overrides }); + + it('allows a channel without downloads to switch to a TV folder', async () => { + await expect(check({ newSubFolder: 'TV' })).resolves.toMatchObject({ + oldFolder: 'Kids', newFolder: 'TV', oldLayout: 'videos', newLayout: 'tv', involvesTv: true, + }); + }); + + it('refuses an MP3 download type in a TV folder', async () => { + await expect(check({ newSubFolder: 'TV', newAudioFormat: 'mp3_only' })).rejects.toMatchObject({ status: 400 }); + }); + + it('refuses a channel with downloads switching layouts', async () => { + Video.count.mockResolvedValue(3); + await expect(check({ newSubFolder: 'TV' })).rejects.toMatchObject({ status: 409 }); + }); + + it('refuses a TV channel with downloads moving to another TV folder', async () => { + Video.count.mockResolvedValue(3); + await expect(check({ channel: { ...channel, sub_folder: 'TV' }, newSubFolder: 'Kids TV' })) + .rejects.toMatchObject({ status: 409 }); + }); + + it('refuses a switch while a download runs', async () => { + await expect(check({ newSubFolder: 'TV', isDownloadRunning: () => true })) + .rejects.toThrow(channelLayout.MESSAGES.running); + }); + + it('keeps allowing videos-folder moves for channels with downloads', async () => { + Video.count.mockResolvedValue(3); + await expect(check({ newSubFolder: 'Music' })).resolves.toMatchObject({ involvesTv: false }); + }); + }); + + describe('applyChannelFolderChange', () => { + const toTv = { oldFolder: 'Kids', newFolder: 'TV', oldLayout: 'videos', newLayout: 'tv' }; + + it('creates the show at the new folder and remembers the folder it left', async () => { + await channelLayout.applyChannelFolderChange({ channel, previousSubFolder: '##USE_GLOBAL_DEFAULT##', change: toTv }); + expect(showStore.createChannelShow).toHaveBeenCalledWith({ + channelId: 'UC1', + name: 'Mark Rober', + folderName: 'Mark Rober', + libraryFolder: 'TV', + previousVideosFolder: '##USE_GLOBAL_DEFAULT##', + }); + }); + + it.each([null, ''])('remembers a root channel (sub_folder %p) as the root folder', async (previousSubFolder) => { + await channelLayout.applyChannelFolderChange({ channel, previousSubFolder, change: toTv }); + expect(showStore.createChannelShow).toHaveBeenCalledWith(expect.objectContaining({ previousVideosFolder: '##ROOT##' })); + }); + + it('moves an existing show to the new TV folder', async () => { + const show = { library_folder: 'Kids TV', update: jest.fn() }; + showStore.findChannelShow.mockResolvedValue(show); + showStore.relocateChannelShow.mockResolvedValue(show); + await channelLayout.applyChannelFolderChange({ channel, previousSubFolder: 'Kids', change: toTv }); + expect(showStore.relocateChannelShow).toHaveBeenCalledWith(show, 'TV'); + }); + + describe('leaving TV for a videos folder', () => { + const toVideos = { oldFolder: 'TV', newFolder: 'Kids', oldLayout: 'tv', newLayout: 'videos' }; + let showFolderCleanup; + beforeEach(() => { showFolderCleanup = require('../../filesystem/showFolderCleanup'); }); + + it('keeps the show row and creates or moves nothing', async () => { + showStore.findChannelShow.mockResolvedValue({ library_folder: 'TV', folder_name: 'MR', update: jest.fn() }); + await expect(channelLayout.applyChannelFolderChange({ channel, previousSubFolder: 'TV', change: toVideos })).resolves.toBeNull(); + expect(showStore.createChannelShow).not.toHaveBeenCalled(); + expect(showStore.relocateChannelShow).not.toHaveBeenCalled(); + }); + + it('removes the show folder left behind when only metadata and art remain', async () => { + showStore.findChannelShow.mockResolvedValue({ library_folder: 'TV', folder_name: 'MR' }); + await channelLayout.applyChannelFolderChange({ channel, previousSubFolder: 'TV', change: toVideos }); + expect(showFolderCleanup.cleanupOrphanShowFolder).toHaveBeenCalledWith(path.join('/data', '__TV', 'MR')); + }); + + it('cleans nothing for a channel without a show', async () => { + await channelLayout.applyChannelFolderChange({ channel, previousSubFolder: 'TV', change: toVideos }); + expect(showFolderCleanup.cleanupOrphanShowFolder).not.toHaveBeenCalled(); + }); + + it('leaves the folder alone when the channel moves between videos folders', async () => { + const change = { oldFolder: 'Kids', newFolder: 'Music', oldLayout: 'videos', newLayout: 'videos' }; + await channelLayout.applyChannelFolderChange({ channel, previousSubFolder: 'Kids', change }); + expect(showStore.findChannelShow).not.toHaveBeenCalled(); + }); + }); + }); + + describe('resolveLayoutTarget', () => { + const target = (layout, folder) => channelLayout.resolveLayoutTarget({ channel, layout, folder }); + + it('uses a chosen TV folder', async () => { + await expect(target('tv', 'Kids TV')).resolves.toBe('Kids TV'); + }); + + it('rejects a chosen folder with the other layout', async () => { + await expect(target('tv', 'Kids')).rejects.toMatchObject({ status: 400 }); + }); + + it('uses the global default when it is a TV folder', async () => { + configModule.getDefaultSubfolder.mockReturnValue('TV'); + await expect(target('tv')).resolves.toBe('##USE_GLOBAL_DEFAULT##'); + }); + + it('uses the only TV folder', async () => { + await expect(target('tv')).resolves.toBe('TV'); + }); + + it('asks for a choice when there are several TV folders', async () => { + libraryLayouts.listTvFolders.mockResolvedValue(['Kids TV', 'TV']); + await expect(target('tv')).rejects.toThrow('Choose a TV folder.'); + }); + + it('asks for setup when there is no TV folder', async () => { + libraryLayouts.listTvFolders.mockResolvedValue([]); + await expect(target('tv')).rejects.toThrow('Set up a TV folder first.'); + }); + + it('returns to the folder the channel left for TV', async () => { + showStore.findChannelShow.mockResolvedValue({ previous_videos_folder: 'Music' }); + await expect(target('videos')).resolves.toBe('Music'); + }); + + it('falls back to the global default for videos', async () => { + await expect(target('videos')).resolves.toBe('##USE_GLOBAL_DEFAULT##'); + }); + + it('rejects an unknown layout', async () => { + await expect(target('shows')).rejects.toMatchObject({ status: 400 }); + }); + }); + + describe('getChannelTvState', () => { + it('describes a TV channel with its show folder', async () => { + showStore.findChannelShow.mockResolvedValue({ name: 'Mark Rober', folder_name: 'MR', library_folder: 'TV' }); + const state = await channelLayout.getChannelTvState({ ...channel, sub_folder: 'TV' }); + expect(state).toMatchObject({ + layout: 'tv', + libraryFolder: 'TV', + show: { name: 'Mark Rober', folderName: 'MR', libraryFolder: 'TV' }, + tvFolders: ['TV'], + canSwitch: true, + }); + }); + + it('reports a channel with downloads as unable to switch', async () => { + Video.count.mockResolvedValue(1); + const state = await channelLayout.getChannelTvState(channel); + expect(state).toMatchObject({ layout: 'videos', show: null, canSwitch: false }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/episodeAllocator.test.js b/server/modules/tvShows/__tests__/episodeAllocator.test.js new file mode 100644 index 000000000..e3c20379a --- /dev/null +++ b/server/modules/tvShows/__tests__/episodeAllocator.test.js @@ -0,0 +1,117 @@ +jest.mock('../../../models/videoclassification', () => ({ + findByPk: jest.fn(), + findAll: jest.fn(), + create: jest.fn(), +})); +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +const YOUTUBE_ID = 'abcdefghijk'; +const SHOW = { id: 4, channel_id: 'UC123' }; +// 2024-03-15 12:00:00 UTC +const TIMESTAMP = Date.UTC(2024, 2, 15, 12, 0, 0) / 1000; + +function uniqueError() { + const err = new Error('Validation error'); + err.name = 'SequelizeUniqueConstraintError'; + return err; +} + +describe('episodeAllocator.assignDateEpisode', () => { + let VideoClassification; + let assignDateEpisode; + + const assign = (info = { timestamp: TIMESTAMP, title: 'Big Build' }, extra = {}) => + assignDateEpisode({ show: SHOW, youtubeId: YOUTUBE_ID, channelId: 'UC123', info, ...extra }); + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + VideoClassification = require('../../../models/videoclassification'); + ({ assignDateEpisode } = require('../episodeAllocator')); + VideoClassification.findByPk.mockResolvedValue(null); + VideoClassification.findAll.mockResolvedValue([]); + VideoClassification.create.mockImplementation(async (values) => values); + }); + + it('numbers a new video by its upload time', async () => { + await expect(assign()).resolves.toEqual({ + season: 2024, + episode: 3151200, + dateNumbered: true, + episodeTitle: 'Big Build', + fileStem: `S2024E03151200 - Big Build [${YOUTUBE_ID}]`, + }); + }); + + it('stores the assignment with its sources', async () => { + await assign(); + expect(VideoClassification.create).toHaveBeenCalledWith(expect.objectContaining({ + youtube_id: YOUTUBE_ID, + channel_id: 'UC123', + show_id: 4, + status: 'assigned', + season: 2024, + episode: 3151200, + source: 'date', + timestamp_source: 'timestamp', + file_stem: `S2024E03151200 - Big Build [${YOUTUBE_ID}]`, + })); + }); + + it('takes the next free number when the upload minute is taken', async () => { + VideoClassification.findAll.mockResolvedValue([{ episode: 3151200 }, { episode: 3151201 }]); + const result = await assign(); + expect(result.episode).toBe(3151202); + }); + + it('falls back to upload_date and records it', async () => { + await assign({ upload_date: '20240315', title: 'Big Build' }); + expect(VideoClassification.create).toHaveBeenCalledWith(expect.objectContaining({ + episode: 3150000, + timestamp_source: 'upload_date', + })); + }); + + it('numbers from the current time when the info has no release time', async () => { + const now = () => Date.UTC(2025, 0, 2, 3, 4, 0); + const result = await assign({ title: 'Big Build' }, { now }); + expect([result.season, result.episode]).toEqual([2025, 1020304]); + }); + + it('reuses a stored assignment in the same show without writing', async () => { + VideoClassification.findByPk.mockResolvedValue({ + show_id: 4, status: 'assigned', season: 2023, episode: 1010000, + file_stem: `S2023E01010000 - Old Title [${YOUTUBE_ID}]`, episode_title: 'Old Title', + }); + const result = await assign(); + expect(result).toEqual({ + season: 2023, + episode: 1010000, + dateNumbered: true, + episodeTitle: 'Old Title', + fileStem: `S2023E01010000 - Old Title [${YOUTUBE_ID}]`, + }); + expect(VideoClassification.create).not.toHaveBeenCalled(); + }); + + it('renumbers a stored row that belongs to another show in place', async () => { + const stored = { show_id: 9, status: 'assigned', season: 2023, episode: 1, update: jest.fn() }; + VideoClassification.findByPk.mockResolvedValue(stored); + await assign(); + expect(stored.update).toHaveBeenCalledWith(expect.objectContaining({ show_id: 4, season: 2024, episode: 3151200 })); + }); + + it('re-reads the season and retries once when another writer takes the number', async () => { + VideoClassification.create.mockRejectedValueOnce(uniqueError()); + VideoClassification.findAll + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ episode: 3151200 }]); + const result = await assign(); + expect(result.episode).toBe(3151201); + }); + + it('gives up when the retry also collides', async () => { + VideoClassification.create.mockRejectedValue(uniqueError()); + await expect(assign()).rejects.toThrow('Validation error'); + }); +}); diff --git a/server/modules/tvShows/__tests__/episodeInfo.test.js b/server/modules/tvShows/__tests__/episodeInfo.test.js new file mode 100644 index 000000000..a074bacb0 --- /dev/null +++ b/server/modules/tvShows/__tests__/episodeInfo.test.js @@ -0,0 +1,47 @@ +jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/tvshow', () => ({ findAll: jest.fn() })); +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +const ID = 'abcdefghijk'; +const STEM = `S2024E03151200 - Big Build [${ID}]`; + +describe('episodeInfo.getEpisodeInfoMap', () => { + let VideoClassification; + let TvShow; + let getEpisodeInfoMap; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + VideoClassification = require('../../../models/videoclassification'); + TvShow = require('../../../models/tvshow'); + ({ getEpisodeInfoMap } = require('../episodeInfo')); + VideoClassification.findAll.mockResolvedValue([ + { youtube_id: ID, show_id: 5, season: 2024, episode: 3151200, source: 'date', file_stem: STEM }, + ]); + TvShow.findAll.mockResolvedValue([{ id: 5, name: 'Mark Rober' }]); + }); + + it('describes a video whose file is its episode', async () => { + const map = await getEpisodeInfoMap([{ youtubeId: ID, filePath: `/data/__TV/MR/Season 2024/${STEM}.mp4` }]); + expect(map.get(ID)).toEqual({ showName: 'Mark Rober', season: 2024, episode: 3151200, code: 'S2024E03151200' }); + }); + + it('ignores a stored episode whose file is named movie-style', async () => { + const map = await getEpisodeInfoMap([{ youtubeId: ID, filePath: `/data/MR/Big Build [${ID}]/Big Build [${ID}].mp4` }]); + expect(map.has(ID)).toBe(false); + }); + + it('skips videos without a file without querying', async () => { + const map = await getEpisodeInfoMap([{ youtubeId: ID, filePath: null }]); + expect(map.size).toBe(0); + expect(VideoClassification.findAll).not.toHaveBeenCalled(); + }); + + it('only reads assigned episodes', async () => { + await getEpisodeInfoMap([{ youtubeId: ID, filePath: `/x/${STEM}.mp4` }]); + expect(VideoClassification.findAll).toHaveBeenCalledWith(expect.objectContaining({ + where: { youtube_id: [ID], status: 'assigned' }, + })); + }); +}); diff --git a/server/modules/tvShows/__tests__/episodeNaming.test.js b/server/modules/tvShows/__tests__/episodeNaming.test.js index bef162ab1..d9652a46f 100644 --- a/server/modules/tvShows/__tests__/episodeNaming.test.js +++ b/server/modules/tvShows/__tests__/episodeNaming.test.js @@ -1,4 +1,4 @@ -const { seasonFolderName, episodeCode, buildEpisodeStem, EPISODE_TITLE_MAX_BYTES } = require('../episodeNaming'); +const { seasonFolderName, episodeCode, buildEpisodeStem, episodeFileName, EPISODE_TITLE_MAX_BYTES } = require('../episodeNaming'); const { SEASON_FOLDER_PATTERN } = require('../../filesystem/constants'); describe('episodeNaming', () => { @@ -90,4 +90,18 @@ describe('episodeNaming', () => { expect(() => buildEpisodeStem({ ...base, youtubeId: '../escape' })).toThrow(TypeError); }); }); + + describe('episodeFileName', () => { + const id = 'abcdefghijk'; + const stem = `S2024E03151200 - Big Build [${id}]`; + + it.each([ + [`Mark Rober - Big Build [${id}].mp4`, `${stem}.mp4`], + [`Mark Rober - Big Build [${id}].en.srt`, `${stem}.en.srt`], + [`Mark Rober - Big Build [${id}]-fanart.jpg`, `${stem}-fanart.jpg`], + [`._Mark Rober - Big Build [${id}].mp4`, `._${stem}.mp4`], + ])('renames %s', (fileName, expected) => { + expect(episodeFileName(fileName, id, stem)).toBe(expected); + }); + }); }); diff --git a/server/modules/tvShows/__tests__/episodePlacement.test.js b/server/modules/tvShows/__tests__/episodePlacement.test.js new file mode 100644 index 000000000..8bc6e9c86 --- /dev/null +++ b/server/modules/tvShows/__tests__/episodePlacement.test.js @@ -0,0 +1,223 @@ +jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); +jest.mock('../showStore', () => ({ + findChannelShow: jest.fn(), + createChannelShow: jest.fn(), + relocateChannelShow: jest.fn(), + toLocation: jest.requireActual('../showStore').toLocation, +})); +jest.mock('../episodeAllocator', () => ({ assignDateEpisode: jest.fn() })); +jest.mock('../../../models/videoclassification', () => ({ findOne: jest.fn() })); +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +// Retries of a failing move are instant. +jest.mock('../../filesystem/sleep', () => ({ sleep: () => Promise.resolve() })); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ID = 'abcdefghijk'; +const STEM = `S2024E03151200 - Big Build [${ID}]`; +const ASSIGNMENT = { season: 2024, episode: 3151200, dateNumbered: true, episodeTitle: 'Big Build', fileStem: STEM }; + +describe('episodePlacement', () => { + let episodePlacement; + let libraryLayouts; + let showStore; + let episodeAllocator; + let VideoClassification; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + libraryLayouts = require('../libraryLayouts'); + showStore = require('../showStore'); + episodeAllocator = require('../episodeAllocator'); + VideoClassification = require('../../../models/videoclassification'); + episodePlacement = require('../episodePlacement'); + libraryLayouts.getLayoutResolver.mockResolvedValue((folder) => (folder === 'TV' ? 'tv' : 'videos')); + showStore.findChannelShow.mockResolvedValue(null); + showStore.createChannelShow.mockImplementation(async (p) => ({ + id: 5, name: p.name, folder_name: p.folderName, library_folder: p.libraryFolder, external_key: p.channelId, + })); + episodeAllocator.assignDateEpisode.mockResolvedValue(ASSIGNMENT); + }); + + describe('planEpisode', () => { + const plan = (overrides = {}) => episodePlacement.planEpisode({ + youtubeId: ID, + info: { channel: 'Mark Rober', title: 'Big Build', timestamp: 1710504000 }, + ownerChannelId: 'UC1', + channelRecord: null, + channelEnabled: false, + uploaderFolderName: 'Mark Rober', + resolvedSubfolder: 'TV', + baseDir: '/data', + ...overrides, + }); + + it('returns null for a videos folder without touching shows', async () => { + await expect(plan({ resolvedSubfolder: 'Kids' })).resolves.toBeNull(); + expect(showStore.findChannelShow).not.toHaveBeenCalled(); + }); + + it('returns null when the folder layouts cannot be read', async () => { + libraryLayouts.getLayoutResolver.mockRejectedValue(new Error('db down')); + await expect(plan()).resolves.toBeNull(); + }); + + it('creates an untracked channel show from the uploader folder and channel name', async () => { + await plan(); + expect(showStore.createChannelShow).toHaveBeenCalledWith({ + channelId: 'UC1', name: 'Mark Rober', folderName: 'Mark Rober', libraryFolder: 'TV', + }); + }); + + it('names a tracked channel show after the channel record', async () => { + await plan({ channelRecord: { title: 'Mark Rober Official', folder_name: 'MarkRober' }, channelEnabled: true }); + expect(showStore.createChannelShow).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Mark Rober Official', folderName: 'MarkRober', + })); + }); + + it('places the episode in a season folder of the show folder', async () => { + const placement = await plan(); + expect(placement).toMatchObject({ + showDir: path.join('/data', '__TV', 'Mark Rober'), + seasonDir: path.join('/data', '__TV', 'Mark Rober', 'Season 2024'), + stem: STEM, + assignment: ASSIGNMENT, + }); + }); + + it('places shows of the main folder directly under the downloads folder', async () => { + libraryLayouts.getLayoutResolver.mockResolvedValue(() => 'tv'); + const placement = await plan({ resolvedSubfolder: null }); + expect(placement.showDir).toBe(path.join('/data', 'Mark Rober')); + }); + + it('keeps an existing show at its stored location', async () => { + libraryLayouts.getLayoutResolver.mockResolvedValue(() => 'tv'); + showStore.findChannelShow.mockResolvedValue({ id: 2, name: 'MR', folder_name: 'Pinned', library_folder: 'TV' }); + const placement = await plan({ resolvedSubfolder: 'Other TV' }); + expect(placement.showDir).toBe(path.join('/data', '__TV', 'Pinned')); + }); + + it('moves an existing show out of a folder that is no longer TV', async () => { + const show = { id: 2, name: 'MR', folder_name: 'Pinned', library_folder: 'Old' }; + showStore.findChannelShow.mockResolvedValue(show); + showStore.relocateChannelShow.mockImplementation(async (s, folder) => ({ ...s, library_folder: folder })); + const placement = await plan(); + expect(showStore.relocateChannelShow).toHaveBeenCalledWith(show, 'TV'); + expect(placement.showDir).toBe(path.join('/data', '__TV', 'Pinned')); + }); + + it('numbers the episode in the show', async () => { + await plan(); + expect(episodeAllocator.assignDateEpisode).toHaveBeenCalledWith(expect.objectContaining({ + show: expect.objectContaining({ id: 5 }), youtubeId: ID, channelId: 'UC1', + })); + }); + }); + + describe('earliestEpisodeDate', () => { + it('reads the date of the lowest assigned episode', async () => { + VideoClassification.findOne.mockResolvedValue({ season: 2019, episode: 4050000 }); + await expect(episodePlacement.earliestEpisodeDate(5)).resolves.toBe('2019-04-05'); + }); + + it('returns null for a show without episodes', async () => { + VideoClassification.findOne.mockResolvedValue(null); + await expect(episodePlacement.earliestEpisodeDate(5)).resolves.toBeNull(); + }); + }); + + describe('writeEpisodeMetadata', () => { + let showDir; + beforeEach(() => { showDir = fs.mkdtempSync(path.join(os.tmpdir(), 'episode-meta-')); }); + afterEach(() => fs.rmSync(showDir, { recursive: true, force: true })); + + const write = () => { + const seasonDir = path.join(showDir, 'Season 2024'); + fs.mkdirSync(seasonDir); + return episodePlacement.writeEpisodeMetadata({ + placement: { + show: { id: 5, name: 'Mark Rober', external_key: 'UC1' }, assignment: ASSIGNMENT, showDir, seasonDir, stem: STEM, + }, + info: { id: ID, title: 'Big Build', upload_date: '20240315' }, + showPlot: 'Engineering', + }); + }; + + it('writes the episode NFO next to the episode', async () => { + await write(); + const xml = fs.readFileSync(path.join(showDir, 'Season 2024', `${STEM}.nfo`), 'utf8'); + expect(xml).toContain('<episode>3151200</episode>'); + }); + + it('writes tvshow.nfo with the earliest episode as premiered', async () => { + VideoClassification.findOne.mockResolvedValue({ season: 2019, episode: 4050000 }); + await write(); + const xml = fs.readFileSync(path.join(showDir, 'tvshow.nfo'), 'utf8'); + expect(xml).toContain('<premiered>2019-04-05</premiered>'); + }); + }); + + describe('moveEpisodeFiles', () => { + let root; + let source; + let seasonDir; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'episode-move-')); + source = path.join(root, 'temp', 'Mark Rober'); + seasonDir = path.join(root, 'TV', 'Mark Rober', 'Season 2024'); + fs.mkdirSync(source, { recursive: true }); + for (const name of [`MR - Big Build [${ID}].mp4`, `MR - Big Build [${ID}].jpg`, 'MR - Other [zzzzzzzzzzz].mp4']) { + fs.writeFileSync(path.join(source, name), name); + } + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('moves the video and its sidecars into the season folder under the stem', async () => { + await episodePlacement.moveEpisodeFiles({ sourceDir: source, youtubeId: ID, seasonDir, stem: STEM }); + expect(fs.readdirSync(seasonDir).sort()).toEqual([`${STEM}.jpg`, `${STEM}.mp4`]); + }); + + it('leaves other videos in the source folder', async () => { + await episodePlacement.moveEpisodeFiles({ sourceDir: source, youtubeId: ID, seasonDir, stem: STEM }); + expect(fs.readdirSync(source)).toEqual(['MR - Other [zzzzzzzzzzz].mp4']); + }); + + // Mirrors fs-extra's move across filesystems: with overwrite it removes the + // destination first, then the copy fails. The earlier episode must not be + // the destination of that move. + it('keeps the earlier episode file when the transfer of its replacement fails', async () => { + fs.mkdirSync(seasonDir, { recursive: true }); + fs.writeFileSync(path.join(seasonDir, `${STEM}.mp4`), 'old'); + const fsExtra = require('fs-extra'); + const realMove = fsExtra.move; + const move = jest.spyOn(fsExtra, 'move').mockImplementation(async (from, to, opts) => { + if (!from.endsWith('.mp4')) return realMove(from, to, opts); + if (opts && opts.overwrite) await fsExtra.remove(to); + throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }); + }); + try { + await expect(episodePlacement.moveEpisodeFiles({ sourceDir: source, youtubeId: ID, seasonDir, stem: STEM })) + .rejects.toThrow('no space left on device'); + expect(fs.readFileSync(path.join(seasonDir, `${STEM}.mp4`), 'utf8')).toBe('old'); + expect(fs.readdirSync(seasonDir).filter((name) => name.endsWith('.part'))).toEqual([]); + } finally { + move.mockRestore(); + } + }); + + it('replaces older files of the same video in the season folder', async () => { + fs.mkdirSync(seasonDir, { recursive: true }); + fs.writeFileSync(path.join(seasonDir, `${STEM}.webm`), 'old'); + fs.writeFileSync(path.join(seasonDir, `${STEM}.mp4`), 'old'); + await episodePlacement.moveEpisodeFiles({ sourceDir: source, youtubeId: ID, seasonDir, stem: STEM }); + expect(fs.readdirSync(seasonDir).sort()).toEqual([`${STEM}.jpg`, `${STEM}.mp4`]); + expect(fs.readFileSync(path.join(seasonDir, `${STEM}.mp4`), 'utf8')).toBe(`MR - Big Build [${ID}].mp4`); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/layoutGuards.test.js b/server/modules/tvShows/__tests__/layoutGuards.test.js new file mode 100644 index 000000000..7f81c9b99 --- /dev/null +++ b/server/modules/tvShows/__tests__/layoutGuards.test.js @@ -0,0 +1,178 @@ +jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/video', () => ({ count: jest.fn() })); +jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: '/data' })); +jest.mock('../showStore', () => ({ findChannelShow: jest.fn() })); +jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('layoutGuards', () => { + let layoutGuards; + let Channel; + let Playlist; + let Video; + let VideoClassification; + let configModule; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + Channel = require('../../../models/channel'); + Playlist = require('../../../models/playlist'); + Video = require('../../../models/video'); + VideoClassification = require('../../../models/videoclassification'); + configModule = require('../../configModule'); + layoutGuards = require('../layoutGuards'); + VideoClassification.findAll.mockResolvedValue([]); + configModule.getDefaultSubfolder.mockReturnValue('TV'); + Channel.findAll.mockResolvedValue([]); + Playlist.findAll.mockResolvedValue([]); + Video.count.mockResolvedValue(0); + }); + + describe('assertNoDownloadRunning', () => { + it('refuses with 409 while a download runs', () => { + expect(() => layoutGuards.assertNoDownloadRunning(() => true, 'busy')).toThrow(expect.objectContaining({ status: 409 })); + }); + + it('allows the change when nothing runs or no check is given', () => { + expect(() => layoutGuards.assertNoDownloadRunning(() => false, 'busy')).not.toThrow(); + expect(() => layoutGuards.assertNoDownloadRunning(undefined, 'busy')).not.toThrow(); + }); + }); + + describe('channelHasDownloads', () => { + it('counts the channel videos that still have files', async () => { + Video.count.mockResolvedValue(2); + await expect(layoutGuards.channelHasDownloads('UC1')).resolves.toBe(true); + expect(Video.count).toHaveBeenCalledWith({ where: { channel_id: 'UC1', removed: false } }); + }); + + it('counts episodes of the channel uploaded under another channel id', async () => { + Video.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1); + VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'vevo1' }]); + await expect(layoutGuards.channelHasDownloads('UC1')).resolves.toBe(true); + expect(Video.count).toHaveBeenLastCalledWith({ where: { youtubeId: ['vevo1'], removed: false } }); + }); + + it('reports no downloads when the channel has no videos or episodes with files', async () => { + Video.count.mockResolvedValue(0); + VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'gone1' }]); + await expect(layoutGuards.channelHasDownloads('UC1')).resolves.toBe(false); + }); + }); + + describe('mainFolderHasFiles', () => { + let root; + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'main-folder-')); }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + const write = (relative) => { + fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true }); + fs.writeFileSync(path.join(root, relative), 'x'); + }; + + it('ignores subfolders, the temp folder and dotfiles', async () => { + write('__Kids/Channel/video [abcdefghijk].mp4'); + write('.youtarr_tmp/Channel/video [abcdefghijk].mp4'); + write('.plexignore'); + await expect(layoutGuards.mainFolderHasFiles(root)).resolves.toBe(false); + }); + + it('finds a channel folder with downloads', async () => { + write('Channel/video [abcdefghijk]/video [abcdefghijk].mp4'); + await expect(layoutGuards.mainFolderHasFiles(root)).resolves.toBe(true); + }); + + it('treats a missing downloads folder as empty', async () => { + await expect(layoutGuards.mainFolderHasFiles(path.join(root, 'missing'))).resolves.toBe(false); + }); + }); + + describe('usersOfFolder', () => { + it('matches channels and playlists by the folder they resolve to', async () => { + Channel.findAll.mockResolvedValue([ + { channel_id: 'A', sub_folder: '##USE_GLOBAL_DEFAULT##' }, + { channel_id: 'B', sub_folder: 'tv' }, + { channel_id: 'C', sub_folder: 'Kids' }, + ]); + Playlist.findAll.mockResolvedValue([{ playlist_id: 'P', default_sub_folder: 'TV' }]); + const users = await layoutGuards.usersOfFolder('TV'); + expect(users.channels.map((c) => c.channel_id)).toEqual(['A', 'B']); + expect(users.playlists.map((p) => p.playlist_id)).toEqual(['P']); + }); + + it('resolves sentinels through a supplied resolver', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'A', sub_folder: '##USE_GLOBAL_DEFAULT##' }]); + const users = await layoutGuards.usersOfFolder('Kids', { subFolderOf: () => 'Kids' }); + expect(users.channels).toHaveLength(1); + }); + }); + + describe('usersOfGlobalDefault', () => { + it('selects channels and enabled playlists set to the global default', async () => { + await layoutGuards.usersOfGlobalDefault(); + expect(Channel.findAll).toHaveBeenCalledWith(expect.objectContaining({ + where: { sub_folder: '##USE_GLOBAL_DEFAULT##' }, + })); + expect(Playlist.findAll).toHaveBeenCalledWith(expect.objectContaining({ + where: { enabled: true, default_sub_folder: '##USE_GLOBAL_DEFAULT##' }, + })); + }); + }); + + describe('assertNoMp3Users', () => { + it('refuses when an enabled channel downloads MP3, naming it', () => { + const users = { channels: [{ title: 'Podcast', enabled: true, audio_format: 'mp3_only' }], playlists: [] }; + expect(() => layoutGuards.assertNoMp3Users(users, 'this folder')).toThrow(/Podcast/); + }); + + it('ignores disabled channels', () => { + const users = { channels: [{ title: 'Old', enabled: false, audio_format: 'mp3_only' }], playlists: [] }; + expect(() => layoutGuards.assertNoMp3Users(users, 'this folder')).not.toThrow(); + }); + + it('refuses an MP3 playlist', () => { + const users = { channels: [], playlists: [{ title: 'Mix', audio_format: 'video_mp3' }] }; + expect(() => layoutGuards.assertNoMp3Users(users, 'this folder')).toThrow(expect.objectContaining({ status: 409 })); + }); + }); + + describe('assertVideoOnlyDestination', () => { + beforeEach(() => { + require('../libraryLayouts').getLayoutResolver.mockResolvedValue((folder) => (folder === 'TV' ? 'tv' : 'videos')); + }); + + it('refuses MP3 into a TV folder', async () => { + await expect(layoutGuards.assertVideoOnlyDestination({ audioFormat: 'video_mp3', subFolderValue: 'TV' })) + .rejects.toMatchObject({ status: 400 }); + }); + + it('resolves the global default sentinel', async () => { + await expect(layoutGuards.assertVideoOnlyDestination({ audioFormat: 'mp3_only', subFolderValue: '##USE_GLOBAL_DEFAULT##' })) + .rejects.toMatchObject({ status: 400 }); + }); + + it('allows MP3 into a videos folder', async () => { + await expect(layoutGuards.assertVideoOnlyDestination({ audioFormat: 'mp3_only', subFolderValue: 'Kids' })) + .resolves.toBeUndefined(); + }); + + it('allows video into a TV folder without reading layouts', async () => { + await layoutGuards.assertVideoOnlyDestination({ audioFormat: null, subFolderValue: 'TV' }); + expect(require('../libraryLayouts').getLayoutResolver).not.toHaveBeenCalled(); + }); + }); + + describe('assertChannelsHaveNoDownloads', () => { + it('refuses with the given message when a channel has downloads', async () => { + Video.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1); + await expect(layoutGuards.assertChannelsHaveNoDownloads([{ channel_id: 'A' }, { channel_id: 'B' }], 'has files')) + .rejects.toThrow('has files'); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/libraryFolders.test.js b/server/modules/tvShows/__tests__/libraryFolders.test.js new file mode 100644 index 000000000..b4a19e98a --- /dev/null +++ b/server/modules/tvShows/__tests__/libraryFolders.test.js @@ -0,0 +1,183 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const mockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'library-folders-')); + +jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/video', () => ({ count: jest.fn() })); +jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn().mockResolvedValue([]) })); +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: mockRoot })); +jest.mock('../../subfolderModule', () => ({ getUsage: jest.fn(), getAll: jest.fn(), register: jest.fn() })); +jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn(), setLayout: jest.fn() })); +jest.mock('../showStore', () => ({ findChannelShow: jest.fn() })); + +const usage = (name, overrides = {}) => ({ + name, + displayName: `__${name}`, + usage: { channels: 0, playlists: 0, isDefault: false, plexMapped: false, hasFiles: false, ...overrides }, +}); + +describe('libraryFolders', () => { + let libraryFolders; + let Channel; + let Playlist; + let Video; + let configModule; + let subfolderModule; + let libraryLayouts; + let layouts; + + const writeFile = (relative, content = 'x') => { + fs.mkdirSync(path.dirname(path.join(mockRoot, relative)), { recursive: true }); + fs.writeFileSync(path.join(mockRoot, relative), content); + }; + const plexIgnorePath = () => path.join(mockRoot, '.plexignore'); + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + fs.rmSync(mockRoot, { recursive: true, force: true }); + fs.mkdirSync(mockRoot); + Channel = require('../../../models/channel'); + Playlist = require('../../../models/playlist'); + Video = require('../../../models/video'); + configModule = require('../../configModule'); + subfolderModule = require('../../subfolderModule'); + libraryLayouts = require('../libraryLayouts'); + libraryFolders = require('../libraryFolders'); + layouts = { TV: 'tv' }; + configModule.getDefaultSubfolder.mockReturnValue('Kids'); + libraryLayouts.getLayoutResolver.mockImplementation(async () => (folder) => layouts[folder] || 'videos'); + subfolderModule.getAll.mockResolvedValue(['__Kids', '__TV']); + subfolderModule.getUsage.mockResolvedValue([usage('Kids', { isDefault: true, hasFiles: true }), usage('TV')]); + Channel.findAll.mockResolvedValue([]); + Playlist.findAll.mockResolvedValue([]); + Video.count.mockResolvedValue(0); + }); + + afterAll(() => fs.rmSync(mockRoot, { recursive: true, force: true })); + + describe('listLibraryFolders', () => { + it('lists the main folder first, then each subfolder with its layout', async () => { + Channel.findAll.mockResolvedValue([ + { sub_folder: '##USE_GLOBAL_DEFAULT##' }, { sub_folder: 'kids' }, { sub_folder: 'TV' }, { sub_folder: null }, + ]); + await expect(libraryFolders.listLibraryFolders()).resolves.toEqual([ + { name: '', layout: 'videos', isDefault: false, hasFiles: false, channels: 1 }, + { name: 'Kids', layout: 'videos', isDefault: true, hasFiles: true, channels: 2 }, + { name: 'TV', layout: 'tv', isDefault: false, hasFiles: false, channels: 1 }, + ]); + }); + }); + + describe('setFolderLayout', () => { + it('switches an empty subfolder to TV', async () => { + await expect(libraryFolders.setFolderLayout('Kids', 'tv')).resolves.toEqual({ changed: true }); + expect(libraryLayouts.setLayout).toHaveBeenCalledWith('Kids', 'tv'); + }); + + it('registers a subfolder known only from config before storing its layout', async () => { + const order = []; + subfolderModule.register.mockImplementation(async (name) => { order.push(`register:${name}`); }); + libraryLayouts.setLayout.mockImplementation(async (name) => { order.push(`layout:${name}`); }); + await libraryFolders.setFolderLayout('Kids', 'tv'); + expect(order).toEqual(['register:Kids', 'layout:Kids']); + }); + + it('does not register the main folder', async () => { + await libraryFolders.setFolderLayout('', 'tv'); + expect(subfolderModule.register).not.toHaveBeenCalled(); + }); + + it('leaves a folder that already has the layout alone', async () => { + await expect(libraryFolders.setFolderLayout('TV', 'tv')).resolves.toEqual({ changed: false }); + expect(libraryLayouts.setLayout).not.toHaveBeenCalled(); + }); + + it('rejects an unknown subfolder', async () => { + await expect(libraryFolders.setFolderLayout('Nope', 'tv')).rejects.toMatchObject({ status: 404 }); + }); + + it('rejects an unknown layout', async () => { + await expect(libraryFolders.setFolderLayout('Kids', 'shows')).rejects.toMatchObject({ status: 400 }); + }); + + it('refuses while a download runs', async () => { + await expect(libraryFolders.setFolderLayout('Kids', 'tv', { isDownloadRunning: () => true })) + .rejects.toThrow(libraryFolders.MESSAGES.running); + }); + + it('refuses a folder that holds downloaded files', async () => { + writeFile('__Kids/Channel/video [abcdefghijk].mp4'); + await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toThrow(libraryFolders.MESSAGES.hasFiles); + }); + + it('refuses when a channel in the folder has downloads elsewhere on record', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', sub_folder: 'Kids', enabled: true }]); + Video.count.mockResolvedValue(4); + await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toMatchObject({ status: 409 }); + }); + + it('refuses TV for a folder whose channels download MP3', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', title: 'Pod', sub_folder: 'Kids', enabled: true, audio_format: 'mp3_only' }]); + await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toThrow(/Pod/); + }); + + it('writes a .plexignore when the main folder becomes TV', async () => { + await libraryFolders.setFolderLayout('', 'tv'); + expect(fs.readFileSync(plexIgnorePath(), 'utf8')).toBe('__*/*\n'); + }); + + it('adds the rule to an existing .plexignore', async () => { + writeFile('.plexignore', 'Extras/*'); + await libraryFolders.setFolderLayout('', 'tv'); + expect(fs.readFileSync(plexIgnorePath(), 'utf8')).toBe('Extras/*\n__*/*\n'); + }); + + it('removes its own .plexignore when the main folder goes back to videos', async () => { + layouts[''] = 'tv'; + writeFile('.plexignore', '__*/*\n'); + await libraryFolders.setFolderLayout('', 'videos'); + expect(fs.existsSync(plexIgnorePath())).toBe(false); + }); + + it('keeps a .plexignore the user changed', async () => { + layouts[''] = 'tv'; + writeFile('.plexignore', 'Extras/*\n__*/*\n'); + await libraryFolders.setFolderLayout('', 'videos'); + expect(fs.existsSync(plexIgnorePath())).toBe(true); + }); + }); + + describe('checkDefaultSubfolderChange', () => { + const check = (overrides) => libraryFolders.checkDefaultSubfolderChange({ oldDefault: 'Kids', newDefault: 'TV', ...overrides }); + + it('allows moving between folders with the same layout without looking at channels', async () => { + await expect(check({ newDefault: 'Music' })).resolves.toBeUndefined(); + expect(Channel.findAll).not.toHaveBeenCalled(); + }); + + it('allows a cross-layout change when the default has no downloads', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', enabled: true }]); + await expect(check()).resolves.toBeUndefined(); + }); + + it('refuses when channels on the default have downloads', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', enabled: true }]); + Video.count.mockResolvedValue(1); + await expect(check()).rejects.toThrow(libraryFolders.MESSAGES.defaultHasDownloads); + }); + + it('refuses while a download runs', async () => { + await expect(check({ isDownloadRunning: () => true })).rejects.toThrow(libraryFolders.MESSAGES.defaultRunning); + }); + + it('refuses a TV default when a playlist on the default downloads MP3', async () => { + Playlist.findAll.mockResolvedValue([{ title: 'Mix', audio_format: 'mp3_only' }]); + await expect(check()).rejects.toMatchObject({ status: 409 }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/libraryLayouts.test.js b/server/modules/tvShows/__tests__/libraryLayouts.test.js index 589edadd0..9198fdb3f 100644 --- a/server/modules/tvShows/__tests__/libraryLayouts.test.js +++ b/server/modules/tvShows/__tests__/libraryLayouts.test.js @@ -1,8 +1,79 @@ -const { getLayoutResolver, LAYOUT_VIDEOS } = require('../libraryLayouts'); +jest.mock('../../../models/subfolder', () => ({ + findAll: jest.fn(), + update: jest.fn(), +})); +jest.mock('../../configModule', () => ({ + getConfig: jest.fn(), + updateConfig: jest.fn(), +})); describe('libraryLayouts', () => { - it('resolves the main folder and every subfolder to the videos layout', async () => { - const layoutOf = await getLayoutResolver(); - expect(['', 'TV Shows', 'kids'].map(layoutOf)).toEqual([LAYOUT_VIDEOS, LAYOUT_VIDEOS, LAYOUT_VIDEOS]); + let libraryLayouts; + let Subfolder; + let configModule; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + Subfolder = require('../../../models/subfolder'); + configModule = require('../../configModule'); + libraryLayouts = require('../libraryLayouts'); + configModule.getConfig.mockReturnValue({}); + Subfolder.findAll.mockResolvedValue([]); + }); + + describe('getLayoutResolver', () => { + it('resolves every folder to videos when nothing is set to tv', async () => { + const layoutOf = await libraryLayouts.getLayoutResolver(); + expect(['', 'TV Shows', 'kids'].map(layoutOf)).toEqual(['videos', 'videos', 'videos']); + }); + + it('resolves the main folder from mainFolderLayout', async () => { + configModule.getConfig.mockReturnValue({ mainFolderLayout: 'tv' }); + const layoutOf = await libraryLayouts.getLayoutResolver(); + expect(layoutOf('')).toBe('tv'); + }); + + it('treats an unknown mainFolderLayout as videos', async () => { + configModule.getConfig.mockReturnValue({ mainFolderLayout: 'shows' }); + const layoutOf = await libraryLayouts.getLayoutResolver(); + expect(layoutOf('')).toBe('videos'); + }); + + it('resolves a subfolder stored as tv, ignoring case', async () => { + Subfolder.findAll.mockResolvedValue([{ name: 'TV Shows' }]); + const layoutOf = await libraryLayouts.getLayoutResolver(); + expect([layoutOf('tv shows'), layoutOf('kids')]).toEqual(['tv', 'videos']); + }); + }); + + describe('listTvFolders', () => { + it('lists TV subfolders sorted, with the main folder first when it is TV', async () => { + configModule.getConfig.mockReturnValue({ mainFolderLayout: 'tv' }); + Subfolder.findAll.mockResolvedValue([{ name: 'Shows' }, { name: 'Kids TV' }]); + await expect(libraryLayouts.listTvFolders()).resolves.toEqual(['', 'Kids TV', 'Shows']); + }); + + it('leaves out a videos main folder', async () => { + Subfolder.findAll.mockResolvedValue([{ name: 'Shows' }]); + await expect(libraryLayouts.listTvFolders()).resolves.toEqual(['Shows']); + }); + }); + + describe('setLayout', () => { + it('stores the main folder layout in config', async () => { + configModule.getConfig.mockReturnValue({ defaultSubfolder: 'x' }); + await libraryLayouts.setLayout('', 'tv'); + expect(configModule.updateConfig).toHaveBeenCalledWith({ defaultSubfolder: 'x', mainFolderLayout: 'tv' }); + }); + + it('stores a subfolder layout on its row', async () => { + await libraryLayouts.setLayout('TV Shows', 'tv'); + expect(Subfolder.update).toHaveBeenCalledWith({ layout: 'tv' }, { where: { name: 'TV Shows' } }); + }); + + it('rejects an unknown layout', async () => { + await expect(libraryLayouts.setLayout('kids', 'shows')).rejects.toThrow(TypeError); + }); }); }); diff --git a/server/modules/tvShows/__tests__/showStore.test.js b/server/modules/tvShows/__tests__/showStore.test.js new file mode 100644 index 000000000..5ec66cba3 --- /dev/null +++ b/server/modules/tvShows/__tests__/showStore.test.js @@ -0,0 +1,130 @@ +jest.mock('../../../models/tvshow', () => ({ + findOne: jest.fn(), + findAll: jest.fn(), + create: jest.fn(), +})); + +const CHANNEL_ID = 'UCY1kMZp36IQSyNx_9h4mpCg'; + +function uniqueError() { + const err = new Error('Validation error'); + err.name = 'SequelizeUniqueConstraintError'; + return err; +} + +describe('showStore', () => { + let showStore; + let TvShow; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + TvShow = require('../../../models/tvshow'); + showStore = require('../showStore'); + TvShow.findOne.mockResolvedValue(null); + TvShow.create.mockImplementation(async (values) => ({ id: 7, ...values })); + }); + + describe('showFolderNameProblem', () => { + it.each([ + ['__kids', 'starts with __'], + ['.hidden', 'starts with .'], + ['Season 01', 'season folder'], + ['trailers', 'extras folder'], + ['Behind The Scenes', 'extras folder'], + ['', 'empty'], + ])('flags %s', (name) => { + expect(showStore.showFolderNameProblem(name)).not.toBeNull(); + }); + + it('accepts an ordinary show name', () => { + expect(showStore.showFolderNameProblem('Mark Rober')).toBeNull(); + }); + }); + + describe('findChannelShow', () => { + it('looks up the active channel show of a channel', async () => { + await showStore.findChannelShow(CHANNEL_ID); + expect(TvShow.findOne).toHaveBeenCalledWith({ + where: { channel_id: CHANNEL_ID, kind: 'channel', retired_at: null }, + }); + }); + }); + + describe('createChannelShow', () => { + const create = (overrides = {}) => showStore.createChannelShow({ + channelId: CHANNEL_ID, + name: 'Mark Rober', + folderName: 'Mark Rober', + libraryFolder: 'TV Shows', + ...overrides, + }); + + it('returns the existing channel show instead of creating a second one', async () => { + const existing = { id: 3, channel_id: CHANNEL_ID }; + TvShow.findOne.mockResolvedValue(existing); + await expect(create()).resolves.toBe(existing); + expect(TvShow.create).not.toHaveBeenCalled(); + }); + + it('creates the show at the library folder with the channel id as its key', async () => { + const show = await create({ previousVideosFolder: '##USE_GLOBAL_DEFAULT##' }); + expect(show).toMatchObject({ + channel_id: CHANNEL_ID, + kind: 'channel', + name: 'Mark Rober', + folder_name: 'Mark Rober', + library_folder: 'TV Shows', + external_key: CHANNEL_ID, + previous_videos_folder: '##USE_GLOBAL_DEFAULT##', + }); + }); + + it('sanitizes a raw folder name like a yt-dlp field', async () => { + const show = await create({ folderName: 'AC/DC: Live?' }); + expect(show.folder_name).toBe('AC⧸DC: Live?'); + }); + + it('adds the channel id when another show holds the folder name', async () => { + TvShow.create.mockRejectedValueOnce(uniqueError()); + const show = await create(); + expect(show.folder_name).toBe(`Mark Rober (${CHANNEL_ID})`); + }); + + it('adds the channel id when the name is an extras folder name', async () => { + const show = await create({ folderName: 'Trailers' }); + expect(show.folder_name).toBe(`Trailers (${CHANNEL_ID})`); + }); + + it('uses the channel id alone when no form of the name is valid', async () => { + const show = await create({ folderName: '.hidden' }); + expect(show.folder_name).toBe(CHANNEL_ID); + }); + + it('rethrows errors other than a taken folder name', async () => { + TvShow.create.mockRejectedValueOnce(new Error('connection lost')); + await expect(create()).rejects.toThrow('connection lost'); + }); + }); + + describe('relocateChannelShow', () => { + it('moves the show to another library folder, keeping its folder name', async () => { + const show = { channel_id: CHANNEL_ID, folder_name: 'Mark Rober', update: jest.fn() }; + await showStore.relocateChannelShow(show, 'Kids TV'); + expect(show.update).toHaveBeenCalledWith({ library_folder: 'Kids TV', folder_name: 'Mark Rober' }); + }); + + it('adds the channel id when the folder name is taken in the new folder', async () => { + const show = { + channel_id: CHANNEL_ID, + folder_name: 'Mark Rober', + update: jest.fn().mockRejectedValueOnce(uniqueError()).mockResolvedValueOnce(undefined), + }; + await showStore.relocateChannelShow(show, 'Kids TV'); + expect(show.update).toHaveBeenLastCalledWith({ + library_folder: 'Kids TV', + folder_name: `Mark Rober (${CHANNEL_ID})`, + }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/tvNfo.test.js b/server/modules/tvShows/__tests__/tvNfo.test.js new file mode 100644 index 000000000..179fafde0 --- /dev/null +++ b/server/modules/tvShows/__tests__/tvNfo.test.js @@ -0,0 +1,117 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const tvNfo = require('../tvNfo'); + +const INFO = { + id: 'abcdefghijk', + title: 'Big Build & More', + description: 'We built <things>.', + upload_date: '20240315', + duration: 125, + uploader: 'Mark Rober', + categories: ['Science & Technology'], + tags: ['engineering'], + normalized_rating: 'TV-PG', +}; + +describe('tvNfo', () => { + describe('buildEpisodeNfo', () => { + const xml = tvNfo.buildEpisodeNfo({ + info: INFO, showTitle: 'Mark Rober', season: 2024, episode: 3151200, episodeTitle: 'Big Build & More', + }); + + it('uses an episodedetails root', () => { + expect(xml).toMatch(/<episodedetails>[\s\S]*<\/episodedetails>\n$/); + }); + + it('writes the season and episode as plain integers', () => { + expect(xml).toContain('<season>2024</season>'); + expect(xml).toContain('<episode>3151200</episode>'); + }); + + it('escapes the episode title and show title', () => { + expect(xml).toContain('<title>Big Build & More'); + expect(xml).toContain('Mark Rober'); + }); + + it('writes aired and premiered as dates without a time', () => { + expect(xml).toContain('2024-03-15'); + expect(xml).toContain('2024-03-15'); + }); + + it('identifies the video by its YouTube id', () => { + expect(xml).toContain('abcdefghijk'); + }); + + it('writes runtime, studio, genres, tags and rating', () => { + expect(xml).toContain('3'); + expect(xml).toContain('Mark Rober'); + expect(xml).toContain('Science & Technology'); + expect(xml).toContain('engineering'); + expect(xml).toContain('TV-PG'); + }); + + it('never writes lockdata, dateadded or artwork paths', () => { + expect(xml).not.toMatch(/|||/); + }); + }); + + describe('buildTvShowNfo', () => { + const xml = tvNfo.buildTvShowNfo({ + title: 'Mark Rober', plot: 'Engineering videos', premiered: '2016-01-20', externalKey: 'UC123', + }); + + it('carries the external key as both the YouTube id and a custom id', () => { + expect(xml).toContain('UC123'); + expect(xml).toContain('UC123'); + }); + + it('writes title, plot, premiered and studio', () => { + expect(xml).toMatch(/[\s\S]*Mark Rober<\/title>/); + expect(xml).toContain('<plot>Engineering videos</plot>'); + expect(xml).toContain('<premiered>2016-01-20</premiered>'); + expect(xml).toContain('<studio>YouTube</studio>'); + }); + + it('leaves out an empty plot and premiered', () => { + const bare = tvNfo.buildTvShowNfo({ title: 'X', externalKey: 'UC1' }); + expect(bare).not.toMatch(/<plot>|<premiered>/); + }); + }); + + describe('dateFromEpisodeCode', () => { + it('reads the date of a date-numbered episode, including bumped numbers', () => { + expect(tvNfo.dateFromEpisodeCode(2024, 1151260)).toBe('2024-01-15'); + }); + + it('returns null for numbers that are not a date', () => { + expect(tvNfo.dateFromEpisodeCode(1, 20)).toBeNull(); + }); + }); + + describe('writeTvShowNfoIfChanged', () => { + let dir; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tvnfo-')); }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + const params = { title: 'Mark Rober', externalKey: 'UC123' }; + + it('writes tvshow.nfo when it is missing', async () => { + await expect(tvNfo.writeTvShowNfoIfChanged(dir, params)).resolves.toBe(true); + expect(fs.readFileSync(path.join(dir, 'tvshow.nfo'), 'utf8')).toContain('<title>Mark Rober'); + }); + + it('leaves an unchanged tvshow.nfo alone', async () => { + await tvNfo.writeTvShowNfoIfChanged(dir, params); + await expect(tvNfo.writeTvShowNfoIfChanged(dir, params)).resolves.toBe(false); + }); + + it('rewrites tvshow.nfo when a value changes', async () => { + await tvNfo.writeTvShowNfoIfChanged(dir, params); + await expect(tvNfo.writeTvShowNfoIfChanged(dir, { ...params, title: 'Mark Rober 2' })).resolves.toBe(true); + }); + }); +}); diff --git a/server/modules/tvShows/channelFolders.js b/server/modules/tvShows/channelFolders.js new file mode 100644 index 000000000..48f6c4e6e --- /dev/null +++ b/server/modules/tvShows/channelFolders.js @@ -0,0 +1,91 @@ +/** + * Where a tracked channel's files live. A channel in a videos folder has its + * channel folder (/); a channel in a TV folder + * has its show folder, pinned on its tv_shows row, which can differ from + * folder_name and from the channel's current library folder. + */ + +const path = require('path'); +const { Op } = require('sequelize'); +const configModule = require('../configModule'); +// pathBuilder only: the filesystem aggregator would load fs-extra into every module that resolves a folder. +const { resolveEffectiveSubfolder, buildChannelPath, resolveChannelFolderName, buildSubfolderSegment } = require('../filesystem/pathBuilder'); +const { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } = require('../filesystem/constants'); +const { LAYOUT_TV } = require('./constants'); +const { getLayoutResolver, listTvFolders } = require('./libraryLayouts'); +const showStore = require('./showStore'); + +/** + * Library folder a sub_folder value resolves to ('' = main folder). + * @param {string|null} subFolderValue - channels.sub_folder (may be a sentinel) + */ +function effectiveLibraryFolder(subFolderValue) { + return resolveEffectiveSubfolder(subFolderValue, configModule.getDefaultSubfolder()) || ''; +} + +/** + * Absolute path of a show's folder. + * @param {{library_folder: string, folder_name: string}} show + */ +function showDirectory(show) { + const baseDir = configModule.directoryPath; + const libraryDir = show.library_folder ? path.join(baseDir, buildSubfolderSegment(show.library_folder)) : baseDir; + return path.join(libraryDir, show.folder_name); +} + +/** + * @param {Object} channel - channels row (sub_folder, channel_id, folder_name, uploader) + * @param {Object} [options] + * @param {(libraryFolder: string) => string} [options.layoutOf] - Reuse a resolver across channels + * @returns {Promise<{layout: string, dir: string|null}>} dir is null for a TV + * channel whose show doesn't exist yet, or a channel without a folder name + */ +async function resolveChannelDirectory(channel, { layoutOf } = {}) { + const resolveLayout = layoutOf || await getLayoutResolver(); + const libraryFolder = effectiveLibraryFolder(channel.sub_folder); + const layout = resolveLayout(libraryFolder); + if (layout === LAYOUT_TV) { + const show = await showStore.findChannelShow(channel.channel_id); + return { layout, dir: show ? showDirectory(show) : null }; + } + const folderName = resolveChannelFolderName(channel); + return { layout, dir: folderName ? buildChannelPath(configModule.directoryPath, libraryFolder || null, folderName) : null }; +} + +/** + * @param {Object} channel - channels row + * @param {(libraryFolder: string) => string} [layoutOf] + * @returns {Promise} + */ +async function isTvChannel(channel, layoutOf) { + const resolveLayout = layoutOf || await getLayoutResolver(); + return resolveLayout(effectiveLibraryFolder(channel.sub_folder)) === LAYOUT_TV; +} + +/** + * Sequelize condition on channels.sub_folder matching channels that download + * to a TV folder, or null when no folder is TV. + * @returns {Promise} + */ +async function tvChannelCondition() { + const tvFolders = await listTvFolders(); + const keys = new Set(tvFolders.map((name) => name.toLowerCase())); + const named = tvFolders.filter(Boolean); + const conditions = []; + if (named.length > 0) conditions.push({ sub_folder: { [Op.in]: named } }); + if (keys.has(String(configModule.getDefaultSubfolder() || '').toLowerCase())) { + conditions.push({ sub_folder: GLOBAL_DEFAULT_SENTINEL }); + } + if (keys.has('')) { + conditions.push({ sub_folder: { [Op.or]: [null, '', ROOT_SENTINEL] } }); + } + return conditions.length > 0 ? { [Op.or]: conditions } : null; +} + +module.exports = { + effectiveLibraryFolder, + tvChannelCondition, + showDirectory, + resolveChannelDirectory, + isTvChannel +}; diff --git a/server/modules/tvShows/channelLayout.js b/server/modules/tvShows/channelLayout.js new file mode 100644 index 000000000..1b8f42de4 --- /dev/null +++ b/server/modules/tvShows/channelLayout.js @@ -0,0 +1,187 @@ +/** + * A tracked channel's layout is the layout of the folder it downloads to. + * These functions guard and apply a channel's folder changes (Channel + * Settings, the TV layout toggle, subscribing with initial settings), work out + * the folder a layout toggle switches to, and describe a channel's TV state. + */ + +const configModule = require('../configModule'); +const { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } = require('../filesystem/constants'); +const { cleanupOrphanShowFolder } = require('../filesystem/showFolderCleanup'); +const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey } = require('./constants'); +const { getLayoutResolver, listTvFolders } = require('./libraryLayouts'); +const showStore = require('./showStore'); +const { effectiveLibraryFolder, showDirectory } = require('./channelFolders'); +const layoutGuards = require('./layoutGuards'); + +const LAYOUTS = new Set([LAYOUT_VIDEOS, LAYOUT_TV]); + +const MESSAGES = { + mp3: 'TV shows are video-only. Change this channel\'s download type to Video before saving it to a TV folder.', + running: 'Wait for the current download to finish before switching this channel between Videos and TV.', + hasDownloads: 'This channel already has downloaded videos, so it can\'t switch between Videos and TV or move to ' + + 'another TV folder yet.', +}; + +// sub_folder value that targets a library folder explicitly. +function subFolderValueFor(libraryFolder) { + return libraryFolder ? libraryFolder : ROOT_SENTINEL; +} + +/** + * Check a channel settings change before it is saved. Refuses an MP3 download + * type in a TV folder, and a folder change that crosses layouts or moves a + * show between TV folders while the channel has downloads or a download runs. + * + * @param {Object} params + * @param {Object} params.channel - Current channels row + * @param {string|null} [params.newSubFolder] - New sub_folder value; undefined when unchanged + * @param {string|null} [params.newAudioFormat] - New audio_format; undefined when unchanged + * @param {() => boolean} [params.isDownloadRunning] + * @returns {Promise<{oldFolder: string, newFolder: string, oldLayout: string, newLayout: string, involvesTv: boolean}>} + */ +async function checkChannelSettingsChange({ channel, newSubFolder, newAudioFormat, isDownloadRunning }) { + const layoutOf = await getLayoutResolver(); + const oldFolder = effectiveLibraryFolder(channel.sub_folder); + const newFolder = newSubFolder === undefined ? oldFolder : effectiveLibraryFolder(newSubFolder); + const oldLayout = layoutOf(oldFolder); + const newLayout = layoutOf(newFolder); + const change = { oldFolder, newFolder, oldLayout, newLayout, involvesTv: oldLayout === LAYOUT_TV || newLayout === LAYOUT_TV }; + + const audioFormat = newAudioFormat === undefined ? channel.audio_format : newAudioFormat; + if (newLayout === LAYOUT_TV && layoutGuards.isMp3Format(audioFormat)) { + throw layoutGuards.guardError(MESSAGES.mp3, 400); + } + + const folderChanged = folderKey(oldFolder) !== folderKey(newFolder); + if (folderChanged && change.involvesTv) { + layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.running); + if (await layoutGuards.channelHasDownloads(channel.channel_id)) { + throw layoutGuards.guardError(MESSAGES.hasDownloads, 409); + } + } + return change; +} + +/** + * After a checked folder change is saved, give a channel that is now TV its + * show at the new folder (created, or moved there: the guard allowed the + * change only because no files exist). The folder it came from is kept so + * switching back to Videos returns there. A channel leaving TV keeps its show + * row (its location and numbering) but not the show folder it leaves behind, + * which holds nothing but tvshow.nfo and art once its episodes are gone. + * + * @param {Object} params + * @param {Object} params.channel - Updated channels row + * @param {string|null} params.previousSubFolder - sub_folder before the change + * @param {Object} params.change - checkChannelSettingsChange's result + */ +async function applyChannelFolderChange({ channel, previousSubFolder, change }) { + if (change.newLayout !== LAYOUT_TV) { + if (change.oldLayout === LAYOUT_TV) { + const leftShow = await showStore.findChannelShow(channel.channel_id); + if (leftShow) await cleanupOrphanShowFolder(showDirectory(leftShow)); + } + return null; + } + // NULL and legacy '' both mean the main folder. + const previousVideosFolder = change.oldLayout === LAYOUT_VIDEOS + ? (previousSubFolder === null || previousSubFolder === '' ? ROOT_SENTINEL : previousSubFolder) + : undefined; + + let show = await showStore.findChannelShow(channel.channel_id); + if (!show) { + return showStore.createChannelShow({ + channelId: channel.channel_id, + name: channel.title || channel.uploader || channel.folder_name, + folderName: channel.folder_name || channel.title || channel.uploader, + libraryFolder: change.newFolder, + previousVideosFolder: previousVideosFolder === undefined ? null : previousVideosFolder, + }); + } + if (folderKey(show.library_folder) !== folderKey(change.newFolder)) { + show = await showStore.relocateChannelShow(show, change.newFolder); + } + if (previousVideosFolder !== undefined) { + await show.update({ previous_videos_folder: previousVideosFolder }); + } + return show; +} + +/** + * The sub_folder value a layout toggle saves. + * TV: the chosen folder, else the global default when it is TV, else the + * only TV folder. Videos: the chosen folder, else the folder the channel left + * for TV, else the global default when it is a Videos folder. + * + * @param {Object} params + * @param {Object} params.channel - channels row + * @param {string} params.layout - 'videos' | 'tv' + * @param {string} [params.folder] - Chosen library folder ('' = main folder) + * @returns {Promise} + */ +async function resolveLayoutTarget({ channel, layout, folder }) { + if (!LAYOUTS.has(layout)) { + throw layoutGuards.guardError('layout must be "videos" or "tv"', 400); + } + const layoutOf = await getLayoutResolver(); + const defaultFolder = configModule.getDefaultSubfolder() || ''; + const layoutName = layout === LAYOUT_TV ? 'TV' : 'Videos'; + + if (folder !== undefined && folder !== null) { + const name = String(folder).trim(); + if (layoutOf(name) !== layout) { + throw layoutGuards.guardError(`${name ? `__${name}` : 'The main folder'} is not a ${layoutName} folder.`, 400); + } + return subFolderValueFor(name); + } + + if (layout === LAYOUT_VIDEOS) { + const show = await showStore.findChannelShow(channel.channel_id); + const previous = show ? show.previous_videos_folder : null; + if (previous && layoutOf(effectiveLibraryFolder(previous)) === LAYOUT_VIDEOS) return previous; + if (layoutOf(defaultFolder) === LAYOUT_VIDEOS) return GLOBAL_DEFAULT_SENTINEL; + throw layoutGuards.guardError('Choose a Videos folder.', 400); + } + + if (layoutOf(defaultFolder) === LAYOUT_TV) return GLOBAL_DEFAULT_SENTINEL; + const tvFolders = await listTvFolders(); + if (tvFolders.length === 1) return subFolderValueFor(tvFolders[0]); + throw layoutGuards.guardError(tvFolders.length === 0 ? 'Set up a TV folder first.' : 'Choose a TV folder.', 400); +} + +/** + * A channel's TV state for Channel Settings. + * @param {Object} channel - channels row + */ +async function getChannelTvState(channel) { + const layoutOf = await getLayoutResolver(); + const libraryFolder = effectiveLibraryFolder(channel.sub_folder); + const layout = layoutOf(libraryFolder); + const [show, tvFolders, hasDownloads] = await Promise.all([ + showStore.findChannelShow(channel.channel_id), + listTvFolders(), + layoutGuards.channelHasDownloads(channel.channel_id), + ]); + const defaultFolder = configModule.getDefaultSubfolder() || ''; + return { + layout, + libraryFolder, + show: show && layout === LAYOUT_TV + ? { name: show.name, folderName: show.folder_name, libraryFolder: show.library_folder, path: showDirectory(show) } + : null, + tvFolders, + defaultFolder, + defaultFolderLayout: layoutOf(defaultFolder), + hasDownloads, + canSwitch: !hasDownloads, + }; +} + +module.exports = { + MESSAGES, + checkChannelSettingsChange, + applyChannelFolderChange, + resolveLayoutTarget, + getChannelTvState +}; diff --git a/server/modules/tvShows/constants.js b/server/modules/tvShows/constants.js new file mode 100644 index 000000000..6b80156f3 --- /dev/null +++ b/server/modules/tvShows/constants.js @@ -0,0 +1,37 @@ +/** + * Shared TV show constants and helpers, kept free of dependencies so pure + * modules can import them without loading config or the database. + */ + +// Library folder layouts. +const LAYOUT_VIDEOS = 'videos'; +const LAYOUT_TV = 'tv'; + +// Show kinds (tv_shows.kind). +const KIND_TITLE_SHOW = 'title'; +const KIND_CHANNEL_SHOW = 'channel'; + +// Download types that produce MP3 output. TV folders are video-only. +const MP3_AUDIO_FORMATS = new Set(['mp3_only', 'video_mp3']); + +function isMp3Format(audioFormat) { + return MP3_AUDIO_FORMATS.has(audioFormat); +} + +/** + * Comparison key for a library folder name ('' = main folder): folder names + * compare ignoring case, like the subfolders registry's collation. + */ +function folderKey(libraryFolder) { + return String(libraryFolder || '').trim().toLowerCase(); +} + +module.exports = { + folderKey, + isMp3Format, + MP3_AUDIO_FORMATS, + LAYOUT_VIDEOS, + LAYOUT_TV, + KIND_TITLE_SHOW, + KIND_CHANNEL_SHOW +}; diff --git a/server/modules/tvShows/episodeAllocator.js b/server/modules/tvShows/episodeAllocator.js new file mode 100644 index 000000000..86c3d073d --- /dev/null +++ b/server/modules/tvShows/episodeAllocator.js @@ -0,0 +1,104 @@ +/** + * Episode numbers for date-numbered shows (channel shows), assigned when a + * video is post-processed: season = UTC upload year, episode = MMDDHHMM, the + * next free number when that minute is taken. The assignment is stored, so a + * re-download lands on the same number and file name. + * + * Post-processors run one at a time, so the only concurrent writer is the API. + * No upsert: MySQL's ON DUPLICATE KEY UPDATE would also fire on the episode + * unique key and overwrite the video holding that number. + */ + +const { Op } = require('sequelize'); +const VideoClassification = require('../../models/videoclassification'); +const logger = require('../../logger'); +const { releaseTime, dateEpisodeFor, allocateEpisode } = require('./dateNumbering'); +const { buildEpisodeStem } = require('./episodeNaming'); + +const STATUS_ASSIGNED = 'assigned'; +const SOURCE_DATE = 'date'; + +function isUniqueConstraintError(err) { + return Boolean(err && err.name === 'SequelizeUniqueConstraintError'); +} + +function toAssignment(row) { + return { + season: row.season, + episode: row.episode, + dateNumbered: true, + episodeTitle: row.episode_title, + fileStem: row.file_stem, + }; +} + +async function takenEpisodes(showId, season, youtubeId) { + const rows = await VideoClassification.findAll({ + where: { show_id: showId, season, youtube_id: { [Op.ne]: youtubeId } }, + attributes: ['episode'], + }); + return new Set(rows.map((row) => row.episode)); +} + +/** + * Assign (or reuse) a date-numbered episode for a video in a show. + * + * @param {Object} params + * @param {{id: number}} params.show - tv_shows row + * @param {string} params.youtubeId + * @param {string} params.channelId - Owner channel's YouTube id + * @param {Object} params.info - yt-dlp info dict (timestamp, upload_date, title) + * @param {() => number} [params.now] - Clock for videos with no release time + * @returns {Promise<{season: number, episode: number, dateNumbered: true, episodeTitle: string, fileStem: string}>} + */ +async function assignDateEpisode({ show, youtubeId, channelId, info, now = Date.now }) { + const stored = await VideoClassification.findByPk(youtubeId); + if (stored && stored.show_id === show.id && stored.status === STATUS_ASSIGNED + && stored.season !== null && stored.episode !== null && stored.file_stem) { + return toAssignment(stored); + } + + let release = releaseTime(info); + if (!release) { + logger.warn({ youtubeId }, 'TV episode has no upload time; numbering it by the download time'); + release = { epochSeconds: Math.floor(now() / 1000), source: null }; + } + const { season, episode: desired } = dateEpisodeFor(release.epochSeconds); + const episodeTitle = (info.fulltitle || info.title || '').trim() || null; + + const write = async () => { + const episode = allocateEpisode(desired, await takenEpisodes(show.id, season, youtubeId)); + const values = { + channel_id: channelId, + show_id: show.id, + status: STATUS_ASSIGNED, + season, + episode, + source: SOURCE_DATE, + timestamp_source: release.source, + pattern_id: null, + episode_title: episodeTitle, + file_stem: buildEpisodeStem({ season, episode, dateNumbered: true, videoTitle: episodeTitle, youtubeId }), + }; + if (stored) { + await stored.update(values); + } else { + await VideoClassification.create({ youtube_id: youtubeId, ...values }); + } + return toAssignment(values); + }; + + try { + return await write(); + } catch (err) { + if (!isUniqueConstraintError(err)) throw err; + logger.info({ youtubeId, showId: show.id, season }, 'Episode number taken meanwhile; retrying once'); + return write(); + } +} + +module.exports = { + STATUS_ASSIGNED, + SOURCE_DATE, + assignDateEpisode +}; diff --git a/server/modules/tvShows/episodeInfo.js b/server/modules/tvShows/episodeInfo.js new file mode 100644 index 000000000..6791b7358 --- /dev/null +++ b/server/modules/tvShows/episodeInfo.js @@ -0,0 +1,51 @@ +/** + * Episode details for listing and detail responses: the show and SxxEyy of a + * downloaded video, when its file is an episode. + */ + +const path = require('path'); +const VideoClassification = require('../../models/videoclassification'); +const TvShow = require('../../models/tvshow'); +const { STATUS_ASSIGNED, SOURCE_DATE } = require('./episodeAllocator'); +const { episodeCode } = require('./episodeNaming'); + +/** + * @param {Array<{youtubeId: string, filePath: string|null}>} videos + * @returns {Promise>} + * Only videos whose file is named as the stored episode: a classification + * kept after a channel moved back to Videos must not label a movie-style file. + */ +async function getEpisodeInfoMap(videos) { + const result = new Map(); + const withFiles = (videos || []).filter((video) => video && video.youtubeId && video.filePath); + if (withFiles.length === 0) return result; + + const rows = await VideoClassification.findAll({ + where: { youtube_id: withFiles.map((video) => video.youtubeId), status: STATUS_ASSIGNED }, + attributes: ['youtube_id', 'show_id', 'season', 'episode', 'source', 'file_stem'], + }); + if (rows.length === 0) return result; + const shows = await TvShow.findAll({ + where: { id: [...new Set(rows.map((row) => row.show_id))] }, + attributes: ['id', 'name'], + }); + const showNames = new Map(shows.map((show) => [show.id, show.name])); + const rowsById = new Map(rows.map((row) => [row.youtube_id, row])); + + for (const video of withFiles) { + const row = rowsById.get(video.youtubeId); + if (!row || row.season === null || row.episode === null || !row.file_stem) continue; + if (!path.basename(video.filePath).startsWith(row.file_stem)) continue; + result.set(video.youtubeId, { + showName: showNames.get(row.show_id) || null, + season: row.season, + episode: row.episode, + code: episodeCode({ season: row.season, episode: row.episode, dateNumbered: row.source === SOURCE_DATE }), + }); + } + return result; +} + +module.exports = { + getEpisodeInfoMap +}; diff --git a/server/modules/tvShows/episodeNaming.js b/server/modules/tvShows/episodeNaming.js index ade8d61a9..35862c382 100644 --- a/server/modules/tvShows/episodeNaming.js +++ b/server/modules/tvShows/episodeNaming.js @@ -72,9 +72,26 @@ function buildEpisodeStem({ season, episode, dateNumbered = false, episodeTitle, return title ? `${code} - ${title} [${youtubeId}]` : `${code} [${youtubeId}]`; } +/** + * New name for one of a video's files once it is an episode: the episode stem + * plus whatever follows the [id] token (".mp4", ".en.srt", "-fanart.jpg"). + * + * @param {string} fileName - A file of the video, e.g. "Channel - Title [id].en.srt" + * @param {string} youtubeId + * @param {string} stem - The episode stem + * @returns {string} + */ +function episodeFileName(fileName, youtubeId, stem) { + const token = `[${youtubeId}]`; + const suffix = fileName.slice(fileName.lastIndexOf(token) + token.length); + const appleDoublePrefix = fileName.startsWith('._') ? '._' : ''; + return `${appleDoublePrefix}${stem}${suffix}`; +} + module.exports = { EPISODE_TITLE_MAX_BYTES, seasonFolderName, episodeCode, - buildEpisodeStem + buildEpisodeStem, + episodeFileName }; diff --git a/server/modules/tvShows/episodePlacement.js b/server/modules/tvShows/episodePlacement.js new file mode 100644 index 000000000..d0731f794 --- /dev/null +++ b/server/modules/tvShows/episodePlacement.js @@ -0,0 +1,173 @@ +/** + * The post-processor's TV branch: decide whether a downloaded video is an + * episode, and if so where it goes and under which name. + * + * //Season NN/. + * + * Files sit directly in the season folder (no per-video folders), renamed to + * the episode stem, so their sidecars (.jpg, ..srt, .nfo) follow it. + */ + +const fs = require('fs'); +const path = require('path'); +const VideoClassification = require('../../models/videoclassification'); +const logger = require('../../logger'); +const { buildSubfolderSegment, isFileForVideo, replaceFileWithRetries, ensureDirWithRetries } = require('../filesystem'); +const { LAYOUT_TV } = require('./constants'); +const { getLayoutResolver } = require('./libraryLayouts'); +const { resolveDestination } = require('./routing'); +const showStore = require('./showStore'); +const { assignDateEpisode, STATUS_ASSIGNED } = require('./episodeAllocator'); +const { seasonFolderName, episodeFileName } = require('./episodeNaming'); +const { dateFromEpisodeCode, buildEpisodeNfo, writeTvShowNfoIfChanged } = require('./tvNfo'); + +const MOVE_RETRY_OPTIONS = { retries: 5, delayMs: 500 }; + +function libraryFolderPath(baseDir, libraryFolder) { + return libraryFolder ? path.join(baseDir, buildSubfolderSegment(libraryFolder)) : baseDir; +} + +/** + * Work out where a downloaded video goes. Returns null for a movie-style + * destination, which is also the result when folder layouts can't be read. + * A failure after that point (show or number) throws: an episode must never + * land movie-style in a TV folder. + * + * @param {Object} params + * @param {string} params.youtubeId + * @param {Object} params.info - yt-dlp info dict + * @param {string|null} params.ownerChannelId - YouTube id of the channel that owns the video + * @param {Object|null} params.channelRecord - The owner's channels row, if tracked + * @param {boolean} params.channelEnabled - The owner channel is tracked and enabled + * @param {string} params.uploaderFolderName - Channel folder name yt-dlp wrote in temp + * @param {string|null} params.resolvedSubfolder - Destination subfolder (null = main folder) + * @param {string} params.baseDir - Downloads folder + * @returns {Promise} + */ +async function planEpisode({ + youtubeId, info, ownerChannelId, channelRecord, channelEnabled, uploaderFolderName, resolvedSubfolder, baseDir, +}) { + let layoutOf; + try { + layoutOf = await getLayoutResolver(); + } catch (err) { + logger.error({ err, youtubeId }, 'Could not read library folder layouts; saving the video movie-style'); + return null; + } + if (layoutOf(resolvedSubfolder || '') !== LAYOUT_TV) return null; + + let channelShow = ownerChannelId ? await showStore.findChannelShow(ownerChannelId) : null; + const folderName = (channelRecord && channelRecord.folder_name) || uploaderFolderName; + const destination = resolveDestination({ + ownerChannel: { + channelId: ownerChannelId, + folderName, + tracked: Boolean(channelRecord), + enabled: Boolean(channelEnabled), + }, + channelShow: channelShow ? showStore.toLocation(channelShow) : null, + resolvedSubfolder, + layoutOf, + }); + if (destination.layout !== LAYOUT_TV) return null; + + // A show left in a folder that has since switched to videos holds no + // files (the layout change requires that), so it follows the channel. + if (channelShow && layoutOf(channelShow.library_folder) !== LAYOUT_TV) { + channelShow = await showStore.relocateChannelShow(channelShow, resolvedSubfolder || ''); + } + const show = channelShow || await showStore.createChannelShow({ + channelId: ownerChannelId, + name: (channelRecord && channelRecord.title) || info.channel || info.uploader || folderName, + folderName, + libraryFolder: destination.libraryFolder, + }); + + const assignment = await assignDateEpisode({ show, youtubeId, channelId: ownerChannelId, info }); + const showDir = path.join(libraryFolderPath(baseDir, show.library_folder), show.folder_name); + return { + show, + assignment, + showDir, + seasonDir: path.join(showDir, seasonFolderName(assignment.season)), + stem: assignment.fileStem, + }; +} + +/** + * Move a video's files from sourceDir into seasonDir under the episode stem. + * A file of the same name is replaced through a staging file, so the earlier + * copy survives a transfer that fails. Only after every move succeeds are the + * video's other files in the season folder removed: a re-download in another + * container (.webm) would otherwise become a second version of the episode on + * Plex and Jellyfin 12. + * + * @returns {Promise} The moved files' new names + */ +async function moveEpisodeFiles({ sourceDir, youtubeId, seasonDir, stem }) { + await ensureDirWithRetries(seasonDir, MOVE_RETRY_OPTIONS); + + const moved = []; + const sources = (await fs.promises.readdir(sourceDir)).filter((file) => isFileForVideo(file, youtubeId)); + for (const file of sources) { + const target = episodeFileName(file, youtubeId, stem); + await replaceFileWithRetries(path.join(sourceDir, file), path.join(seasonDir, target), MOVE_RETRY_OPTIONS); + moved.push(target); + } + + const movedNames = new Set(moved); + const leftovers = (await fs.promises.readdir(seasonDir)) + .filter((file) => isFileForVideo(file, youtubeId) && !movedNames.has(file)); + for (const name of leftovers) { + logger.info({ file: name, seasonDir }, '[Post-Process] Removing an earlier file of this episode'); + await fs.promises.rm(path.join(seasonDir, name), { force: true }); + } + return moved; +} + +/** + * Air date of a show's earliest numbered episode (tvshow.nfo ). + * Date-numbered episode codes sort chronologically. + */ +async function earliestEpisodeDate(showId) { + const first = await VideoClassification.findOne({ + where: { show_id: showId, status: STATUS_ASSIGNED }, + order: [['season', 'ASC'], ['episode', 'ASC']], + attributes: ['season', 'episode'], + }); + return first ? dateFromEpisodeCode(first.season, first.episode) : null; +} + +/** + * Write the episode's .nfo and refresh the show's tvshow.nfo. Run after + * the episode's files are in place, so premiered counts this episode. + * + * @param {Object} params + * @param {Object} params.placement - planEpisode's result + * @param {Object} params.info - yt-dlp info dict (with normalized_rating applied) + * @param {string|null} [params.showPlot] - Show description (the channel description) + */ +async function writeEpisodeMetadata({ placement, info, showPlot = null }) { + const { show, assignment, showDir, seasonDir, stem } = placement; + const episodeXml = buildEpisodeNfo({ + info, + showTitle: show.name, + season: assignment.season, + episode: assignment.episode, + episodeTitle: assignment.episodeTitle, + }); + await fs.promises.writeFile(path.join(seasonDir, `${stem}.nfo`), episodeXml, 'utf8'); + await writeTvShowNfoIfChanged(showDir, { + title: show.name, + plot: showPlot, + premiered: await earliestEpisodeDate(show.id), + externalKey: show.external_key, + }); +} + +module.exports = { + planEpisode, + moveEpisodeFiles, + earliestEpisodeDate, + writeEpisodeMetadata +}; diff --git a/server/modules/tvShows/index.js b/server/modules/tvShows/index.js index b26f49d05..1096d4c9f 100644 --- a/server/modules/tvShows/index.js +++ b/server/modules/tvShows/index.js @@ -2,20 +2,52 @@ * TV show support: channels and title-pattern shows saved as TV shows * (//Season NN/SxxEyy - Title [id].ext). * + * - constants: layouts and show kinds (no dependencies) * - libraryLayouts: the layout (videos or tv) of each library folder + * - libraryFolders: the folder list and layout changes (Settings) + * - layoutGuards: refusals that keep a folder from mixing layouts + * - channelFolders: where a channel's files live (channel or show folder) + * - channelLayout: a channel's TV state and its folder changes + * - showStore: stored shows and their pinned locations * - episodeNaming: season folder names and episode file stems * - dateNumbering: upload-time season and episode numbers + * - episodeAllocator: stored, unique episode numbers per show * - routing: where a downloaded video belongs + * - episodePlacement: the post-processor's TV branch + * - tvNfo: episode NFO and tvshow.nfo files + * - episodeInfo: episode details for API responses + * + * Callers require the submodule they need; most of them read the database. */ +const constants = require('./constants'); const libraryLayouts = require('./libraryLayouts'); +const libraryFolders = require('./libraryFolders'); +const layoutGuards = require('./layoutGuards'); +const channelFolders = require('./channelFolders'); +const channelLayout = require('./channelLayout'); +const showStore = require('./showStore'); const episodeNaming = require('./episodeNaming'); const dateNumbering = require('./dateNumbering'); +const episodeAllocator = require('./episodeAllocator'); const routing = require('./routing'); +const episodePlacement = require('./episodePlacement'); +const tvNfo = require('./tvNfo'); +const episodeInfo = require('./episodeInfo'); module.exports = { + constants, libraryLayouts, + libraryFolders, + layoutGuards, + channelFolders, + channelLayout, + showStore, episodeNaming, dateNumbering, - routing + episodeAllocator, + routing, + episodePlacement, + tvNfo, + episodeInfo }; diff --git a/server/modules/tvShows/layoutGuards.js b/server/modules/tvShows/layoutGuards.js new file mode 100644 index 000000000..bf71276b7 --- /dev/null +++ b/server/modules/tvShows/layoutGuards.js @@ -0,0 +1,167 @@ +/** + * Refusals that keep a library folder from mixing layouts. Until downloads can + * be reorganized, a channel or folder that already holds downloaded videos + * can't switch between videos and TV, and nothing switches while a download + * runs (its later videos would land in the new layout, its earlier ones in + * the old). TV layout is also video-only: MP3 downloads stay out of TV folders. + */ + +const fs = require('fs'); +const path = require('path'); +const Channel = require('../../models/channel'); +const Playlist = require('../../models/playlist'); +const Video = require('../../models/video'); +const VideoClassification = require('../../models/videoclassification'); +const configModule = require('../configModule'); +const { buildSubfolderSegment, directoryHasFiles, GLOBAL_DEFAULT_SENTINEL } = require('../filesystem'); +const { effectiveLibraryFolder } = require('./channelFolders'); +const { getLayoutResolver } = require('./libraryLayouts'); +const { LAYOUT_TV, folderKey, isMp3Format, MP3_AUDIO_FORMATS } = require('./constants'); + +function guardError(message, status) { + const err = new Error(message); + err.status = status; + return err; +} + +/** + * @param {() => boolean} [isDownloadRunning] + * @param {string} message + */ +function assertNoDownloadRunning(isDownloadRunning, message) { + if (isDownloadRunning && isDownloadRunning()) { + throw guardError(message, 409); + } +} + +async function channelHasDownloads(channelId) { + if ((await Video.count({ where: { channel_id: channelId, removed: false } })) > 0) return true; + // A VEVO/Topic upload routed to its owner's show keeps the uploader's id on + // its videos row, so the owner's episodes are found through their classifications. + const episodes = await VideoClassification.findAll({ where: { channel_id: channelId }, attributes: ['youtube_id'] }); + if (episodes.length === 0) return false; + const youtubeIds = episodes.map((row) => row.youtube_id); + return (await Video.count({ where: { youtubeId: youtubeIds, removed: false } })) > 0; +} + +// Downloaded files directly in the main folder; __subfolders, the local temp +// folder and dotfiles are not the main folder's content. +async function mainFolderHasFiles(baseDir) { + let entries; + try { + entries = await fs.promises.readdir(baseDir, { withFileTypes: true }); + } catch (err) { + if (err.code === 'ENOENT') return false; + throw err; + } + for (const entry of entries) { + if (entry.name.startsWith('__') || entry.name.startsWith('.')) continue; + if (!entry.isDirectory()) return true; + if (await directoryHasFiles(path.join(baseDir, entry.name))) return true; + } + return false; +} + +/** + * @param {string} libraryFolder - '' for the main folder + * @returns {Promise} + */ +async function folderHasFiles(libraryFolder) { + const baseDir = configModule.directoryPath; + if (!libraryFolder) return mainFolderHasFiles(baseDir); + return directoryHasFiles(path.join(baseDir, buildSubfolderSegment(libraryFolder))); +} + +/** + * Channels and enabled playlists whose downloads resolve into a library folder. + * @param {string} libraryFolder + * @param {{subFolderOf?: (value: string|null) => string}} [options] - Override how + * sentinel values resolve (e.g. against a default subfolder about to be saved) + */ +async function usersOfFolder(libraryFolder, { subFolderOf = effectiveLibraryFolder } = {}) { + const key = folderKey(libraryFolder); + const [channels, playlists] = await Promise.all([ + Channel.findAll({ attributes: ['channel_id', 'title', 'sub_folder', 'audio_format', 'enabled'] }), + Playlist.findAll({ where: { enabled: true }, attributes: ['playlist_id', 'title', 'default_sub_folder', 'audio_format'] }), + ]); + return { + channels: channels.filter((c) => folderKey(subFolderOf(c.sub_folder)) === key), + playlists: playlists.filter((p) => folderKey(subFolderOf(p.default_sub_folder)) === key), + }; +} + +/** + * Channels and enabled playlists set to follow the global default subfolder. + */ +async function usersOfGlobalDefault() { + const [channels, playlists] = await Promise.all([ + Channel.findAll({ + where: { sub_folder: GLOBAL_DEFAULT_SENTINEL }, + attributes: ['channel_id', 'title', 'sub_folder', 'audio_format', 'enabled'], + }), + Playlist.findAll({ + where: { enabled: true, default_sub_folder: GLOBAL_DEFAULT_SENTINEL }, + attributes: ['playlist_id', 'title', 'default_sub_folder', 'audio_format'], + }), + ]); + return { channels, playlists }; +} + +/** + * Refuse saving MP3 downloads into a TV folder. + * @param {{channels: Array, playlists: Array}} users + * @param {string} folderDescription - e.g. 'this folder' + */ +function assertNoMp3Users({ channels, playlists }, folderDescription) { + const mp3Channels = channels.filter((c) => c.enabled && isMp3Format(c.audio_format)); + const mp3Playlists = playlists.filter((p) => isMp3Format(p.audio_format)); + if (mp3Channels.length === 0 && mp3Playlists.length === 0) return; + const names = [...mp3Channels, ...mp3Playlists].map((item) => item.title).filter(Boolean).slice(0, 5); + throw guardError( + `TV shows are video-only, and ${mp3Channels.length + mp3Playlists.length} channel(s) or playlist(s) in ` + + `${folderDescription} download MP3${names.length ? ` (${names.join(', ')})` : ''}. Change their download type to Video first.`, + 409 + ); +} + +/** + * Refuse an MP3 download type for a destination folder with the TV layout. + * @param {Object} params + * @param {string|null|undefined} params.audioFormat + * @param {string|null|undefined} params.subFolderValue - sub_folder-style value (name or sentinel) + */ +async function assertVideoOnlyDestination({ audioFormat, subFolderValue }) { + if (!isMp3Format(audioFormat)) return; + const layoutOf = await getLayoutResolver(); + if (layoutOf(effectiveLibraryFolder(subFolderValue)) === LAYOUT_TV) { + throw guardError('TV folders are video-only. Choose Video as the download type, or a Videos folder.', 400); + } +} + +/** + * Refuse when any of the channels has downloaded videos. + * @param {Array} channels - channels rows + * @param {string} message + */ +async function assertChannelsHaveNoDownloads(channels, message) { + for (const channel of channels) { + if (await channelHasDownloads(channel.channel_id)) { + throw guardError(message, 409); + } + } +} + +module.exports = { + MP3_AUDIO_FORMATS, + guardError, + isMp3Format, + assertNoDownloadRunning, + channelHasDownloads, + mainFolderHasFiles, + folderHasFiles, + usersOfFolder, + usersOfGlobalDefault, + assertNoMp3Users, + assertVideoOnlyDestination, + assertChannelsHaveNoDownloads +}; diff --git a/server/modules/tvShows/libraryFolders.js b/server/modules/tvShows/libraryFolders.js new file mode 100644 index 000000000..6fd9d3250 --- /dev/null +++ b/server/modules/tvShows/libraryFolders.js @@ -0,0 +1,161 @@ +/** + * Library folders as Settings shows them: the main downloads folder and each + * __subfolder, with its layout and what uses it. Changing a folder's layout, + * or moving the default subfolder to a folder with another layout, changes + * the layout of every channel that downloads there, so both are refused + * while that would leave a folder with videos of the old layout. + */ + +const fs = require('fs'); +const path = require('path'); +const configModule = require('../configModule'); +const subfolderModule = require('../subfolderModule'); +const logger = require('../../logger'); +const Channel = require('../../models/channel'); +const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey } = require('./constants'); +const libraryLayouts = require('./libraryLayouts'); +const layoutGuards = require('./layoutGuards'); +const { effectiveLibraryFolder } = require('./channelFolders'); + +const LAYOUTS = new Set([LAYOUT_VIDEOS, LAYOUT_TV]); +// Main folder as TV: a Plex TV library pointed there skips the subfolders. +const PLEXIGNORE_NAME = '.plexignore'; +const PLEXIGNORE_SUBFOLDER_RULE = '__*/*'; +const PLEXIGNORE_CONTENT = `${PLEXIGNORE_SUBFOLDER_RULE}\n`; + +const MESSAGES = { + running: 'Wait for the current download to finish before changing a folder\'s layout.', + hasFiles: 'This folder already holds downloaded videos, so its layout can\'t change yet.', + defaultRunning: 'Wait for the current download to finish before switching the default subfolder to a folder ' + + 'with a different layout.', + defaultHasDownloads: 'Channels that use the default subfolder already have downloaded videos, so the default can\'t ' + + 'switch to a folder with a different layout yet.', +}; + +/** + * @returns {Promise>} + * name '' is the main folder, listed first + */ +async function listLibraryFolders() { + const [layoutOf, usage, channels] = await Promise.all([ + libraryLayouts.getLayoutResolver(), + subfolderModule.getUsage(), + Channel.findAll({ where: { enabled: true }, attributes: ['sub_folder'] }), + ]); + const channelCounts = new Map(); + for (const channel of channels) { + const key = folderKey(effectiveLibraryFolder(channel.sub_folder)); + channelCounts.set(key, (channelCounts.get(key) || 0) + 1); + } + const defaultFolder = configModule.getDefaultSubfolder() || ''; + const main = { + name: '', + layout: layoutOf(''), + isDefault: !defaultFolder, + hasFiles: await layoutGuards.folderHasFiles(''), + channels: channelCounts.get('') || 0, + }; + const subfolders = usage.map((item) => ({ + name: item.name, + layout: layoutOf(item.name), + isDefault: item.usage.isDefault, + hasFiles: item.usage.hasFiles, + channels: channelCounts.get(folderKey(item.name)) || 0, + })); + return [main, ...subfolders]; +} + +async function syncPlexIgnore(layout) { + const filePath = path.join(configModule.directoryPath, PLEXIGNORE_NAME); + let current = null; + try { + current = await fs.promises.readFile(filePath, 'utf8'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + if (layout === LAYOUT_TV) { + if (current === null) { + await fs.promises.writeFile(filePath, PLEXIGNORE_CONTENT, 'utf8'); + } else if (!current.split(/\r?\n/).some((line) => line.trim() === PLEXIGNORE_SUBFOLDER_RULE)) { + const separator = current === '' || current.endsWith('\n') ? '' : '\n'; + await fs.promises.writeFile(filePath, `${current}${separator}${PLEXIGNORE_CONTENT}`, 'utf8'); + } + } else if (current === PLEXIGNORE_CONTENT) { + // Only a file exactly as Youtarr wrote it; the user's own rules stay. + await fs.promises.unlink(filePath); + } +} + +/** + * Change a library folder's layout. + * @param {string} name - '' for the main folder, else the subfolder name without __ + * @param {string} layout - 'videos' | 'tv' + * @param {Object} [options] + * @param {() => boolean} [options.isDownloadRunning] + * @returns {Promise<{changed: boolean}>} + */ +async function setFolderLayout(name, layout, { isDownloadRunning } = {}) { + if (!LAYOUTS.has(layout)) { + throw layoutGuards.guardError('layout must be "videos" or "tv"', 400); + } + const folder = String(name || '').trim(); + if (folder) { + const known = (await subfolderModule.getAll()) + .some((display) => folderKey(display.replace(/^__/, '')) === folderKey(folder)); + if (!known) throw layoutGuards.guardError('Subfolder not found', 404); + } + const layoutOf = await libraryLayouts.getLayoutResolver(); + if (layoutOf(folder) === layout) return { changed: false }; + + layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.running); + const users = await layoutGuards.usersOfFolder(folder); + if (layout === LAYOUT_TV) layoutGuards.assertNoMp3Users(users, 'this folder'); + if (await layoutGuards.folderHasFiles(folder)) { + throw layoutGuards.guardError(MESSAGES.hasFiles, 409); + } + await layoutGuards.assertChannelsHaveNoDownloads(users.channels, MESSAGES.hasFiles); + + // A subfolder known only from config (the default subfolder, Plex mappings) + // gets its row before the layout is stored on it. + if (folder) await subfolderModule.register(folder); + await libraryLayouts.setLayout(folder, layout); + logger.info({ libraryFolder: folder, layout }, 'Library folder layout changed'); + if (!folder) { + try { + await syncPlexIgnore(layout); + } catch (err) { + logger.error({ err }, 'Could not update the main folder .plexignore'); + } + } + return { changed: true }; +} + +/** + * Refuse a default subfolder change that would switch the channels and + * playlists following the default between videos and TV while that can't + * be done cleanly. + * + * @param {Object} params + * @param {string|null} params.oldDefault + * @param {string|null} params.newDefault + * @param {() => boolean} [params.isDownloadRunning] + */ +async function checkDefaultSubfolderChange({ oldDefault, newDefault, isDownloadRunning }) { + const layoutOf = await libraryLayouts.getLayoutResolver(); + const newLayout = layoutOf(String(newDefault || '').trim()); + if (layoutOf(String(oldDefault || '').trim()) === newLayout) return; + + const users = await layoutGuards.usersOfGlobalDefault(); + if (newLayout === LAYOUT_TV) layoutGuards.assertNoMp3Users(users, 'the default subfolder'); + layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.defaultRunning); + await layoutGuards.assertChannelsHaveNoDownloads(users.channels, MESSAGES.defaultHasDownloads); +} + +module.exports = { + PLEXIGNORE_NAME, + PLEXIGNORE_CONTENT, + MESSAGES, + listLibraryFolders, + setFolderLayout, + checkDefaultSubfolderChange +}; diff --git a/server/modules/tvShows/libraryLayouts.js b/server/modules/tvShows/libraryLayouts.js index 0e1a337d7..5ab1e0e5d 100644 --- a/server/modules/tvShows/libraryLayouts.js +++ b/server/modules/tvShows/libraryLayouts.js @@ -2,23 +2,73 @@ * Layout of each library folder: the downloads folder itself ('') and each * __subfolder (named without the prefix). Media servers fix a library's type * per folder, so a folder holds either movie-style videos or TV shows. + * + * The main folder's layout is the mainFolderLayout config field; subfolder + * layouts are stored on their subfolders row. */ -const LAYOUT_VIDEOS = 'videos'; -const LAYOUT_TV = 'tv'; +const Subfolder = require('../../models/subfolder'); +const configModule = require('../configModule'); +const { LAYOUT_VIDEOS, LAYOUT_TV, folderKey } = require('./constants'); + +const LAYOUTS = new Set([LAYOUT_VIDEOS, LAYOUT_TV]); + +function normalizeLayout(value) { + return value === LAYOUT_TV ? LAYOUT_TV : LAYOUT_VIDEOS; +} + /** - * Resolve library folder layouts. Every folder uses the videos layout until - * folder layouts can be configured. + * Resolve library folder layouts. One query per call, so callers resolve once + * per operation and reuse the returned function. * * @returns {Promise<(libraryFolder: string) => string>} */ async function getLayoutResolver() { - return () => LAYOUT_VIDEOS; + const mainLayout = normalizeLayout((configModule.getConfig() || {}).mainFolderLayout); + const rows = await Subfolder.findAll({ where: { layout: LAYOUT_TV }, attributes: ['name'] }); + const tvFolders = new Set(rows.map((row) => folderKey(row.name))); + return (libraryFolder) => { + const key = folderKey(libraryFolder); + if (!key) return mainLayout; + return tvFolders.has(key) ? LAYOUT_TV : LAYOUT_VIDEOS; + }; +} + +/** + * Library folders with the TV layout ('' = main folder), main folder first. + * @returns {Promise} + */ +async function listTvFolders() { + const mainLayout = normalizeLayout((configModule.getConfig() || {}).mainFolderLayout); + const rows = await Subfolder.findAll({ where: { layout: LAYOUT_TV }, attributes: ['name'] }); + const names = rows.map((row) => row.name.trim()).sort((a, b) => a.localeCompare(b)); + return mainLayout === LAYOUT_TV ? ['', ...names] : names; +} + +/** + * Store a library folder's layout. A subfolder must have its subfolders row + * already (libraryFolders registers one known only from config first). + * + * @param {string} libraryFolder - '' for the main folder, else the subfolder name without __ + * @param {string} layout - LAYOUT_VIDEOS or LAYOUT_TV + */ +async function setLayout(libraryFolder, layout) { + if (!LAYOUTS.has(layout)) { + throw new TypeError(`Unknown library folder layout: ${layout}`); + } + const name = String(libraryFolder || '').trim(); + if (!name) { + configModule.updateConfig({ ...configModule.getConfig(), mainFolderLayout: layout }); + return; + } + await Subfolder.update({ layout }, { where: { name } }); } module.exports = { LAYOUT_VIDEOS, LAYOUT_TV, - getLayoutResolver + getLayoutResolver, + listTvFolders, + setLayout }; diff --git a/server/modules/tvShows/routing.js b/server/modules/tvShows/routing.js index b8730af3e..51178cabf 100644 --- a/server/modules/tvShows/routing.js +++ b/server/modules/tvShows/routing.js @@ -14,12 +14,9 @@ * 4. In a videos folder the video is saved movie-style. */ -const { LAYOUT_VIDEOS, LAYOUT_TV } = require('./libraryLayouts'); +const { LAYOUT_VIDEOS, LAYOUT_TV, KIND_TITLE_SHOW, KIND_CHANNEL_SHOW } = require('./constants'); const { MAIN_LIBRARY_FOLDER } = require('../filesystem/constants'); -const KIND_TITLE_SHOW = 'title'; -const KIND_CHANNEL_SHOW = 'channel'; - /** * @param {Object} inputs * @param {{channelId: string, folderName: string, tracked: boolean, enabled: boolean}} inputs.ownerChannel diff --git a/server/modules/tvShows/showStore.js b/server/modules/tvShows/showStore.js new file mode 100644 index 000000000..c2822f848 --- /dev/null +++ b/server/modules/tvShows/showStore.js @@ -0,0 +1,129 @@ +/** + * Stored shows (tv_shows). A channel show is created once per channel and its + * location (library folder + folder name) is pinned at creation, so a later + * uploader rename or default-folder change never starts a second show folder. + */ + +const TvShow = require('../../models/tvshow'); +const { sanitizeFilenameLikeYtDlp, sanitizeNameLikeYtDlp } = require('../filesystem/sanitizer'); +const { SEASON_FOLDER_PATTERN, SUBFOLDER_PREFIX } = require('../filesystem/constants'); +const { KIND_CHANNEL_SHOW } = require('./constants'); + +// Same cap as the channel folder in the yt-dlp output template (.80B). +const SHOW_FOLDER_MAX_BYTES = 80; + +// Folder names Jellyfin reads as extras inside a show (compared ignoring case). +const EXTRAS_FOLDER_NAMES = new Set([ + 'trailers', 'backdrops', 'theme-music', 'behind the scenes', 'deleted scenes', 'interviews', + 'scenes', 'samples', 'sample', 'shorts', 'featurettes', 'extras', 'extra', 'other', 'clips', +]); + +/** + * Why a name can't be a show folder, or null when it can. + * @param {string} name + * @returns {string|null} + */ +function showFolderNameProblem(name) { + const value = typeof name === 'string' ? name.trim() : ''; + if (!value) return 'Show folder name is empty'; + if (value.startsWith(SUBFOLDER_PREFIX)) return `Show folder name can't start with ${SUBFOLDER_PREFIX}`; + if (value.startsWith('.')) return 'Show folder name can\'t start with .'; + if (/[/\\]/.test(value)) return 'Show folder name can\'t contain a path separator'; + if (SEASON_FOLDER_PATTERN.test(value)) return 'Show folder name can\'t be a season folder name'; + if (EXTRAS_FOLDER_NAMES.has(value.toLowerCase())) return `"${value}" is a media-server extras folder name`; + return null; +} + +function sanitizeShowFolderName(name) { + const sanitized = sanitizeFilenameLikeYtDlp(String(name || ''), { maxBytes: SHOW_FOLDER_MAX_BYTES }); + return sanitized ? sanitizeNameLikeYtDlp(sanitized) : ''; +} + +// Folder names to try in order: the name itself, then the name with the +// channel id (a second channel with the same name, or a name media servers +// treat specially), then the channel id alone. +function folderNameCandidates(baseName, channelId) { + const suffix = ` (${channelId})`; + const base = baseName.endsWith(suffix) ? baseName.slice(0, -suffix.length) : baseName; + return [base, `${base}${suffix}`, channelId] + .filter((name) => name && showFolderNameProblem(name) === null); +} + +function isUniqueConstraintError(err) { + return Boolean(err && err.name === 'SequelizeUniqueConstraintError'); +} + +async function firstFreeFolderName(candidates, write) { + for (const folderName of candidates) { + try { + return await write(folderName); + } catch (err) { + if (!isUniqueConstraintError(err)) throw err; + } + } + throw new Error(`No free show folder name among: ${candidates.join(', ')}`); +} + +/** + * The channel's active channel show, if it has one. + * @param {string} channelId - YouTube channel id + */ +async function findChannelShow(channelId) { + return TvShow.findOne({ where: { channel_id: channelId, kind: KIND_CHANNEL_SHOW, retired_at: null } }); +} + +/** + * Return the channel's channel show, creating it at libraryFolder when it + * doesn't exist yet. + * + * @param {Object} params + * @param {string} params.channelId - YouTube channel id (also the show's external key) + * @param {string} params.name - Show name (the channel title) + * @param {string} params.folderName - Wanted folder name; sanitized here + * @param {string} params.libraryFolder - '' for the main folder, else the subfolder name + * @param {string|null} [params.previousVideosFolder] - The channel's sub_folder before it switched to TV + */ +async function createChannelShow({ channelId, name, folderName, libraryFolder, previousVideosFolder = null }) { + const existing = await findChannelShow(channelId); + if (existing) return existing; + + const candidates = folderNameCandidates(sanitizeShowFolderName(folderName || name), channelId); + return firstFreeFolderName(candidates, (folder) => TvShow.create({ + channel_id: channelId, + kind: KIND_CHANNEL_SHOW, + name: name || folder, + folder_name: folder, + library_folder: libraryFolder || '', + external_key: channelId, + previous_videos_folder: previousVideosFolder, + })); +} + +/** + * Point a channel show at another library folder. Only for shows with no + * files on disk: nothing is moved. + */ +async function relocateChannelShow(show, libraryFolder) { + const candidates = folderNameCandidates(show.folder_name, show.channel_id); + return firstFreeFolderName(candidates, async (folder) => { + await show.update({ library_folder: libraryFolder || '', folder_name: folder }); + return show; + }); +} + +/** + * @param {Object} show - tv_shows row + * @returns {{id: number, libraryFolder: string, folderName: string}} + */ +function toLocation(show) { + return { id: show.id, libraryFolder: show.library_folder || '', folderName: show.folder_name }; +} + +module.exports = { + SHOW_FOLDER_MAX_BYTES, + showFolderNameProblem, + findChannelShow, + createChannelShow, + relocateChannelShow, + toLocation +}; diff --git a/server/modules/tvShows/tvNfo.js b/server/modules/tvShows/tvNfo.js new file mode 100644 index 000000000..410d1e7c3 --- /dev/null +++ b/server/modules/tvShows/tvNfo.js @@ -0,0 +1,123 @@ +/** + * NFO files for TV layout: .nfo (episodedetails) next to each episode + * and tvshow.nfo in the show folder. Read by Jellyfin, Emby, Kodi and Plex's + * NFO agents. Both are written whatever writeVideoNfoFiles says: without the + * episode NFO, Jellyfin titles episodes after the file name. + * + * Never written: lockdata (Jellyfin would stop re-reading the NFO), + * dateadded (it would change on every rewrite), and relative artwork paths + * (Jellyfin rejects them). + */ + +const fs = require('fs'); +const path = require('path'); +const nfoGenerator = require('../nfoGenerator'); + +const TV_SHOW_NFO_NAME = 'tvshow.nfo'; +const SHOW_STUDIO = 'YouTube'; +const XML_HEADER = '\n'; + +const escape = (text) => nfoGenerator.escapeXml(text); + +function element(name, value) { + return ` <${name}>${escape(value)}\n`; +} + +/** + * The day a date-numbered episode code stands for (season = year, episode = + * MMDDHHMM, possibly bumped past 59 minutes), or null when it isn't a date. + */ +function dateFromEpisodeCode(season, episode) { + const month = Math.floor(episode / 1000000); + const day = Math.floor(episode / 10000) % 100; + const date = new Date(Date.UTC(season, month - 1, day)); + if (season < 1000 || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + return null; + } + return `${season}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** + * @param {Object} params + * @param {Object} params.info - yt-dlp info dict (with normalized_rating applied) + * @param {string} params.showTitle + * @param {number} params.season + * @param {number} params.episode + * @param {string} [params.episodeTitle] - Defaults to the video title; never channel-prefixed + * @returns {string} + */ +function buildEpisodeNfo({ info, showTitle, season, episode, episodeTitle }) { + const title = episodeTitle || info.fulltitle || info.title || 'Unknown Title'; + const aired = nfoGenerator.formatDate(info.upload_date); + const studio = info.uploader || info.channel || info.uploader_id || info.channel_id || ''; + const runtime = nfoGenerator.calculateRuntime(info.duration); + + let xml = XML_HEADER; + xml += '\n'; + xml += element('title', title); + xml += element('showtitle', showTitle); + xml += ` ${season}\n`; + xml += ` ${episode}\n`; + if (info.description) xml += element('plot', info.description); + if (aired) { + xml += element('aired', aired); + xml += element('premiered', aired); + } + if (runtime > 0) xml += ` ${runtime}\n`; + if (studio) xml += element('studio', studio); + for (const genre of info.categories || []) xml += element('genre', genre); + for (const tag of info.tags || []) xml += element('tag', tag); + if (info.normalized_rating) xml += element('mpaa', info.normalized_rating); + if (info.id) xml += ` ${escape(info.id)}\n`; + xml += '\n'; + return xml; +} + +/** + * @param {Object} params + * @param {string} params.title - Show title + * @param {string} [params.plot] + * @param {string} [params.premiered] - YYYY-MM-DD of the earliest episode + * @param {string} params.externalKey - Stable show id (the channel id for channel shows) + * @returns {string} + */ +function buildTvShowNfo({ title, plot, premiered, externalKey }) { + let xml = XML_HEADER; + xml += '\n'; + xml += element('title', title); + if (plot) xml += element('plot', plot); + if (premiered) xml += element('premiered', premiered); + xml += element('studio', SHOW_STUDIO); + // The custom id keeps Jellyfin's played state across a folder rename and + // stops Jellyfin 12 merging two same-named shows. + xml += ` ${escape(externalKey)}\n`; + xml += ` ${escape(externalKey)}\n`; + xml += '\n'; + return xml; +} + +/** + * Write tvshow.nfo when it is missing or its content would change. + * @returns {Promise} true when the file was written + */ +async function writeTvShowNfoIfChanged(showDir, params) { + const nfoPath = path.join(showDir, TV_SHOW_NFO_NAME); + const xml = buildTvShowNfo(params); + let current = null; + try { + current = await fs.promises.readFile(nfoPath, 'utf8'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + if (current === xml) return false; + await fs.promises.writeFile(nfoPath, xml, 'utf8'); + return true; +} + +module.exports = { + TV_SHOW_NFO_NAME, + dateFromEpisodeCode, + buildEpisodeNfo, + buildTvShowNfo, + writeTvShowNfoIfChanged +}; diff --git a/server/modules/videoDownloadPostProcessFiles.js b/server/modules/videoDownloadPostProcessFiles.js index ea5ed5e7d..0cb970de1 100644 --- a/server/modules/videoDownloadPostProcessFiles.js +++ b/server/modules/videoDownloadPostProcessFiles.js @@ -15,6 +15,8 @@ const { VIDEO_PERSISTED_MARKER } = require('./constants/outputMarkers'); const logger = require('../logger'); const logLevelSync = require('./logLevelSync'); const { buildChannelPath, isFileForVideo, cleanupEmptyParents, moveWithRetries, ensureDirWithRetries, copySyncWithFallback } = require('./filesystem'); +const episodePlacement = require('./tvShows/episodePlacement'); +const { episodeFileName } = require('./tvShows/episodeNaming'); // Match the server's log level, including a level chosen in Settings. Quietly: // this process starts at LOG_LEVEL for every video, and its output is relayed @@ -106,13 +108,21 @@ function shouldPrefixChannelNameInTitle() { // AtomicParsley writes directly to the iTunes atom container (moov.udta.meta.ilst) // which Plex reads for "Other Videos" / Personal Media libraries. // It modifies the file in-place (--overWrite), so no temp file dance is needed. -function embedVideoMetadata(targetPath, jsonData) { +// tvEpisode ({ showName, episodeTitle }) tags the file as a TV episode. The +// season and episode atoms are never written: AtomicParsley keeps 16 bits, +// ffprobe reads 8, and Jellyfin's "Replace all metadata" would read them. +function embedVideoMetadata(targetPath, jsonData, tvEpisode = null) { try { const apArgs = [targetPath]; - // Title: "Channel - Title" unless the channel prefix is turned off + // Title: "Channel - Title" unless the channel prefix is turned off; an + // episode title is never prefixed. const channelName = jsonData.uploader || jsonData.channel || jsonData.uploader_id || ''; - if (channelName && jsonData.title) { + if (tvEpisode) { + apArgs.push('--title', tvEpisode.episodeTitle || jsonData.title || ''); + apArgs.push('--TVShowName', tvEpisode.showName); + apArgs.push('--TVEpisode', jsonData.id); + } else if (channelName && jsonData.title) { const title = shouldPrefixChannelNameInTitle() ? `${channelName} - ${jsonData.title}` : jsonData.title; @@ -157,7 +167,7 @@ function embedVideoMetadata(targetPath, jsonData) { } // Media type (stik=9 → Movie, used by Plex for personal media) - apArgs.push('--stik', 'Movie'); + apArgs.push('--stik', tvEpisode ? 'TV Show' : 'Movie'); // Content rating via iTunEXTC atom — this is what Plex actually reads const iTunEXTC = ratingMapper.mapToITunEXTC(jsonData.normalized_rating); @@ -358,6 +368,33 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { } } +// Clean up yt-dlp intermediate files before moving +// In video_mp3 mode with --extract-audio --keep-video, yt-dlp doesn't always +// clean up these intermediate files as it normally would +async function removeYtdlpIntermediates(videoDirectory) { + const filesInDir = await fs.readdir(videoDirectory); + for (const file of filesInDir) { + // Match yt-dlp fragment patterns: .f###.ext or .f###-###.ext where ext is mp4/m4a/webm/mkv + if (/\.f[\d-]+\.(mp4|m4a|webm|mkv)$/i.test(file)) { + const fragmentPath = path.join(videoDirectory, file); + logger.info({ fragmentPath }, '[Post-Process] Removing yt-dlp fragment file'); + await fs.remove(fragmentPath); + } + // Remove original thumbnail files (.webp) - these should have been converted to .jpg + else if (/\.webp$/i.test(file)) { + const webpPath = path.join(videoDirectory, file); + logger.info({ webpPath }, '[Post-Process] Removing original webp thumbnail'); + await fs.remove(webpPath); + } + // Remove original subtitle files (.vtt) - these should have been converted to .srt + else if (/\.vtt$/i.test(file)) { + const vttPath = path.join(videoDirectory, file); + logger.info({ vttPath }, '[Post-Process] Removing original vtt subtitle'); + await fs.remove(vttPath); + } + } +} + // Main execution wrapped in async IIFE to handle async operations (async () => { if (fs.existsSync(jsonPath)) { @@ -439,7 +476,7 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { const channelId = lookupChannelId; channelRecord = await Channel.findOne({ where: { channel_id: channelId }, - attributes: ['id', 'sub_folder', 'title', 'uploader', 'folder_name', 'default_rating', 'enabled', 'skip_video_folder', 'additional_tags'] + attributes: ['id', 'sub_folder', 'title', 'uploader', 'folder_name', 'default_rating', 'enabled', 'skip_video_folder', 'additional_tags', 'description'] }); logger.info({ channelId, ownerProvided: !!ownerChannelId, found: !!channelRecord }, 'Post-process channel lookup'); @@ -556,11 +593,39 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { } logger.info({ subfolder: channelSubFolder }, 'Post-process resolved target subfolder'); + // TV layout: the episode's show, number and file name; null saves the + // video movie-style. Throws rather than misplace an episode. + let tvPlacement = null; + try { + tvPlacement = await episodePlacement.planEpisode({ + youtubeId: id, + info: jsonData, + ownerChannelId: lookupChannelId, + channelRecord, + channelEnabled: Boolean(settingsChannelRecord), + uploaderFolderName: actualChannelFolderName, + resolvedSubfolder: channelSubFolder, + baseDir: configModule.directoryPath, + }); + } catch (err) { + logger.error({ err, id }, '[Post-Process] Could not place the video as a TV episode'); + throw err; + } + // Final name of one of this video's files (episodes are renamed to the stem). + const finalFileName = (filePath) => (tvPlacement + ? episodeFileName(path.basename(filePath), id, tvPlacement.stem) + : path.basename(filePath)); + if (tvPlacement) { + logger.info({ seasonDir: tvPlacement.seasonDir, stem: tvPlacement.stem }, 'Post-process placing the video as a TV episode'); + } + // Phase 2: Calculate the final path for _actual_filepath with subfolder if applicable // Downloads always go to temp first, so we need to store the FINAL path, not the temp path let finalVideoPathForJson; - if (targetChannelFolder) { + if (tvPlacement) { + finalVideoPathForJson = path.join(tvPlacement.seasonDir, finalFileName(videoPath)); + } else if (targetChannelFolder) { // Channel has subfolder - calculate path with subfolder included const videoFileName = path.basename(videoPath); if (outgoingFlat) { @@ -591,7 +656,8 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { fs.moveSync(jsonPath, newJsonPath, { overwrite: true }); // move the file // Generate NFO file for Jellyfin/Kodi/Emby compatibility if enabled - if (shouldWriteVideoNfoFiles()) { + // (episodes get an episode NFO once their files are in place) + if (!tvPlacement && shouldWriteVideoNfoFiles()) { nfoGenerator.writeVideoNfoFile(videoPath, jsonData); } @@ -611,14 +677,23 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { } } + // Settings and the groupers keep MP3 out of TV folders; an MP3 that still + // arrives is placed as the episode, which TV library scanners skip. + if (tvPlacement && isAudioFile && !companionVideoPath) { + logger.warn({ id, seasonDir: tvPlacement.seasonDir }, '[Post-Process] MP3-only download saved as a TV episode; media servers show no video for it'); + } + // MP3 files carry yt-dlp's own embedded tags, so AtomicParsley only runs // against an MP4: the download itself, or the companion in video_mp3 mode. + const tvEpisodeTags = tvPlacement + ? { showName: tvPlacement.show.name, episodeTitle: tvPlacement.assignment.episodeTitle } + : null; if (companionVideoPath) { - embedVideoMetadata(companionVideoPath, jsonData); + embedVideoMetadata(companionVideoPath, jsonData, tvEpisodeTags); } else if (isAudioFile) { logger.info('[Post-Process] Audio file detected, skipping video metadata embedding'); } else { - embedVideoMetadata(videoPath, jsonData); + embedVideoMetadata(videoPath, jsonData, tvEpisodeTags); } if (fs.existsSync(imagePath)) { @@ -693,7 +768,26 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { let finalVideoPath = videoPath; let movedChannelFolderPath = null; - if (tempPathManager.isTempPath(videoPath)) { + if (tvPlacement) { + const { seasonDir, stem, showDir } = tvPlacement; + logger.info({ from: videoDirectory, to: seasonDir }, '[Post-Process] Moving episode files'); + try { + await removeYtdlpIntermediates(videoDirectory); + await episodePlacement.moveEpisodeFiles({ sourceDir: videoDirectory, youtubeId: id, seasonDir, stem }); + if (tempPathManager.isTempPath(videoPath)) { + await cleanupEmptyParents(videoDirectory, tempPathManager.getTempBasePath()); + } + finalVideoPath = path.join(seasonDir, finalFileName(videoPath)); + movedChannelFolderPath = showDir; + } catch (error) { + logger.error({ err: error, src: videoDirectory, dest: seasonDir }, '[Post-Process] ERROR moving episode files'); + process.exit(1); + } + if (!fs.existsSync(finalVideoPath)) { + logger.error({ finalVideoPath }, '[Post-Process] Final episode file doesn\'t exist after move'); + process.exit(1); + } + } else if (tempPathManager.isTempPath(videoPath)) { logger.info({ isFlatMode }, '[Post-Process] Moving files from temp to final location'); // Calculate target video directory based on subfolder setting @@ -727,30 +821,7 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { // Ensure parent channel directory exists (with retries for NFS/cross-filesystem transient errors) await ensureDirWithRetries(targetChannelFolderForMove, { retries: 5, delayMs: 500 }); - // Clean up yt-dlp intermediate files before moving - // In video_mp3 mode with --extract-audio --keep-video, yt-dlp doesn't always - // clean up these intermediate files as it normally would - const filesInDir = await fs.readdir(videoDirectory); - for (const file of filesInDir) { - // Match yt-dlp fragment patterns: .f###.ext or .f###-###.ext where ext is mp4/m4a/webm/mkv - if (/\.f[\d-]+\.(mp4|m4a|webm|mkv)$/i.test(file)) { - const fragmentPath = path.join(videoDirectory, file); - logger.info({ fragmentPath }, '[Post-Process] Removing yt-dlp fragment file'); - await fs.remove(fragmentPath); - } - // Remove original thumbnail files (.webp) - these should have been converted to .jpg - else if (/\.webp$/i.test(file)) { - const webpPath = path.join(videoDirectory, file); - logger.info({ webpPath }, '[Post-Process] Removing original webp thumbnail'); - await fs.remove(webpPath); - } - // Remove original subtitle files (.vtt) - these should have been converted to .srt - else if (/\.vtt$/i.test(file)) { - const vttPath = path.join(videoDirectory, file); - logger.info({ vttPath }, '[Post-Process] Removing original vtt subtitle'); - await fs.remove(vttPath); - } - } + await removeYtdlpIntermediates(videoDirectory); if (outgoingFlat) { // Flat mode: move individual files from temp channel folder to final channel folder @@ -846,7 +917,7 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { // Calculate final path for companion video (same directory as the audio file) const finalCompanionVideoPath = path.join( path.dirname(finalVideoPath), - path.basename(companionVideoPath) + finalFileName(companionVideoPath) ); // Store both paths for videoMetadataProcessor @@ -877,6 +948,18 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { // Don't fail the process, but log the error } + if (tvPlacement) { + try { + await episodePlacement.writeEpisodeMetadata({ + placement: tvPlacement, + info: jsonData, + showPlot: channelRecord ? channelRecord.description : null, + }); + } catch (err) { + logger.warn({ err, id }, '[Post-Process] Error writing episode NFO files'); + } + } + // Create fanart.jpg in video folder if enabled (for Plex background image on compatible clients) // This complements the poster.jpg (which comes from channel thumbnail) if (shouldWriteVideoFanart()) { @@ -924,9 +1007,12 @@ async function resolveTrackedOwnerChannelId(youtubeId, metadataChannelId) { const finalChannelFolderPath = movedChannelFolderPath || (outgoingFlat ? path.dirname(finalVideoPath) : path.dirname(path.dirname(finalVideoPath))); - if (jsonData.channel_id) { - await copyChannelPosterIfNeeded(jsonData.channel_id, finalChannelFolderPath); - await copyChannelBackdropIfNeeded(jsonData.channel_id, finalChannelFolderPath); + // A show's art is its owner channel's, which is not the uploader for a + // VEVO/Topic upload routed to the owner's show. + const artChannelId = tvPlacement ? tvPlacement.show.channel_id : jsonData.channel_id; + if (artChannelId) { + await copyChannelPosterIfNeeded(artChannelId, finalChannelFolderPath); + await copyChannelBackdropIfNeeded(artChannelId, finalChannelFolderPath); } // Save to the videos + channelvideos tables now so listing pages can show diff --git a/server/modules/videoMetadataModule.js b/server/modules/videoMetadataModule.js index 095399eb9..9bd08d4de 100644 --- a/server/modules/videoMetadataModule.js +++ b/server/modules/videoMetadataModule.js @@ -9,6 +9,7 @@ const ChannelVideo = require('../models/channelvideo'); const channelVideoReanchor = require('./channelVideoReanchor'); const { parseTierFromFormatNote, selectionTierForHeight } = require('./resolutionTier'); const { isFileForVideo } = require('./filesystem/pathBuilder'); +const episodeInfo = require('./tvShows/episodeInfo'); const NULL_METADATA = { description: null, @@ -221,6 +222,14 @@ class VideoMetadataModule { // Extract available resolutions from the formats array const availableResolutions = this._extractAvailableResolutions(rawData.formats); + // Show and SxxEyy when the downloaded file is a TV episode (decoration: never fails the request) + const episode = await episodeInfo.getEpisodeInfoMap([{ youtubeId, filePath: rawData._actual_filepath || null }]) + .then((map) => map.get(youtubeId) || null) + .catch((err) => { + logger.warn({ err, youtubeId }, 'Failed to read episode details'); + return null; + }); + return { description: rawData.description ?? null, viewCount: rawData.view_count ?? null, @@ -243,6 +252,7 @@ class VideoMetadataModule { webpageUrl: rawData.webpage_url ?? null, relatedFiles, availableResolutions, + episode, }; } catch (err) { logger.error({ err, youtubeId }, 'Unexpected error in getVideoMetadata'); diff --git a/server/modules/videosModule.js b/server/modules/videosModule.js index 8877d1b3a..495b1c72e 100644 --- a/server/modules/videosModule.js +++ b/server/modules/videosModule.js @@ -6,6 +6,7 @@ const path = require('path'); const configModule = require('./configModule'); const fileCheckModule = require('./fileCheckModule'); const watchStatusQueries = require('./mediaServers/watchStatusQueries'); +const episodeInfo = require('./tvShows/episodeInfo'); const ratingMapper = require('./ratingMapper'); const logger = require('../logger'); const messageEmitter = require('./messageEmitter'); @@ -241,8 +242,12 @@ class VideosModule { // Watched-servers summary for the list UI, honoring the configured // watched rule; per-server detail lives behind /api/videos/:id/watch-status. const watchedByVideoId = await watchStatusQueries.getWatchedByMap(videos.map((v) => v.id)); + const episodesByVideoId = await episodeInfo.getEpisodeInfoMap( + videos.map((v) => ({ youtubeId: v.youtubeId, filePath: v.filePath })) + ); for (const video of videos) { video.watchedBy = watchedByVideoId.get(video.id) || []; + video.episode = episodesByVideoId.get(video.youtubeId) || null; } // Get all unique channels for the filter dropdown diff --git a/server/routes/__tests__/channels.downloadAll.test.js b/server/routes/__tests__/channels.downloadAll.test.js index bfbce7823..0b5e3ad3c 100644 --- a/server/routes/__tests__/channels.downloadAll.test.js +++ b/server/routes/__tests__/channels.downloadAll.test.js @@ -4,6 +4,7 @@ // the route file does not pull in the real database. jest.mock('../../modules/channelSettingsModule', () => ({ validateSubFolder: jest.fn().mockReturnValue({ valid: true }), + getChannelSettings: jest.fn(), })); jest.mock('../../models/channelvideo', () => ({})); @@ -131,6 +132,55 @@ describe('GET /api/channels/:channelId/download-all/preview', () => { }); describe('POST /api/channels/:channelId/download-all', () => { + describe('TV folders', () => { + const channelSettingsModule = require('../../modules/channelSettingsModule'); + const withGuards = () => ({ + ...buildDeps(), + layoutGuards: { + isMp3Format: (format) => format === 'mp3_only' || format === 'video_mp3', + assertVideoOnlyDestination: jest.fn().mockRejectedValue( + Object.assign(new Error('TV folders are video-only.'), { status: 400 }) + ), + }, + }); + const send = async (deps, overrideSettings) => { + const handler = getHandler('post', DOWNLOAD_PATH, deps); + const res = createResponse(); + await handler({ params: { channelId: 'UC123' }, body: { tabType: 'videos', overrideSettings }, log: loggerMock }, res); + return res; + }; + + test('refuses an MP3 download for a channel in a TV folder', async () => { + const deps = withGuards(); + channelSettingsModule.getChannelSettings.mockResolvedValue({ sub_folder: 'TV' }); + + const res = await send(deps, { audioFormat: 'mp3_only' }); + + expect(deps.layoutGuards.assertVideoOnlyDestination).toHaveBeenCalledWith({ audioFormat: 'mp3_only', subFolderValue: 'TV' }); + expect(res.status).toHaveBeenCalledWith(400); + expect(deps.channelDownloadAllModule.startDownloadAll).not.toHaveBeenCalled(); + }); + + test('checks a destination override instead of the channel folder', async () => { + const deps = withGuards(); + + await send(deps, { audioFormat: 'video_mp3', subfolder: 'Shows' }); + + expect(channelSettingsModule.getChannelSettings).not.toHaveBeenCalled(); + expect(deps.layoutGuards.assertVideoOnlyDestination).toHaveBeenCalledWith({ audioFormat: 'video_mp3', subFolderValue: 'Shows' }); + }); + + test('does not check video downloads', async () => { + const deps = withGuards(); + deps.channelDownloadAllModule.startDownloadAll.mockResolvedValue({ queued: 1 }); + + const res = await send(deps, { resolution: '720' }); + + expect(deps.layoutGuards.assertVideoOnlyDestination).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(202); + }); + }); + test('starts the download and returns 202 with the queued count', async () => { const deps = buildDeps(); deps.channelDownloadAllModule.startDownloadAll.mockResolvedValue({ queued: 42 }); diff --git a/server/routes/__tests__/config.test.js b/server/routes/__tests__/config.test.js index c153f1c64..52dd1b1ea 100644 --- a/server/routes/__tests__/config.test.js +++ b/server/routes/__tests__/config.test.js @@ -15,7 +15,7 @@ const LOGGING_STATUS = { file: { enabled: true, directory: '/app/config/logs', maxSizeBytes: 10485760, maxFiles: 5, error: null }, }; -function makeApp() { +function makeApp(extraDeps = {}) { const app = express(); app.use(express.json()); // Attach a minimal logger BEFORE routes so req.log is available in handlers @@ -35,6 +35,7 @@ function makeApp() { videoFilenamePrefix: '%(uploader,channel,uploader_id).80B - %(title).76B', }, getConfig: jest.fn(function () { return this._config; }), + getDefaultSubfolder: jest.fn(function () { return this._config.defaultSubfolder || null; }), updateConfig: jest.fn(function (next) { this._config = next; }), getCookiesStatus: jest.fn(), getCookiesPath: jest.fn(() => null), @@ -64,6 +65,7 @@ function makeApp() { cookieTest, cookieTestRateLimiter, getLoggingStatus, + ...extraDeps, })); // eslint-disable-next-line no-unused-vars -- Express identifies error handlers by 4-arg arity. app.use((err, _req, res, _next) => { @@ -222,6 +224,44 @@ describe('POST /api/cookies/test', () => { }); describe('POST /updateconfig', () => { + describe('library folder layouts', () => { + const refusal = (message, status) => Object.assign(new Error(message), { status }); + + test('keeps the stored mainFolderLayout over the one in the request', async () => { + const { app, configModule } = makeApp(); + configModule._config.mainFolderLayout = 'tv'; + await supertest(app).post('/updateconfig').send({ mainFolderLayout: 'videos' }); + expect(configModule.updateConfig.mock.calls[0][0].mainFolderLayout).toBe('tv'); + }); + + test('checks a default subfolder change against folder layouts', async () => { + const libraryFolders = { checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined) }; + const jobModule = { getInProgressJobId: jest.fn(() => 'job-1') }; + const { app, configModule } = makeApp({ libraryFolders, jobModule }); + configModule._config.defaultSubfolder = 'Kids'; + + const res = await supertest(app).post('/updateconfig').send({ defaultSubfolder: 'TV' }); + + expect(res.status).toBe(200); + const args = libraryFolders.checkDefaultSubfolderChange.mock.calls[0][0]; + expect(args).toMatchObject({ oldDefault: 'Kids', newDefault: 'TV' }); + expect(args.isDownloadRunning()).toBe(true); + }); + + test('refuses a default subfolder change the layout check rejects', async () => { + const libraryFolders = { + checkDefaultSubfolderChange: jest.fn().mockRejectedValue(refusal('channels have downloads', 409)), + }; + const { app, configModule } = makeApp({ libraryFolders }); + + const res = await supertest(app).post('/updateconfig').send({ defaultSubfolder: 'TV' }); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'channels have downloads' }); + expect(configModule.updateConfig).not.toHaveBeenCalled(); + }); + }); + test('returns 200 when ytdlpCustomArgs is empty', async () => { const { app } = makeApp(); const res = await supertest(app) diff --git a/server/routes/__tests__/playlists.test.js b/server/routes/__tests__/playlists.test.js index e45a53095..77f65ec8b 100644 --- a/server/routes/__tests__/playlists.test.js +++ b/server/routes/__tests__/playlists.test.js @@ -2528,3 +2528,94 @@ describe('playlist setup recovery and validation', () => { }); }); }); + +describe('TV folders are video-only', () => { + // Refuses MP3 into the folder named TV, like layoutGuards does for a TV-layout folder. + const withGuards = () => ({ + ...buildDeps(), + layoutGuards: { + assertVideoOnlyDestination: jest.fn(async ({ audioFormat, subFolderValue }) => { + if (audioFormat && subFolderValue === 'TV') { + throw Object.assign(new Error('TV folders are video-only.'), { status: 400 }); + } + }), + }, + }); + + test('refuses subscribing with MP3 into a TV folder', async () => { + const deps = withGuards(); + const handler = getHandler('post', '/api/playlists', deps); + const res = createResponse(); + + await handler({ + body: { url: 'https://youtube.com/playlist?list=PLtest', settings: { default_sub_folder: 'TV', audio_format: 'mp3_only' } }, + log: loggerMock, + }, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(deps.playlistModule.getPlaylistInfo).not.toHaveBeenCalled(); + }); + + test('checks the global default when subscribing without a folder', async () => { + const deps = withGuards(); + deps.playlistModule.getPlaylistInfo.mockResolvedValue({ playlist_id: 'PLtest' }); + deps.playlistModule.upsertPlaylist.mockResolvedValue({ playlist: makePlaylist(), restored: false }); + const handler = getHandler('post', '/api/playlists', deps); + + await handler({ + body: { url: 'https://youtube.com/playlist?list=PLtest', settings: { audio_format: 'mp3_only' } }, + log: loggerMock, + }, createResponse()); + + expect(deps.layoutGuards.assertVideoOnlyDestination).toHaveBeenCalledWith({ + audioFormat: 'mp3_only', subFolderValue: '##USE_GLOBAL_DEFAULT##', + }); + }); + + test('refuses switching a playlist in a TV folder to MP3', async () => { + const deps = withGuards(); + const p = makePlaylist({ default_sub_folder: 'TV', audio_format: null }); + deps.models.Playlist.findOne.mockResolvedValue(p); + const handler = getHandler('put', '/api/playlists/:playlistId/settings', deps); + const res = createResponse(); + + await handler({ params: { playlistId: 'PLtest123' }, body: { audio_format: 'video_mp3' }, log: loggerMock }, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(p.update).not.toHaveBeenCalled(); + }); + + test('refuses moving an MP3 playlist into a TV folder', async () => { + const deps = withGuards(); + const p = makePlaylist({ default_sub_folder: 'Music', audio_format: 'mp3_only' }); + deps.models.Playlist.findOne.mockResolvedValue(p); + const handler = getHandler('put', '/api/playlists/:playlistId/settings', deps); + const res = createResponse(); + + await handler({ params: { playlistId: 'PLtest123' }, body: { default_sub_folder: 'TV' }, log: loggerMock }, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test('skips the check for settings unrelated to the destination', async () => { + const deps = withGuards(); + deps.models.Playlist.findOne.mockResolvedValue(makePlaylist()); + const handler = getHandler('put', '/api/playlists/:playlistId/settings', deps); + + await handler({ params: { playlistId: 'PLtest123' }, body: { video_quality: '720' }, log: loggerMock }, createResponse()); + + expect(deps.layoutGuards.assertVideoOnlyDestination).not.toHaveBeenCalled(); + }); + + test('refuses an MP3 download into the playlist\'s TV folder', async () => { + const deps = withGuards(); + deps.models.Playlist.findOne.mockResolvedValue(makePlaylist({ default_sub_folder: 'TV' })); + const handler = getHandler('post', '/api/playlists/:playlistId/download', deps); + const res = createResponse(); + + await handler({ params: { playlistId: 'PLtest123' }, body: { overrideSettings: { audioFormat: 'mp3_only' } }, log: loggerMock }, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(deps.downloadModule.doPlaylistDownloads).not.toHaveBeenCalled(); + }); +}); diff --git a/server/routes/__tests__/tvShows.test.js b/server/routes/__tests__/tvShows.test.js new file mode 100644 index 000000000..ea9adca95 --- /dev/null +++ b/server/routes/__tests__/tvShows.test.js @@ -0,0 +1,157 @@ +/* eslint-env jest */ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../logger', () => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() })); + +function refusal(message, status) { + return Object.assign(new Error(message), { status }); +} + +describe('TV show routes', () => { + let app; + let libraryFolders; + let channelLayout; + let channelSettingsModule; + let jobModule; + let models; + const channel = { channel_id: 'UC1', sub_folder: 'Kids' }; + + beforeEach(() => { + jest.resetModules(); + libraryFolders = { + listLibraryFolders: jest.fn().mockResolvedValue([{ name: '', layout: 'videos' }]), + setFolderLayout: jest.fn().mockResolvedValue({ changed: true }), + }; + channelLayout = { + getChannelTvState: jest.fn().mockResolvedValue({ layout: 'tv' }), + resolveLayoutTarget: jest.fn().mockResolvedValue('TV'), + }; + channelSettingsModule = { + updateChannelSettings: jest.fn().mockResolvedValue({ settings: { sub_folder: 'TV' } }), + }; + jobModule = { getInProgressJobId: jest.fn().mockReturnValue(null) }; + models = { Channel: { findOne: jest.fn().mockResolvedValue(channel) } }; + const createTvShowRoutes = require('../tvShows'); + app = express(); + app.use(express.json()); + app.use(createTvShowRoutes({ + verifyToken: (req, res, next) => next(), + libraryFolders, + channelLayout, + channelSettingsModule, + jobModule, + models, + })); + }); + + describe('GET /api/library-folders', () => { + test('returns the folder list', async () => { + const res = await request(app).get('/api/library-folders'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ folders: [{ name: '', layout: 'videos' }] }); + }); + + test('returns 500 when listing fails', async () => { + libraryFolders.listLibraryFolders.mockRejectedValueOnce(new Error('disk')); + const res = await request(app).get('/api/library-folders'); + expect(res.status).toBe(500); + }); + }); + + describe('PUT /api/library-folders', () => { + test('changes the layout and returns the updated list', async () => { + const res = await request(app).put('/api/library-folders').send({ name: 'TV', layout: 'tv' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ changed: true, folders: [{ name: '', layout: 'videos' }] }); + expect(libraryFolders.setFolderLayout).toHaveBeenCalledWith('TV', 'tv', expect.any(Object)); + }); + + test('tells the module whether a download is running', async () => { + jobModule.getInProgressJobId.mockReturnValue('job-1'); + await request(app).put('/api/library-folders').send({ name: '', layout: 'tv' }); + const { isDownloadRunning } = libraryFolders.setFolderLayout.mock.calls[0][2]; + expect(isDownloadRunning()).toBe(true); + }); + + test('rejects a missing name with 400', async () => { + const res = await request(app).put('/api/library-folders').send({ layout: 'tv' }); + expect(res.status).toBe(400); + expect(libraryFolders.setFolderLayout).not.toHaveBeenCalled(); + }); + + test('rejects a missing layout with 400', async () => { + const res = await request(app).put('/api/library-folders').send({ name: 'TV' }); + expect(res.status).toBe(400); + }); + + test('passes a refusal through with its status', async () => { + libraryFolders.setFolderLayout.mockRejectedValueOnce(refusal('holds downloads', 409)); + const res = await request(app).put('/api/library-folders').send({ name: 'TV', layout: 'tv' }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'holds downloads' }); + }); + }); + + describe('GET /api/channels/:channelId/tv', () => { + test('returns the channel TV state', async () => { + const res = await request(app).get('/api/channels/UC1/tv'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ layout: 'tv' }); + }); + + test('returns 404 for an unknown channel', async () => { + models.Channel.findOne.mockResolvedValueOnce(null); + const res = await request(app).get('/api/channels/UCX/tv'); + expect(res.status).toBe(404); + }); + }); + + describe('PUT /api/channels/:channelId/tv/layout', () => { + test('saves the resolved folder through the channel settings', async () => { + const res = await request(app).put('/api/channels/UC1/tv/layout').send({ layout: 'tv', folder: 'TV' }); + expect(res.status).toBe(200); + expect(channelLayout.resolveLayoutTarget).toHaveBeenCalledWith({ channel, layout: 'tv', folder: 'TV' }); + expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith( + 'UC1', { sub_folder: 'TV' }, expect.objectContaining({ isDownloadRunning: expect.any(Function) }) + ); + expect(res.body).toEqual({ settings: { sub_folder: 'TV' }, tv: { layout: 'tv' } }); + }); + + test('rejects a non-string layout with 400', async () => { + const res = await request(app).put('/api/channels/UC1/tv/layout').send({ layout: 1 }); + expect(res.status).toBe(400); + }); + + test('rejects a non-string folder with 400', async () => { + const res = await request(app).put('/api/channels/UC1/tv/layout').send({ layout: 'tv', folder: 3 }); + expect(res.status).toBe(400); + }); + + test('returns 404 for an unknown channel', async () => { + models.Channel.findOne.mockResolvedValueOnce(null); + const res = await request(app).put('/api/channels/UCX/tv/layout').send({ layout: 'tv' }); + expect(res.status).toBe(404); + }); + + test('passes a choose-a-folder refusal through as 400', async () => { + channelLayout.resolveLayoutTarget.mockRejectedValueOnce(refusal('Choose a TV folder.', 400)); + const res = await request(app).put('/api/channels/UC1/tv/layout').send({ layout: 'tv' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Choose a TV folder.' }); + }); + + test('passes a has-downloads refusal through as 409', async () => { + channelSettingsModule.updateChannelSettings.mockRejectedValueOnce(refusal('has downloads', 409)); + const res = await request(app).put('/api/channels/UC1/tv/layout').send({ layout: 'tv' }); + expect(res.status).toBe(409); + }); + + test('returns 500 for an unexpected failure', async () => { + channelSettingsModule.updateChannelSettings.mockRejectedValueOnce(new Error('db')); + const res = await request(app).put('/api/channels/UC1/tv/layout').send({ layout: 'tv' }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to switch the channel\'s layout' }); + }); + }); +}); diff --git a/server/routes/__tests__/videos.tvDestination.test.js b/server/routes/__tests__/videos.tvDestination.test.js new file mode 100644 index 000000000..149ff59ac --- /dev/null +++ b/server/routes/__tests__/videos.tvDestination.test.js @@ -0,0 +1,65 @@ +/* eslint-env jest */ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../logger', () => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() })); +jest.mock('../../modules/channelSettingsModule', () => ({ + validateSubFolder: jest.fn().mockReturnValue({ valid: true }), +})); + +describe('POST /triggerspecificdownloads into a TV folder', () => { + let app; + let downloadModule; + let layoutGuards; + const url = 'https://youtu.be/abcdefghijk'; + + beforeEach(() => { + jest.clearAllMocks(); + downloadModule = { + doGroupedManualDownloads: jest.fn().mockResolvedValue({ queued: 1, acceptedIds: [], alreadyActiveIds: [] }), + }; + layoutGuards = { + assertVideoOnlyDestination: jest.fn(async ({ audioFormat, subFolderValue }) => { + if (audioFormat && subFolderValue === 'TV') { + throw Object.assign(new Error('TV folders are video-only.'), { status: 400 }); + } + }), + }; + const createVideoRoutes = require('../videos'); + app = express(); + app.use(express.json()); + app.use((req, res, next) => { req.log = { warn: jest.fn(), error: jest.fn(), info: jest.fn() }; next(); }); + app.use(createVideoRoutes({ + verifyToken: (req, res, next) => next(), + videosModule: {}, + downloadModule, + storageGuard: { isPausedError: () => false }, + layoutGuards, + })); + }); + + test('refuses MP3 with a TV destination override', async () => { + const response = await request(app).post('/triggerspecificdownloads') + .send({ urls: [url], overrideSettings: { subfolder: 'TV', audioFormat: 'mp3_only' } }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'TV folders are video-only.' }); + expect(downloadModule.doGroupedManualDownloads).not.toHaveBeenCalled(); + }); + + test('allows video with a TV destination override', async () => { + const response = await request(app).post('/triggerspecificdownloads') + .send({ urls: [url], overrideSettings: { subfolder: 'TV' } }); + + expect(response.status).toBe(200); + }); + + // Without an override the destination is known per video: manualDownloadGrouper + // downgrades MP3 to video for URLs whose channel saves to a TV folder. + test('does not check MP3 without a destination override', async () => { + await request(app).post('/triggerspecificdownloads') + .send({ urls: [url], overrideSettings: { audioFormat: 'mp3_only' } }); + + expect(layoutGuards.assertVideoOnlyDestination).not.toHaveBeenCalled(); + }); +}); diff --git a/server/routes/channels.js b/server/routes/channels.js index 5282389b1..f01146a5a 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -13,10 +13,13 @@ const parseFilterMode = (value) => * @param {Object} deps.archiveModule - Archive module * @param {Object} deps.channelDownloadAllModule - Channel download-all module * @param {Object} deps.ratingMapper - Rating validation/normalization module + * @param {Object} [deps.jobModule] - Its running job blocks switching a channel between Videos and TV + * @param {Object} [deps.layoutGuards] - Keeps MP3 downloads out of TV folders * @returns {express.Router} */ -module.exports = function createChannelRoutes({ verifyToken, channelModule, archiveModule, channelDownloadAllModule, ratingMapper, storageGuard }) { +module.exports = function createChannelRoutes({ verifyToken, channelModule, archiveModule, channelDownloadAllModule, ratingMapper, storageGuard, jobModule, layoutGuards }) { const router = express.Router(); + const isDownloadRunning = () => Boolean(jobModule && jobModule.getInProgressJobId()); const logger = require('../logger'); const channelSettingsModule = require('../modules/channelSettingsModule'); const ChannelVideo = require('../models/channelvideo'); @@ -71,6 +74,12 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * enum: [asc, desc] * description: Sort order * - in: query + * name: layout + * schema: + * type: string + * enum: [tv] + * description: Only channels that download to a TV folder. Each channel in the response carries its layout (videos or tv). + * - in: query * name: subFolder * schema: * type: string @@ -90,6 +99,7 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch sortBy: req.query.sortBy, sortOrder: req.query.sortOrder, subFolder: req.query.subFolder, + layout: req.query.layout, }); res.json(result); } catch (error) { @@ -159,9 +169,9 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * 200: * description: Channels updated successfully * 400: - * description: Invalid payload, or invalid settings on an add item (no channel is changed) + * description: Invalid payload, invalid settings on an add item (no channel is changed), or an MP3 download type for a channel in a TV folder * 409: - * description: An add item changes the subfolder of a channel that has downloads in progress + * description: An add item changes the subfolder of a channel that has downloads in progress, or moves a channel with downloaded videos between Videos and TV * 500: * description: Failed to update channels */ @@ -185,7 +195,7 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch }); } - await channelModule.updateChannelsByDelta({ enableUrls, disableUrls, channelSettingsModule }); + await channelModule.updateChannelsByDelta({ enableUrls, disableUrls, channelSettingsModule, isDownloadRunning }); return res.json({ status: 'success' }); } @@ -200,6 +210,10 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch if (error.message?.includes('Cannot change subfolder while downloads are in progress')) { return res.status(409).json({ error: error.message }); } + // Library folder layout refusals (MP3 into a TV folder, switching a channel with downloads) + if (error.status === 400 || error.status === 409) { + return res.status(error.status).json({ error: error.message }); + } req.log.error({ err: error }, 'Failed to update channels'); res.status(500).json({ status: 'error', @@ -673,8 +687,10 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * responses: * 200: * description: Settings updated successfully + * 400: + * description: An MP3 download type for a channel in a TV folder * 409: - * description: Cannot change subfolder while downloads are in progress + * description: Downloads are in progress, or a folder change would switch a channel with downloaded videos between Videos and TV * 500: * description: Failed to update settings */ @@ -682,12 +698,14 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch try { const result = await channelSettingsModule.updateChannelSettings( req.params.channelId, - req.body + req.body, + { isDownloadRunning } ); res.json(result); } catch (error) { console.error('Error updating channel settings:', error); - const statusCode = error.message.includes('Cannot change subfolder while downloads are in progress') ? 409 : 500; + const statusCode = error.status + || (error.message.includes('Cannot change subfolder while downloads are in progress') ? 409 : 500); res.status(statusCode).json({ error: error.message }); } }); @@ -1181,7 +1199,7 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * queued: * type: integer * 400: - * description: Invalid tabType or overrideSettings + * description: Invalid tabType or overrideSettings, or an MP3 download type for a channel in a TV folder * 404: * description: Channel not found * 409: @@ -1210,10 +1228,20 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch } try { + const override = overrideResult.value || {}; + if (layoutGuards && layoutGuards.isMp3Format(override.audioFormat)) { + let destination = override.subfolder; + if (destination === undefined || destination === null) { + const settings = await channelSettingsModule.getChannelSettings(channelId); + if (!settings) return res.status(404).json({ error: 'Channel not found' }); + destination = settings.sub_folder; + } + await layoutGuards.assertVideoOnlyDestination({ audioFormat: override.audioFormat, subFolderValue: destination }); + } const result = await channelDownloadAllModule.startDownloadAll( channelId, tabType, - overrideResult.value || {} + override ); res.status(202).json({ status: 'accepted', queued: result.queued }); } catch (error) { @@ -1223,6 +1251,9 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch if (storageGuard.isPausedError(error)) { return res.status(409).json({ error: error.message }); } + if (error.status === 400) { + return res.status(400).json({ error: error.message }); + } req.log.error({ err: error, channelId, tabType }, 'Failed to start channel download-all'); res.status(500).json({ error: 'Failed to start channel download-all' }); } diff --git a/server/routes/config.js b/server/routes/config.js index 1a9ce5ade..3753412be 100644 --- a/server/routes/config.js +++ b/server/routes/config.js @@ -88,13 +88,16 @@ const cookieUpload = multer({ * @param {Function} deps.validateEnvAuthCredentials - Function to validate ENV auth credentials * @param {boolean} deps.isWslEnvironment - Whether running in WSL * @param {Function} deps.getLoggingStatus - Returns LOG_LEVEL and log file status + * @param {Object} [deps.libraryFolders] - Refuses default subfolder changes across library folder layouts + * @param {Object} [deps.jobModule] - Its running job blocks those changes * @returns {express.Router} */ module.exports = function createConfigRoutes({ verifyToken, configModule, validateEnvAuthCredentials, isWslEnvironment, filenamePreviewRateLimiter, - cookieDetails, cookieTest, cookieTestRateLimiter, getLoggingStatus, + cookieDetails, cookieTest, cookieTestRateLimiter, getLoggingStatus, libraryFolders, jobModule, }) { const router = express.Router(); + const isDownloadRunning = () => Boolean(jobModule && jobModule.getInProgressJobId()); /** * @swagger @@ -222,6 +225,8 @@ module.exports = function createConfigRoutes({ * responses: * 400: * description: Invalid configuration; schedule errors include a fieldErrors object keyed by config field + * 409: + * description: A new defaultSubfolder has a different layout (videos or TV) and the channels using the default have downloads, a download is running, or they download MP3. mainFolderLayout is never changed here (see PUT /api/library-folders). * 200: * description: Configuration updated successfully * content: @@ -346,6 +351,21 @@ module.exports = function createConfigRoutes({ updateData.videoFilenamePrefix = basic.trimmed; } + // Moving the default subfolder to a folder with another layout switches + // every channel on the default between Videos and TV. + if (libraryFolders && Object.prototype.hasOwnProperty.call(updateData, 'defaultSubfolder')) { + try { + await libraryFolders.checkDefaultSubfolderChange({ + oldDefault: configModule.getDefaultSubfolder(), + newDefault: typeof updateData.defaultSubfolder === 'string' ? updateData.defaultSubfolder : null, + isDownloadRunning, + }); + } catch (error) { + if (!error.status) throw error; + return res.status(error.status).json({ error: error.message }); + } + } + delete updateData.passwordHash; delete updateData.username; @@ -355,6 +375,8 @@ module.exports = function createConfigRoutes({ updateData.ytdlpLastUpdated = currentConfig.ytdlpLastUpdated; updateData.ytdlpLastResult = currentConfig.ytdlpLastResult; updateData.rescanLastRun = currentConfig.rescanLastRun ?? null; + // Owned by the library folders API; a Settings save sends a stale copy. + updateData.mainFolderLayout = currentConfig.mainFolderLayout || 'videos'; configModule.updateConfig(updateData); diff --git a/server/routes/index.js b/server/routes/index.js index fe3daa04d..931233847 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -19,6 +19,7 @@ const createMaintenanceRoutes = require('./maintenance'); const createSubfolderRoutes = require('./subfolders'); const createSchedulesRoutes = require('./schedules'); const createLogRoutes = require('./logs'); +const createTvShowRoutes = require('./tvShows'); const videoMetadataModule = require('../modules/videoMetadataModule'); const videoOembedEnricher = require('../modules/videoOembedEnricher'); const playlistModule = require('../modules/playlistModule'); @@ -43,6 +44,9 @@ const cookieDetails = require('../modules/cookieDetails'); const cookieTest = require('../modules/cookieTest'); const logger = require('../logger'); const logFilesModule = require('../modules/logFilesModule'); +const libraryFolders = require('../modules/tvShows/libraryFolders'); +const channelLayout = require('../modules/tvShows/channelLayout'); +const layoutGuards = require('../modules/tvShows/layoutGuards'); /** * Registers all route modules with the Express app @@ -91,14 +95,16 @@ function registerRoutes(app, deps) { app.use(createConfigRoutes({ verifyToken, configModule, validateEnvAuthCredentials, isWslEnvironment, filenamePreviewRateLimiter, cookieDetails, cookieTest, cookieTestRateLimiter, getLoggingStatus: logger.getLoggingStatus, + libraryFolders, jobModule, })); // Channel routes - app.use(createChannelRoutes({ verifyToken, channelModule, archiveModule, channelDownloadAllModule, ratingMapper, storageGuard })); + app.use(createChannelRoutes({ verifyToken, channelModule, archiveModule, channelDownloadAllModule, ratingMapper, storageGuard, jobModule, layoutGuards })); // Video routes app.use(createVideoRoutes({ verifyToken, videosModule, downloadModule, videoOembedEnricher, videoLocalStatus, storageGuard, scheduledTaskManager, ratingMapper, + layoutGuards, })); // Video search routes @@ -129,7 +135,7 @@ function registerRoutes(app, deps) { app.use(createVideoDetailRoutes({ verifyToken, videoMetadataModule, mediaServers })); // Playlist routes - app.use(createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3uGenerator, mediaServers, models, channelSettingsModule, ratingMapper, subfolderModule, playlistVideoFilters, playlistDownloadModule, storageGuard })); + app.use(createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3uGenerator, mediaServers, models, channelSettingsModule, ratingMapper, subfolderModule, playlistVideoFilters, playlistDownloadModule, storageGuard, layoutGuards })); // Media server routes app.use(createMediaServerRoutes({ verifyToken, configModule, mediaServers, scheduledTaskManager })); @@ -148,6 +154,9 @@ function registerRoutes(app, deps) { // Log file download app.use(createLogRoutes({ verifyToken, logFilesModule, configModule })); + // TV show layouts (library folders and channel layouts) + app.use(createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channelSettingsModule, jobModule, models })); + // Defensive redirect: /channels -> /subscriptions (frontend handles client-side routing, // this fallback covers direct server-side hits during the transition period) app.get('/channels', (req, res) => res.redirect(301, '/subscriptions')); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index e55d58abc..9579dd2da 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -2,11 +2,12 @@ const express = require('express'); const { MAX_PLAYLIST_VIDEOS, MAX_SELECTED_DOWNLOAD_IDS, DEFAULT_PREVIEW_COUNT, FETCH_IN_PROGRESS_MESSAGE } = require('../modules/playlistConstants'); const { createOverrideSettingsValidator } = require('./overrideSettingsValidator'); const { createSubscribeSettingsValidator } = require('./playlistSubscribeSettings'); +const { GLOBAL_DEFAULT_SENTINEL } = require('../modules/filesystem/constants'); // Saved settings the Add Playlist dialog shows when a removed playlist is restored. const RESTORE_PREVIEW_SETTING_KEYS = ['auto_download', 'default_sub_folder', 'video_quality', 'audio_format']; -function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3uGenerator, mediaServers, models, channelSettingsModule, ratingMapper, subfolderModule, playlistVideoFilters, playlistDownloadModule, storageGuard }) { +function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3uGenerator, mediaServers, models, channelSettingsModule, ratingMapper, subfolderModule, playlistVideoFilters, playlistDownloadModule, storageGuard, layoutGuards }) { const router = express.Router(); const { Playlist, PlaylistVideo, Video } = models; const downloadDeps = { PlaylistVideo, Video, playlistModule, downloadModule, storageGuard }; @@ -48,6 +49,18 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u req.log.error({ err, playlist_id: playlistId }, `background ${op} failed`); }; + // TV folders are video-only. Returns the refusal message, or null. + const videoOnlyDestinationError = async (audioFormat, subFolderValue) => { + if (!layoutGuards) return null; + try { + await layoutGuards.assertVideoOnlyDestination({ audioFormat, subFolderValue }); + return null; + } catch (err) { + if (err.status) return err.message; + throw err; + } + }; + // Soft-deleted playlists (enabled: false) 404 on every id-addressed route. // Only POST /api/playlists (restore) and DELETE can touch one. const findEnabledPlaylist = (playlistId) => @@ -234,6 +247,10 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u const validated = validateSubscribeSettings(req.body.settings); if (!validated.ok) return res.status(400).json({ error: validated.error }); const settings = validated.value; + const videoOnlyError = await videoOnlyDestinationError(settings.audio_format, 'default_sub_folder' in settings + ? settings.default_sub_folder + : GLOBAL_DEFAULT_SENTINEL); + if (videoOnlyError) return res.status(400).json({ error: videoOnlyError }); const info = await playlistModule.getPlaylistInfo(url); const { playlist: created, restored } = await playlistModule.upsertPlaylist(info, { enabled: true, settings }); // On restore the submitted settings are discarded in favor of the saved @@ -436,6 +453,13 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u try { const p = await findEnabledPlaylist(req.params.playlistId); if (!p) return res.status(404).json({ error: 'Playlist not found' }); + if ('audio_format' in updates || 'default_sub_folder' in updates) { + const videoOnlyError = await videoOnlyDestinationError( + 'audio_format' in updates ? updates.audio_format : p.audio_format, + 'default_sub_folder' in updates ? updates.default_sub_folder : p.default_sub_folder + ); + if (videoOnlyError) return res.status(400).json({ error: videoOnlyError }); + } await p.update(updates); registerSubfolder(updates.default_sub_folder); res.json({ settings: updates }); @@ -941,6 +965,12 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u const p = await findEnabledPlaylist(req.params.playlistId); if (!p) return res.status(404).json({ error: 'Playlist not found' }); + const override = overrideResult.value || {}; + const videoOnlyError = await videoOnlyDestinationError( + override.audioFormat, + override.subfolder !== undefined && override.subfolder !== null ? override.subfolder : p.default_sub_folder + ); + if (videoOnlyError) return res.status(400).json({ error: videoOnlyError }); const queued = await downloadModule.doPlaylistDownloads(p, { youtubeIds: videoIds, diff --git a/server/routes/tvShows.js b/server/routes/tvShows.js new file mode 100644 index 000000000..29147a0af --- /dev/null +++ b/server/routes/tvShows.js @@ -0,0 +1,189 @@ +const express = require('express'); +const logger = require('../logger'); + +const MAX_FOLDER_NAME_LENGTH = 100; + +/** + * TV show routes: library folder layouts and per-channel TV layout. + * @param {Object} deps + * @param {Function} deps.verifyToken + * @param {Object} deps.libraryFolders - tvShows/libraryFolders + * @param {Object} deps.channelLayout - tvShows/channelLayout + * @param {Object} deps.channelSettingsModule + * @param {Object} deps.jobModule - Its running job blocks layout switches + * @param {Object} deps.models + * @returns {express.Router} + */ +function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channelSettingsModule, jobModule, models }) { + const router = express.Router(); + const isDownloadRunning = () => Boolean(jobModule.getInProgressJobId()); + + // Refusals carry .status; anything else is unexpected. + const sendError = (res, error, failure, context) => { + if (error.status) { + return res.status(error.status).json({ error: error.message }); + } + logger.error({ err: error, ...context }, failure); + return res.status(500).json({ error: failure }); + }; + + const findChannel = (channelId) => models.Channel.findOne({ where: { channel_id: channelId } }); + + /** + * @swagger + * /api/library-folders: + * get: + * summary: List library folders with their layouts + * description: The main downloads folder (name "") and every subfolder, each with its layout (videos or tv), whether it is the default subfolder, whether it holds downloaded files, and how many enabled channels download to it. + * tags: [TV Shows] + * responses: + * 200: + * description: Library folders + * content: + * application/json: + * schema: + * type: object + * properties: + * folders: + * type: array + * items: + * type: object + * properties: + * name: { type: string, description: '"" for the main folder, else the subfolder name without __' } + * layout: { type: string, enum: [videos, tv] } + * isDefault: { type: boolean } + * hasFiles: { type: boolean } + * channels: { type: integer } + * 500: { description: Failed to list library folders } + */ + router.get('/api/library-folders', verifyToken, async (req, res) => { + try { + return res.json({ folders: await libraryFolders.listLibraryFolders() }); + } catch (error) { + return sendError(res, error, 'Failed to list library folders'); + } + }); + + /** + * @swagger + * /api/library-folders: + * put: + * summary: Change a library folder's layout + * description: Sets the layout of the main folder (name "") or a subfolder. Every channel that downloads to the folder changes layout with it, so the change is refused while the folder holds downloaded videos or a download runs, and TV is refused while a channel or playlist there downloads MP3. Switching the main folder to TV writes a .plexignore there that hides the __ subfolders from a Plex library pointed at it. + * tags: [TV Shows] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, layout] + * properties: + * name: { type: string } + * layout: { type: string, enum: [videos, tv] } + * responses: + * 200: { description: The updated folder list and whether anything changed } + * 400: { description: Invalid name or layout } + * 404: { description: Unknown subfolder } + * 409: { description: The folder holds downloads, a download is running, or its channels download MP3 } + * 500: { description: Failed to change the folder layout } + */ + router.put('/api/library-folders', verifyToken, async (req, res) => { + const { name, layout } = req.body || {}; + if (typeof name !== 'string' || name.length > MAX_FOLDER_NAME_LENGTH) { + return res.status(400).json({ error: 'name must be a folder name ("" for the main folder)' }); + } + if (typeof layout !== 'string') { + return res.status(400).json({ error: 'layout must be "videos" or "tv"' }); + } + try { + const { changed } = await libraryFolders.setFolderLayout(name, layout, { isDownloadRunning }); + return res.json({ changed, folders: await libraryFolders.listLibraryFolders() }); + } catch (error) { + return sendError(res, error, 'Failed to change the folder layout', { name, layout }); + } + }); + + /** + * @swagger + * /api/channels/{channelId}/tv: + * get: + * summary: Get a channel's TV layout + * description: Whether the channel downloads to a TV folder, its show (name and folder) when it has one, the TV folders it can use, and whether it can switch layouts (only while it has no downloaded videos). + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * responses: + * 200: { description: The channel's TV state } + * 404: { description: Channel not found } + * 500: { description: Failed to load the channel's TV state } + */ + router.get('/api/channels/:channelId/tv', verifyToken, async (req, res) => { + try { + const channel = await findChannel(req.params.channelId); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + return res.json(await channelLayout.getChannelTvState(channel)); + } catch (error) { + return sendError(res, error, 'Failed to load the channel\'s TV state', { channelId: req.params.channelId }); + } + }); + + /** + * @swagger + * /api/channels/{channelId}/tv/layout: + * put: + * summary: Switch a channel between Videos and TV + * description: Moves the channel to a folder with the requested layout. TV uses the given folder, else the default subfolder when it is a TV folder, else the only TV folder. Videos uses the given folder, else the folder the channel left for TV, else the default subfolder. Refused while the channel has downloaded videos or a download runs, and for TV while the channel downloads MP3. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [layout] + * properties: + * layout: { type: string, enum: [videos, tv] } + * folder: { type: string, description: 'Library folder to use ("" for the main folder)' } + * responses: + * 200: { description: The saved channel settings and the channel's TV state } + * 400: { description: Invalid layout or folder, no TV folder, or a folder must be chosen } + * 404: { description: Channel not found } + * 409: { description: The channel has downloaded videos or a download is running } + * 500: { description: Failed to switch the channel's layout } + */ + router.put('/api/channels/:channelId/tv/layout', verifyToken, async (req, res) => { + const { channelId } = req.params; + const { layout, folder } = req.body || {}; + if (typeof layout !== 'string') { + return res.status(400).json({ error: 'layout must be "videos" or "tv"' }); + } + if (folder !== undefined && folder !== null && (typeof folder !== 'string' || folder.length > MAX_FOLDER_NAME_LENGTH)) { + return res.status(400).json({ error: 'folder must be a folder name ("" for the main folder)' }); + } + try { + const channel = await findChannel(channelId); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + const subFolder = await channelLayout.resolveLayoutTarget({ channel, layout, folder }); + const result = await channelSettingsModule.updateChannelSettings( + channelId, { sub_folder: subFolder }, { isDownloadRunning } + ); + const updated = await findChannel(channelId); + return res.json({ settings: result.settings, tv: await channelLayout.getChannelTvState(updated) }); + } catch (error) { + return sendError(res, error, 'Failed to switch the channel\'s layout', { channelId, layout }); + } + }); + + return router; +} + +module.exports = createTvShowRoutes; diff --git a/server/routes/videos.js b/server/routes/videos.js index 1434492c6..0416a0b6f 100644 --- a/server/routes/videos.js +++ b/server/routes/videos.js @@ -57,6 +57,7 @@ const apiKeyDownloadLimiter = rateLimit({ */ module.exports = function createVideoRoutes({ verifyToken, videosModule, downloadModule, videoOembedEnricher, videoLocalStatus, storageGuard, scheduledTaskManager, ratingMapper, + layoutGuards, }) { const router = express.Router(); /** @@ -924,7 +925,7 @@ module.exports = function createVideoRoutes({ * 200: * description: Download job started * 400: - * description: Invalid resolution + * description: Invalid override settings, or an MP3 download type with a TV-layout destination override * 409: * description: Downloads are paused because a storage limit was reached (Settings > Storage Limits); the error message gives the reason * content: @@ -1026,12 +1027,23 @@ module.exports = function createVideoRoutes({ } try { + // TV folders are video-only; a destination override makes the folder known up front. + const override = req.body.overrideSettings; + if (layoutGuards && override && override.subfolder !== undefined && override.subfolder !== null) { + await layoutGuards.assertVideoOnlyDestination({ + audioFormat: override.audioFormat, + subFolderValue: override.subfolder, + }); + } const admission = await downloadModule.doGroupedManualDownloads(req); res.json({ status: 'success', ...admission }); } catch (err) { if (storageGuard.isPausedError(err)) { return res.status(409).json({ error: err.message }); } + if (err.status === 400) { + return res.status(400).json({ error: err.message }); + } req.log.error({ err }, 'Failed to start manual downloads'); res.status(500).json({ error: 'Failed to queue downloads' }); } From aa203cd1228297e81e1bb5f06856ce7000f04b3b Mon Sep 17 00:00:00 2001 From: dialmaster Date: Sat, 3 Oct 2026 20:30:54 -0700 Subject: [PATCH 04/12] feat(tv): reorganize downloads between layouts Switching a channel, a library folder or the default subfolder between Videos and TV no longer refuses when downloads exist. The change opens a dry run of every file move (paths, episode numbers, new shows, missing files, collisions, watch state at risk), and confirming it moves the files in the background, so existing downloads become episodes, or movie-style videos again, without downloading them again. - Record each reorganize in tv_reorganize_operations and tv_reorganize_items, so a restart resumes it and failed videos can be retried. A revision token over the planned moves refuses a stale preview. The settings change applies when the move starts and is undone only if no video could be moved. - Hold download jobs in the queue while files move, and refuse the rescan, library repair, auto-removal, watch status sync and yt-dlp update, scheduled runs included. Deletes and channel settings saves of the videos and channels being moved get a 409. - Move files without replacing anything at the destination, through a staging copy when it is on another filesystem, and move earlier files back when a later one fails. NFOs, art, tvshow.nfo, the paths stored in jobs/info, channel and playlist m3u files and media server playlists follow the moved files. - Keep Youtarr's watch state while media servers show the moved files as new, unwatched items: watch_status_holds snapshots it before the move, watch status sync does not lower a held row, and the state is pushed back to the Plex owner account, Jellyfin and Emby once they index the new path. Restores that don't land within 14 days are listed under Watch Status with Retry and Dismiss. - Name videos leaving a TV folder the way a new download would be named, rendered in batches by yt-dlp from their stored info.json. - Add POST /api/tv/reorganize/preview, POST /api/tv/reorganize and the /api/tv/operations and /api/tv/holds endpoints. The library folder, channel layout, channel settings and default subfolder routes answer a reorganizeRequired 409 that names the change to preview. - Add the review and progress dialog to Channel Settings, the library folder list and Settings, and a banner on every page while a reorganize runs. Refs: #175 --- CLAUDE.md | 14 +- .../ChannelPage/ChannelSettingsDialog.tsx | 135 +++++- .../__tests__/ChannelSettingsDialog.story.tsx | 1 - .../__tests__/ChannelSettingsDialog.test.tsx | 166 ++++++- .../components/ChannelTvSection.tsx | 25 +- .../__tests__/ChannelTvSection.test.tsx | 31 +- .../hooks/__tests__/useChannelTv.test.ts | 12 + .../ChannelPage/hooks/useChannelTv.ts | 8 +- .../hooks/__tests__/useConfigSave.test.ts | 73 ++++ .../__tests__/useWatchStateHolds.test.ts | 58 +++ .../Configuration/hooks/useConfigSave.ts | 53 ++- .../Configuration/hooks/useScheduleStatus.ts | 2 +- .../Configuration/hooks/useWatchStateHolds.ts | 67 +++ .../sections/CoreSettingsSection.tsx | 2 +- .../sections/WatchStatusSection.tsx | 5 + .../components/LibraryFoldersList.tsx | 34 +- .../components/WatchStateRestores.tsx | 73 ++++ .../__tests__/LibraryFoldersList.test.tsx | 81 ++-- .../__tests__/WatchStateRestores.test.tsx | 64 +++ .../components/__tests__/runNowHint.test.ts | 4 + .../__tests__/scheduleDisplay.test.ts | 6 + .../sections/components/scheduleDisplay.ts | 1 + client/src/components/Settings/Settings.tsx | 74 +++- .../__tests__/ChannelPage.story.tsx | 1 - client/src/components/layout/AppShell.tsx | 2 + .../components/layout/ReorganizeBanner.tsx | 32 ++ .../__tests__/ReorganizeBanner.test.tsx | 27 ++ .../shared/Reorganize/ReorganizeDialog.tsx | 133 ++++++ .../Reorganize/ReorganizeOperationBody.tsx | 84 ++++ .../Reorganize/ReorganizePreviewBody.tsx | 173 ++++++++ .../__tests__/ReorganizeDialog.test.tsx | 126 ++++++ .../ReorganizeOperationBody.test.tsx | 56 +++ .../__tests__/ReorganizePreviewBody.test.tsx | 104 +++++ .../__tests__/reorganizeErrors.test.ts | 47 ++ .../__tests__/useActiveReorganize.test.ts | 57 +++ .../__tests__/useReorganizeOperation.test.ts | 127 ++++++ .../__tests__/useReorganizeOutcome.test.ts | 108 +++++ .../__tests__/useReorganizePreview.test.ts | 52 +++ .../__tests__/useReorganizeRequest.test.ts | 30 ++ .../Reorganize/hooks/useActiveReorganize.ts | 59 +++ .../hooks/useReorganizeOperation.ts | 94 ++++ .../Reorganize/hooks/useReorganizeOutcome.ts | 45 ++ .../Reorganize/hooks/useReorganizePreview.ts | 56 +++ .../Reorganize/hooks/useReorganizeRequest.ts | 36 ++ .../src/components/shared/Reorganize/index.ts | 6 + .../shared/Reorganize/reorganizeErrors.ts | 57 +++ .../shared/Reorganize/reorganizeText.ts | 34 ++ .../hooks/__tests__/useLibraryFolders.test.ts | 12 + client/src/hooks/useLibraryFolders.ts | 8 +- client/src/types/reorganize.ts | 155 +++++++ client/src/types/tvShows.ts | 8 +- docs/DATABASE.md | 3 + .../20261003140340-add-tv-reorganize.js | 143 ++++++ .../server.additional-routes.test.js | 11 + server/__tests__/server.apikeys.test.js | 11 + server/__tests__/server.auth-sessions.test.js | 11 + server/__tests__/server.core.test.js | 11 + server/__tests__/server.plex-routes.test.js | 11 + server/__tests__/server.routes.test.js | 11 + server/models/index.js | 9 + server/models/tvreorganizeitem.js | 29 ++ server/models/tvreorganizeoperation.js | 31 ++ server/models/watchstatushold.js | 30 ++ server/modules/__tests__/jobModule.test.js | 70 +++ .../__tests__/rescanRunSummary.test.js | 9 + .../__tests__/scheduledTaskManager.test.js | 57 +++ .../modules/__tests__/sidecarWriter.test.js | 143 ++++++ .../modules/__tests__/videoInfoStore.test.js | 107 +++++ server/modules/__tests__/videosModule.test.js | 12 + .../fileOperations.moveNoClobber.test.js | 115 +++++ server/modules/filesystem/fileOperations.js | 109 +++++ server/modules/jobModule.js | 27 +- .../__tests__/mediaServerSync.test.js | 30 ++ .../__tests__/watchStateCompare.test.js | 33 ++ .../__tests__/watchStatusHolds.test.js | 307 +++++++++++++ .../__tests__/watchStatusPushBack.test.js | 205 +++++++++ .../__tests__/watchStatusSync.test.js | 32 ++ .../adapters/__tests__/baseAdapter.test.js | 15 + .../adapters/__tests__/embyAdapter.test.js | 37 ++ .../__tests__/jellyfinAdapter.test.js | 51 +++ .../adapters/__tests__/plexAdapter.test.js | 57 +++ .../mediaServers/adapters/baseAdapter.js | 59 +++ .../mediaServers/adapters/embyAdapter.js | 53 +++ .../mediaServers/adapters/jellyfinAdapter.js | 71 +++ .../mediaServers/adapters/plexAdapter.js | 48 ++- .../modules/mediaServers/mediaServerSync.js | 25 ++ .../modules/mediaServers/watchStateCompare.js | 40 ++ .../modules/mediaServers/watchStatusHolds.js | 390 +++++++++++++++++ .../mediaServers/watchStatusPushBack.js | 183 ++++++++ .../modules/mediaServers/watchStatusSync.js | 17 +- .../__tests__/changeContext.test.js | 144 +++++++ .../reorganize/__tests__/changeScope.test.js | 124 ++++++ .../__tests__/destinationPlanner.test.js | 276 ++++++++++++ .../reorganize/__tests__/followUp.test.js | 112 +++++ .../reorganize/__tests__/index.test.js | 53 +++ .../reorganize/__tests__/itemExecutor.test.js | 296 +++++++++++++ .../__tests__/movieNameRenderer.test.js | 106 +++++ .../__tests__/operationRunner.test.js | 394 +++++++++++++++++ .../__tests__/operationStore.test.js | 166 +++++++ .../reorganize/__tests__/planner.test.js | 115 +++++ .../__tests__/reorganizeLock.test.js | 71 +++ .../reorganize/__tests__/revision.test.js | 40 ++ .../__tests__/settingsApplier.test.js | 146 +++++++ .../reorganize/__tests__/showPlanner.test.js | 121 ++++++ server/modules/reorganize/changeContext.js | 188 ++++++++ server/modules/reorganize/changeScope.js | 184 ++++++++ server/modules/reorganize/constants.js | 61 +++ .../modules/reorganize/destinationPlanner.js | 374 ++++++++++++++++ server/modules/reorganize/followUp.js | 142 ++++++ server/modules/reorganize/index.js | 83 ++++ server/modules/reorganize/itemExecutor.js | 227 ++++++++++ .../modules/reorganize/movieNameRenderer.js | 160 +++++++ server/modules/reorganize/operationRunner.js | 408 ++++++++++++++++++ server/modules/reorganize/operationStore.js | 255 +++++++++++ server/modules/reorganize/planner.js | 158 +++++++ server/modules/reorganize/reorganizeLock.js | 128 ++++++ server/modules/reorganize/revision.js | 32 ++ server/modules/reorganize/settingsApplier.js | 153 +++++++ server/modules/reorganize/showPlanner.js | 133 ++++++ server/modules/rescanRunSummary.js | 5 +- server/modules/scheduledTaskManager.js | 37 +- server/modules/sidecarWriter.js | 131 ++++++ .../tvShows/__tests__/channelLayout.test.js | 35 +- .../tvShows/__tests__/layoutGuards.test.js | 25 +- .../tvShows/__tests__/libraryFolders.test.js | 39 +- .../tvShows/__tests__/showStore.test.js | 52 +++ server/modules/tvShows/channelLayout.js | 20 +- server/modules/tvShows/layoutGuards.js | 58 ++- server/modules/tvShows/libraryFolders.js | 52 ++- server/modules/tvShows/showStore.js | 63 +++ server/modules/videoInfoStore.js | 83 ++++ server/modules/videosModule.js | 7 + server/routes/__tests__/config.test.js | 16 + server/routes/__tests__/tvReorganize.test.js | 169 ++++++++ server/routes/__tests__/tvShows.test.js | 35 +- .../__tests__/videos.reorganizeDelete.test.js | 51 +++ server/routes/channels.js | 12 +- server/routes/config.js | 6 +- server/routes/index.js | 13 +- server/routes/tvReorganize.js | 312 ++++++++++++++ server/routes/tvShows.js | 30 +- server/routes/videos.js | 10 +- server/server.js | 12 + 143 files changed, 11274 insertions(+), 180 deletions(-) create mode 100644 client/src/components/Configuration/hooks/__tests__/useWatchStateHolds.test.ts create mode 100644 client/src/components/Configuration/hooks/useWatchStateHolds.ts create mode 100644 client/src/components/Configuration/sections/components/WatchStateRestores.tsx create mode 100644 client/src/components/Configuration/sections/components/__tests__/WatchStateRestores.test.tsx create mode 100644 client/src/components/layout/ReorganizeBanner.tsx create mode 100644 client/src/components/layout/__tests__/ReorganizeBanner.test.tsx create mode 100644 client/src/components/shared/Reorganize/ReorganizeDialog.tsx create mode 100644 client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx create mode 100644 client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx create mode 100644 client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx create mode 100644 client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx create mode 100644 client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx create mode 100644 client/src/components/shared/Reorganize/__tests__/reorganizeErrors.test.ts create mode 100644 client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts create mode 100644 client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOperation.test.ts create mode 100644 client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOutcome.test.ts create mode 100644 client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts create mode 100644 client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeRequest.test.ts create mode 100644 client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts create mode 100644 client/src/components/shared/Reorganize/hooks/useReorganizeOperation.ts create mode 100644 client/src/components/shared/Reorganize/hooks/useReorganizeOutcome.ts create mode 100644 client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts create mode 100644 client/src/components/shared/Reorganize/hooks/useReorganizeRequest.ts create mode 100644 client/src/components/shared/Reorganize/index.ts create mode 100644 client/src/components/shared/Reorganize/reorganizeErrors.ts create mode 100644 client/src/components/shared/Reorganize/reorganizeText.ts create mode 100644 client/src/types/reorganize.ts create mode 100644 migrations/20261003140340-add-tv-reorganize.js create mode 100644 server/models/tvreorganizeitem.js create mode 100644 server/models/tvreorganizeoperation.js create mode 100644 server/models/watchstatushold.js create mode 100644 server/modules/__tests__/sidecarWriter.test.js create mode 100644 server/modules/__tests__/videoInfoStore.test.js create mode 100644 server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js create mode 100644 server/modules/mediaServers/__tests__/watchStateCompare.test.js create mode 100644 server/modules/mediaServers/__tests__/watchStatusHolds.test.js create mode 100644 server/modules/mediaServers/__tests__/watchStatusPushBack.test.js create mode 100644 server/modules/mediaServers/watchStateCompare.js create mode 100644 server/modules/mediaServers/watchStatusHolds.js create mode 100644 server/modules/mediaServers/watchStatusPushBack.js create mode 100644 server/modules/reorganize/__tests__/changeContext.test.js create mode 100644 server/modules/reorganize/__tests__/changeScope.test.js create mode 100644 server/modules/reorganize/__tests__/destinationPlanner.test.js create mode 100644 server/modules/reorganize/__tests__/followUp.test.js create mode 100644 server/modules/reorganize/__tests__/index.test.js create mode 100644 server/modules/reorganize/__tests__/itemExecutor.test.js create mode 100644 server/modules/reorganize/__tests__/movieNameRenderer.test.js create mode 100644 server/modules/reorganize/__tests__/operationRunner.test.js create mode 100644 server/modules/reorganize/__tests__/operationStore.test.js create mode 100644 server/modules/reorganize/__tests__/planner.test.js create mode 100644 server/modules/reorganize/__tests__/reorganizeLock.test.js create mode 100644 server/modules/reorganize/__tests__/revision.test.js create mode 100644 server/modules/reorganize/__tests__/settingsApplier.test.js create mode 100644 server/modules/reorganize/__tests__/showPlanner.test.js create mode 100644 server/modules/reorganize/changeContext.js create mode 100644 server/modules/reorganize/changeScope.js create mode 100644 server/modules/reorganize/constants.js create mode 100644 server/modules/reorganize/destinationPlanner.js create mode 100644 server/modules/reorganize/followUp.js create mode 100644 server/modules/reorganize/index.js create mode 100644 server/modules/reorganize/itemExecutor.js create mode 100644 server/modules/reorganize/movieNameRenderer.js create mode 100644 server/modules/reorganize/operationRunner.js create mode 100644 server/modules/reorganize/operationStore.js create mode 100644 server/modules/reorganize/planner.js create mode 100644 server/modules/reorganize/reorganizeLock.js create mode 100644 server/modules/reorganize/revision.js create mode 100644 server/modules/reorganize/settingsApplier.js create mode 100644 server/modules/reorganize/showPlanner.js create mode 100644 server/modules/sidecarWriter.js create mode 100644 server/modules/videoInfoStore.js create mode 100644 server/routes/__tests__/tvReorganize.test.js create mode 100644 server/routes/__tests__/videos.reorganizeDelete.test.js create mode 100644 server/routes/tvReorganize.js diff --git a/CLAUDE.md b/CLAUDE.md index 83786f868..659dad20a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/cookieDetails.js`: summarizes the YouTube login cookies (names, counts, earliest expiry; never values) in the active cookie file as `details` on `GET /api/cookies/status` and the upload/delete responses, reading external files through `externalCookies.readExternalCookies`. `modules/cookieTest.js`: `POST /api/cookies/test`, one yt-dlp request to the subscriptions feed with `buildCommonArgs` (same proxy/IP family/cookies/cache as downloads) through `ytDlpRunner`, against a private temp copy of uploaded cookies (yt-dlp writes its jar back on exit, which would otherwise undo a replace/delete made mid-test; external files get spawnYtDlp's snapshot) and never logs stderr (yt-dlp echoes malformed cookie lines with their values); any exit 0 with JSON is signed in (even zero subscriptions), and the feed's "Incomplete yt initial data" warnings mean signed out (EXPIRED_COOKIES), a meaning specific to this feed, so it is not in the shared subscription-import classifier. One test at a time (409), rate-limited by `cookieTestRateLimiter`. - `server.js`: Express entry point. `db.js`: Sequelize setup. `logger.js`: Pino logger with request correlation. - `logger.js` + `logging/`: two transport targets pinned to `trace`, so the root level is the only filter and `logger.applyLevelSetting` can change it live. Only the server process (`process.argv[1]` is `server.js`; the yt-dlp post-processor, scripts, and tests never write files, since post-processor output already reaches the server log through yt-dlp's stdout) also writes the console format, without colors or embedded ANSI codes, to `config/logs/youtarr..log` through `logging/logFileTransport.js` (pino-roll with `removeOtherLogFiles`, so files from earlier runs are pruned too; `LOG_FILE_MAX_SIZE` default 10MB, `LOG_FILE_MAX_COUNT` older files kept, default 5). A file error prints one stderr notice and stops file output only; an unwritable folder is detected at startup and reported through `logger.getLoggingStatus()` (served as `logging` on `/getconfig`). Never log a field named `level`: it overwrites pino's level and the record is dropped. `modules/logLevelSync.js` applies the `logLevel` setting ('' = `LOG_LEVEL`) at startup, on config change, and in the post-processor. `modules/logFilesModule.js` + `routes/logs.js` serve `GET /api/logs/download` (all files oldest first, numeric order), read line by line through `logging/logScrubber.js`, which replaces the configured keys/tokens and Apprise notification URLs by value plus `X-Plex-Token`/`X-Emby-Token`/`api_key` values and URL userinfo (proxy credentials) with `[REDACTED]`; pino's redaction only covers top-level keys, and tokens reach the log inside logged axios errors and the logged yt-dlp args. The files on disk are not scrubbed. -- `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus, tvshow, videoclassification). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates, TvShow hasMany VideoClassifications. -- `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs, tvShows). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. +- `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus, tvshow, videoclassification, tvreorganizeoperation, tvreorganizeitem, watchstatushold). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates, TvShow hasMany VideoClassifications, TvReorganizeOperation hasMany TvReorganizeItems. +- `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs, tvShows, tvReorganize). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. - `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to @latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings; MP3 is downgraded to video for videos whose predicted folder has the TV layout), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths, active references, and TV shows with numbered episodes in the folder, not counting a show whose tracked channel has since moved back to a videos folder; reads layouts through `tvShows/libraryLayouts`, which therefore never imports it), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: `; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation, downgrading MP3 to video for URLs whose predicted folder has the TV layout; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). - `modules/titleFilterRegex.js`: evaluates channel title filter regexes with Python's `re` (via `utils/title-filter-regex.py`, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's `--match-filter` exactly. Channel downloads apply the pattern through `ytdlpCommandBuilder.buildMatchFilters`, which escapes only `'` and `&` because yt-dlp's `match_str` unescapes nothing else. Playlist title filters are separate: `playlistModule.buildTitleFilterRegExp` evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile. - `modules/storageUsage.js`: total bytes of downloaded videos (`SUM(file_size + audio_file_size)` over non-removed rows, never a disk walk) and the shared `STORED_BYTES_SQL` fragment the oldest-first auto-removal candidate queries use, so audio-only (MP3) videos count their real size. @@ -47,7 +47,9 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`<library folder>/<show>/Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. -- `modules/tvShows/` (TV show support; channel shows ship in this branch, title shows and the reorganize come later): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos can't switch between videos and TV (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. +- `modules/tvShows/` (TV show support; channel shows and the reorganize ship in this branch, title shows come later): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. +- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art, emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos, Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings; items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. +- `modules/mediaServers/watchStatusHolds.js` + `watchStatusPushBack.js`: watch state a reorganize protects. Before files move, each watched or in-progress `video_watch_status` row (Plex: the owner only; other accounts come from play history, which a move never resets) is snapshotted into `watch_status_holds`; `watchStatusSync` passes every server's matches through `applyHolds`, which drops downgrades of held rows (the moved item looks new and unwatched, and until a rescan the server lists the old path, which the current-copy rule rejects) and ends a hold when the item at the video's current path (matching it with more trailing segments than the path the video came from, kept in the snapshot as `fromPath`: between TV folders the stale item shares the show, season and file names) shows at least the held state or a newer one. A push stamps the server item with the push time: a last-watched time within 10 minutes of the hold's last actual write (`last_pushed_at`; an attempt that found the state already there writes nothing and leaves no echo) is that echo, not a watch, so the row keeps its historical play count and last-watched time (restored holds are consulted for this too), while a later real watch is written as is. Re-holding a row for a later reorganize resets its push record (the earlier push went to the earlier location). Push-back first reads the user's state of the item (`getWatchState`: Plex `/library/metadata/{key}` as the owner, Jellyfin and Emby `/Users/{id}/Items/{id}`; `watchStateCompare` holds the pure `isAtLeast`/`isLaterWatch` the sync uses too) and leaves a state at least as watched, or watched since the snapshot, alone; otherwise it writes the state through the adapters' `setWatchState` (Plex `/:/scrobble` and `/:/progress` as the owner; Jellyfin `/UserPlayedItems` and `/UserItems/{id}/UserData`, falling back to the legacy `/Users/{id}/...` routes; Emby `/Users/{id}/PlayedItems` and `/Users/{id}/Items/{id}/UserData`), finding items with `resolveItemMatchesByPaths` (an item is skipped only when the same item answers for both the new and the old path and fits the old one at least as well, i.e. the stale item before the server rescans; two different items mean the new file is indexed; an explicit retry takes whatever matches); a hold is pushed once, and again only when a sync finds the item at the new path below the held state (`applyHolds` records that as the hold's error), since a blind re-push could undo a watch made in between. It runs 1, 5 and 15 minutes after a reorganize and at the end of every sync, an hour apart per hold. Holds not restored within 14 days become failed (still protecting the row) until retried or dismissed (`GET /api/tv/holds`, `POST /api/tv/holds/:id/retry|dismiss`). - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. - `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10, and episodes by listing show sections with type=4, only when a file in the batch is named SxxEyy), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). @@ -56,10 +58,10 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), ### Frontend (client/src/) - `App.tsx`: app routing plus a global `fetch()` override that detects 503 `requiresDbFix` responses and surfaces the database error overlay. You can use normal `fetch()` anywhere; database errors are handled automatically. - `components/`: feature directories and pages. Complex features pair a top-level `FeatureName.tsx` with a same-named `FeatureName/` directory holding `components/`, `hooks/`, and `__tests__/`. Examples of this sibling-file layout: `Subscriptions.tsx` + `Subscriptions/` (renamed from `ChannelManager`), `Configuration.tsx` + `Configuration/`, `ChannelPage.tsx` + `ChannelPage/`, `PlaylistPage.tsx` + `PlaylistPage/`. Newer features (e.g. `SubscriptionImport/`) put the main component at `FeatureName/index.tsx` instead; either layout is acceptable for new features. -- `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards). +- `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards, `Reorganize/` for the reorganize review and progress dialog: `ReorganizeDialog` previews a change and follows the operation it starts, opened by Channel Settings (layout toggle and folder saves), the library folder list and Settings saves of the default subfolder whenever the server answers `reorganizeRequired`; `reorganizeErrors` turns those refusals into a `ReorganizeRequiredError` carrying the change; the opener saves the folder optimistically when the move starts (`onApplied`), then follows the operation with `hooks/useReorganizeOutcome` (once per run end, told apart by `finishedAt`; `onRetried` bumps its attempt) and reads the saved folder back at each end, since the server undoes the change when nothing moved and applies it again when a retry moves some). - `components/ui/`: theme-neutral UI primitives (Button, Card, Dialog, Select, etc.) built on Radix and styled via CSS variables + Tailwind. Use these instead of Material-UI imports in new code. `AccordionContent` stays mounted when collapsed (tests can query it) but is `invisible`, so its controls leave the tab order and accessibility tree. -- `components/layout/`: app shell and navigation chrome. `AppShell.tsx` is the outer frame; `NavSidebar.tsx` / `NavHeader.tsx` own desktop and mobile nav; `navLayoutConstants.ts` holds shared sidebar/header sizing constants; `layoutFallback.css` provides fallback CSS variables for themes that skip layout overrides. -- `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. +- `components/layout/`: app shell and navigation chrome. `AppShell.tsx` is the outer frame; `NavSidebar.tsx` / `NavHeader.tsx` own desktop and mobile nav; `navLayoutConstants.ts` holds shared sidebar/header sizing constants; `layoutFallback.css` provides fallback CSS variables for themes that skip layout overrides; `ReorganizeBanner.tsx` shows a running reorganize on every page (downloads wait for it). +- `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle, the watch-state restores a reorganize left (`sections/components/WatchStateRestores.tsx` via `hooks/useWatchStateHolds.ts`: pending count, failed restores with Retry and Dismiss) and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. - `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`). - `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow. - `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface. diff --git a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx index c159effc9..3ece6787e 100644 --- a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx +++ b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx @@ -44,9 +44,14 @@ import RatingBadge from '../shared/RatingBadge'; import TabsEditor, { TabsEditorRefreshResult } from './components/TabsEditor'; import ChannelTvSection from './components/ChannelTvSection'; import { useChannelTv } from './hooks/useChannelTv'; +import { + ReorganizeDialog, useReorganizeRequest, useReorganizeOutcome, isReorganizeRequired, reorganizeChangeOf, +} from '../shared/Reorganize'; +import { ReorganizeStartResult } from '../../types/reorganize'; const TV_FOLDER_GENERAL_CAPTION = 'Episodes are saved in season folders, and channel playlist files are off for TV shows.'; const SUBFOLDER_BUSY_MESSAGE = 'Cannot change subfolder while downloads are in progress for this channel. Please wait for downloads to complete.'; +const OTHER_CHANGES_NOTICE = 'The folder change is being applied by the move. Save again to apply your other changes.'; const M3U_SORT_ORDERS = ['oldest_first', 'newest_first'] as const; type M3uSortOrder = (typeof M3U_SORT_ORDERS)[number]; @@ -181,6 +186,8 @@ function ChannelSettingsDialog({ const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null); const [success, setSuccess] = useState(false); + // After a reorganize saved the folder on its own, the other edits still wait for Save. + const [notice, setNotice] = useState<string | null>(null); // Use config hook to get global quality setting const { config, refetch: refetchConfig } = useConfig(token); @@ -188,7 +195,11 @@ function ChannelSettingsDialog({ const { subfolders, createSubfolder } = useSubfolders(token); const { folders: libraryFolders, layoutOf, setFolderLayout } = useLibraryFolders(token); - const { tv, loading: tvLoading, error: tvError, switchLayout } = useChannelTv(channelId, token, open); + const { tv, loading: tvLoading, error: tvError, switchLayout, refetch: refetchTv } = useChannelTv(channelId, token, open); + // A folder change that moves downloaded files is reviewed in the reorganize dialog. + const reorganize = useReorganizeRequest(); + // The move started or retried from here, followed to each end (the review may be closed by then). + const [trackedMove, setTrackedMove] = useState<{ operationId: number; attempt: number } | null>(null); const draftIsTv = layoutOf(effectiveLibraryFolder(settings.sub_folder, config.defaultSubfolder)) === 'tv'; // Duration input state (in minutes for UI convenience) @@ -229,6 +240,7 @@ function ChannelSettingsDialog({ // Reset state when dialog closes setSuccess(false); setError(null); + setNotice(null); return; } @@ -306,6 +318,7 @@ function ChannelSettingsDialog({ setSaving(true); setError(null); setSuccess(false); + setNotice(null); try { const response = await fetch(`/api/channels/${channelId}/settings`, { @@ -338,6 +351,11 @@ function ChannelSettingsDialog({ let parseFailed = false; try { const data = await response.json(); + const change = reorganizeChangeOf(data); + if (change) { + reorganize.review(change); + return; + } serverMessage = typeof data?.error === 'string' && data.error ? data.error : null; } catch (parseError) { parseFailed = true; @@ -423,27 +441,29 @@ function ChannelSettingsDialog({ onClose(); }; - const hasChanges = () => { + const settingsDiffer = (a: ChannelSettings, b: ChannelSettings) => { const tabsChanged = - settings.hidden_tabs.length !== originalSettings.hidden_tabs.length || - settings.hidden_tabs.some((tab) => !originalSettings.hidden_tabs.includes(tab)); - return settings.sub_folder !== originalSettings.sub_folder || - settings.video_quality !== originalSettings.video_quality || - settings.min_duration !== originalSettings.min_duration || - settings.max_duration !== originalSettings.max_duration || - settings.title_filter_regex !== originalSettings.title_filter_regex || - settings.additional_tags !== originalSettings.additional_tags || - settings.audio_format !== originalSettings.audio_format || - settings.default_rating !== originalSettings.default_rating || - settings.auto_download_enabled_tabs !== originalSettings.auto_download_enabled_tabs || - settings.skip_video_folder !== originalSettings.skip_video_folder || - settings.m3u_enabled !== originalSettings.m3u_enabled || - settings.m3u_sort_order !== originalSettings.m3u_sort_order || - settings.auto_removal_protected !== originalSettings.auto_removal_protected || - settings.auto_removal_keep_recent_count !== originalSettings.auto_removal_keep_recent_count || + a.hidden_tabs.length !== b.hidden_tabs.length || + a.hidden_tabs.some((tab) => !b.hidden_tabs.includes(tab)); + return a.sub_folder !== b.sub_folder || + a.video_quality !== b.video_quality || + a.min_duration !== b.min_duration || + a.max_duration !== b.max_duration || + a.title_filter_regex !== b.title_filter_regex || + a.additional_tags !== b.additional_tags || + a.audio_format !== b.audio_format || + a.default_rating !== b.default_rating || + a.auto_download_enabled_tabs !== b.auto_download_enabled_tabs || + a.skip_video_folder !== b.skip_video_folder || + a.m3u_enabled !== b.m3u_enabled || + a.m3u_sort_order !== b.m3u_sort_order || + a.auto_removal_protected !== b.auto_removal_protected || + a.auto_removal_keep_recent_count !== b.auto_removal_keep_recent_count || tabsChanged; }; + const hasChanges = () => settingsDiffer(settings, originalSettings); + const allTabsHidden = detectedTabs.length > 0 && detectedTabs.every((tab) => settings.hidden_tabs.includes(tab)); @@ -478,10 +498,7 @@ function ChannelSettingsDialog({ })); }; - // A layout switch saves the channel's new sub_folder on the server right away. - const handleLayoutSwitch = async (layout: LibraryLayout, folder?: string) => { - const result = await switchLayout(layout, folder); - const subFolder = result.settings.sub_folder ?? null; + const applySavedSubFolder = (subFolder: string | null) => { setSettings((prev) => ({ ...prev, sub_folder: subFolder })); setOriginalSettings((prev) => ({ ...prev, sub_folder: subFolder })); if (onSettingsSaved) { @@ -494,6 +511,64 @@ function ChannelSettingsDialog({ } }; + // A layout switch saves the channel's new sub_folder on the server right away, + // unless the channel's files must move: then the move is reviewed first. + const handleLayoutSwitch = async (layout: LibraryLayout, folder?: string) => { + try { + const result = await switchLayout(layout, folder); + applySavedSubFolder(result.settings.sub_folder ?? null); + } catch (err: unknown) { + if (!isReorganizeRequired(err)) throw err; + reorganize.review(err.change); + } + }; + + // The reorganize applies the folder change when it starts. + const handleReorganizeApplied = (result: ReorganizeStartResult) => { + if (reorganize.change && reorganize.change.type === 'channel') { + const subFolder = reorganize.change.subFolder; + applySavedSubFolder(subFolder); + if (settingsDiffer({ ...settings, sub_folder: subFolder }, { ...originalSettings, sub_folder: subFolder })) { + setNotice(OTHER_CHANGES_NOTICE); + } + } + if (result.operationId) setTrackedMove({ operationId: result.operationId, attempt: 0 }); + else void refetchTv(); + }; + + const handleReorganizeRetried = (operationId: number) => { + setTrackedMove((current) => ({ + operationId, + attempt: current && current.operationId === operationId ? current.attempt + 1 : 1, + })); + }; + + // The server undoes the folder change when no video could be moved, so the + // saved folder is read back rather than assumed; the other draft edits stay. + const reloadSavedSubFolder = async () => { + try { + const response = await fetch(`/api/channels/${channelId}/settings`, { headers: { 'x-access-token': token || '' } }); + if (!response.ok) return; + const data = await response.json(); + const subFolder = data?.sub_folder || null; + if (subFolder !== originalSettings.sub_folder) applySavedSubFolder(subFolder); + } catch { + // The next open of the dialog loads the saved settings anyway. + } + }; + + const handleReorganizeClosed = () => { + reorganize.close(); + void refetchTv(); + void reloadSavedSubFolder(); + }; + + // Each end of the move (the first run or a retry) decides the saved folder. + useReorganizeOutcome(token, trackedMove?.operationId ?? null, () => { + void refetchTv(); + void reloadSavedSubFolder(); + }, { attempt: trackedMove?.attempt ?? 0 }); + const handlePreviewFilter = async () => { setLoadingPreview(true); setPreviewError(null); @@ -806,6 +881,7 @@ function ChannelSettingsDialog({ onSwitch={handleLayoutSwitch} createSubfolder={createSubfolder} setFolderLayout={setFolderLayout} + onShowReorganize={reorganize.showOperation} disabled={saving} /> ); @@ -1184,6 +1260,12 @@ function ChannelSettingsDialog({ </Alert> )} + {notice && ( + <Alert severity="info" style={{ marginBottom: 16 }} onClose={() => setNotice(null)}> + {notice} + </Alert> + )} + {isMobile ? ( renderSectionContent(activeSection) ) : ( @@ -1206,6 +1288,15 @@ function ChannelSettingsDialog({ {saving ? <CircularProgress size={24} /> : 'Save'} </Button> </DialogActions> + <ReorganizeDialog + open={reorganize.open} + token={token} + change={reorganize.change} + operationId={reorganize.operationId} + onClose={handleReorganizeClosed} + onApplied={handleReorganizeApplied} + onRetried={handleReorganizeRetried} + /> </Dialog> ); } diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx index 7667faf0f..12ede0d2b 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx @@ -39,7 +39,6 @@ const meta: Meta<typeof ChannelSettingsDialog> = { defaultFolder: '', defaultFolderLayout: 'videos', hasDownloads: false, - canSwitch: true, }) ), http.get('/api/library-folders', () => diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx index cb79813e1..4c762b4aa 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx @@ -17,6 +17,7 @@ jest.mock('../../../hooks/useConfig', () => ({ // for the refresh button. Existing dialog tests do not click refresh so the // mock is passive for them; it's only exercised by the refresh-path test. jest.mock('axios', () => ({ + get: jest.fn(() => new Promise(() => {})), post: jest.fn(), isAxiosError: jest.fn(() => false), })); @@ -47,6 +48,26 @@ jest.mock('../../../hooks/useLibraryFolders', () => ({ }), })); +jest.mock('../../shared/Reorganize', () => { + const actual = jest.requireActual('../../shared/Reorganize'); + return { + ...actual, + ReorganizeDialog: function MockReorganizeDialog(props: { + open: boolean; change: unknown; onClose: () => void; + onApplied?: (result: { operationId: number | null; applied: boolean }) => void; + onRetried?: (operationId: number) => void; + }) { + const React = require('react'); + if (!props.open) return null; + return React.createElement('div', { 'data-testid': 'reorganize-dialog' }, + JSON.stringify(props.change), + React.createElement('button', { type: 'button', onClick: () => props.onApplied?.({ operationId: 5, applied: false }) }, 'mock apply'), + React.createElement('button', { type: 'button', onClick: () => props.onRetried?.(5) }, 'mock retry'), + React.createElement('button', { type: 'button', onClick: () => props.onClose() }, 'mock close')); + }, + }; +}); + const mockSwitchLayout = jest.fn(); const mockChannelTv: { current: unknown } = { current: null }; jest.mock('../hooks/useChannelTv', () => ({ @@ -2092,7 +2113,6 @@ describe('ChannelSettingsDialog', () => { defaultFolder: '', defaultFolderLayout: 'videos', hasDownloads: false, - canSwitch: true, }; const renderLoaded = async (loaded: Record<string, unknown> = mockChannelSettings) => { @@ -2202,5 +2222,149 @@ describe('ChannelSettingsDialog', () => { expect(await screen.findByText(refusal)).toBeInTheDocument(); }); + + test('opens the move review when a layout switch must move downloaded files', async () => { + const { ReorganizeRequiredError } = jest.requireActual('../../shared/Reorganize'); + mockChannelTv.current = { ...videosChannelTv, hasDownloads: true }; + mockSwitchLayout.mockRejectedValueOnce( + new ReorganizeRequiredError('Review the move', { type: 'channel', channelId: 'UC123', subFolder: 'Anime' }) + ); + await renderLoaded(); + + const user = await openSettingsSection('TV Show'); + await user.click(screen.getByRole('button', { name: 'TV show' })); + + expect(await screen.findByTestId('reorganize-dialog')).toHaveTextContent('"subFolder":"Anime"'); + expect(screen.queryByText('Review the move')).not.toBeInTheDocument(); + }); + + test('opens the move review when a save must move downloaded files', async () => { + const change = { type: 'channel', channelId: 'UC123', subFolder: 'Anime' }; + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockChannelSettings), + }) + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: jest.fn().mockResolvedValueOnce({ error: 'Review the move', reorganizeRequired: true, change }), + }); + render(<ChannelSettingsDialog {...defaultProps} />); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + const user = userEvent.setup(); + + await user.click(screen.getByLabelText('Channel Video Quality Override')); + await user.click(screen.getByText('720p (HD)')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByTestId('reorganize-dialog')).toHaveTextContent('"subFolder":"Anime"'); + expect(screen.queryByText('Review the move')).not.toBeInTheDocument(); + }); + + test('says the other changes still need saving once the move is started from a save', async () => { + const change = { type: 'channel', channelId: 'UC123', subFolder: 'Anime' }; + mockFetch + .mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValueOnce(mockChannelSettings) }) + .mockResolvedValueOnce({ + ok: false, status: 409, + json: jest.fn().mockResolvedValueOnce({ error: 'Review the move', reorganizeRequired: true, change }), + }); + render(<ChannelSettingsDialog {...defaultProps} />); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + const user = userEvent.setup(); + await user.click(screen.getByLabelText('Channel Video Quality Override')); + await user.click(screen.getByText('720p (HD)')); + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByTestId('reorganize-dialog'); + + await user.click(screen.getByRole('button', { name: 'mock apply' })); + + expect(await screen.findByText(/Save again to apply your other changes/)).toBeInTheDocument(); + }); + + test('re-reads the saved folder when the move ends, whether or not the review is still open', async () => { + const { ReorganizeRequiredError } = jest.requireActual('../../shared/Reorganize'); + mockChannelTv.current = { ...videosChannelTv, hasDownloads: true }; + mockSwitchLayout.mockRejectedValueOnce( + new ReorganizeRequiredError('Review the move', { type: 'channel', channelId: 'UC123', subFolder: 'Anime' }) + ); + // The started operation ends with nothing moved: the server undid the folder change. + mockAxios.get.mockImplementation((url: string) => (url === '/api/tv/operations/5' + ? Promise.resolve({ data: { id: 5, label: 'Chan', status: 'failed', total: 2, done: 0, failed: 2, failedItems: [] } }) + : new Promise(() => {}))); + mockFetch.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ ...mockChannelSettings, sub_folder: 'Kids' }) }); + await renderLoaded(); + const user = await openSettingsSection('TV Show'); + await user.click(screen.getByRole('button', { name: 'TV show' })); + await screen.findByTestId('reorganize-dialog'); + + await user.click(screen.getByRole('button', { name: 'mock apply' })); + + await waitFor(() => { + expect(mockOnSettingsSaved).toHaveBeenLastCalledWith(expect.objectContaining({ sub_folder: 'Kids' })); + }); + expect(mockFetch).toHaveBeenCalledWith('/api/channels/channel123/settings', expect.objectContaining({ headers: expect.anything() })); + }); + + test('follows a retry that succeeds after a rollback, so the form ends on the new folder', async () => { + const { ReorganizeRequiredError } = jest.requireActual('../../shared/Reorganize'); + mockChannelTv.current = { ...videosChannelTv, hasDownloads: true }; + mockSwitchLayout.mockRejectedValueOnce( + new ReorganizeRequiredError('Review the move', { type: 'channel', channelId: 'UC123', subFolder: 'Anime' }) + ); + const outcomes = [ + { id: 5, label: 'Chan', status: 'failed', total: 2, done: 0, failed: 2, finishedAt: '2026-10-03T12:00:00.000Z', failedItems: [] }, + { id: 5, label: 'Chan', status: 'completed', total: 2, done: 2, failed: 0, finishedAt: '2026-10-03T12:05:00.000Z', failedItems: [] }, + ]; + mockAxios.get.mockImplementation((url: string) => (url === '/api/tv/operations/5' + ? Promise.resolve({ data: outcomes.length > 1 ? outcomes.shift() : outcomes[0] }) + : new Promise(() => {}))); + const savedFolders = ['Kids', 'Anime']; + mockFetch.mockImplementation(() => Promise.resolve({ + ok: true, json: jest.fn().mockResolvedValue({ ...mockChannelSettings, sub_folder: savedFolders.length > 1 ? savedFolders.shift() : savedFolders[0] }), + })); + await renderLoaded(); + const user = await openSettingsSection('TV Show'); + await user.click(screen.getByRole('button', { name: 'TV show' })); + await screen.findByTestId('reorganize-dialog'); + await user.click(screen.getByRole('button', { name: 'mock apply' })); + await waitFor(() => { + expect(mockOnSettingsSaved).toHaveBeenLastCalledWith(expect.objectContaining({ sub_folder: 'Kids' })); + }); + + await user.click(screen.getByRole('button', { name: 'mock retry' })); + + await waitFor(() => { + expect(mockOnSettingsSaved).toHaveBeenLastCalledWith(expect.objectContaining({ sub_folder: 'Anime' })); + }); + }); + + test('re-reads the saved folder when the move review closes, in case the move was undone', async () => { + const { ReorganizeRequiredError } = jest.requireActual('../../shared/Reorganize'); + mockChannelTv.current = { ...videosChannelTv, hasDownloads: true }; + mockSwitchLayout.mockRejectedValueOnce( + new ReorganizeRequiredError('Review the move', { type: 'channel', channelId: 'UC123', subFolder: 'Anime' }) + ); + await renderLoaded(); + const user = await openSettingsSection('TV Show'); + await user.click(screen.getByRole('button', { name: 'TV show' })); + await screen.findByTestId('reorganize-dialog'); + await user.click(screen.getByRole('button', { name: 'mock apply' })); + expect(mockOnSettingsSaved).toHaveBeenLastCalledWith(expect.objectContaining({ sub_folder: 'Anime' })); + // Every video failed to move, so the server put the folder back. + mockFetch.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValueOnce({ ...mockChannelSettings, sub_folder: 'Kids' }) }); + + await user.click(screen.getByRole('button', { name: 'mock close' })); + + await waitFor(() => { + expect(mockOnSettingsSaved).toHaveBeenLastCalledWith(expect.objectContaining({ sub_folder: 'Kids' })); + }); + expect(screen.queryByTestId('reorganize-dialog')).not.toBeInTheDocument(); + }); }); }); diff --git a/client/src/components/ChannelPage/components/ChannelTvSection.tsx b/client/src/components/ChannelPage/components/ChannelTvSection.tsx index 9d75c1d40..69f2061c1 100644 --- a/client/src/components/ChannelPage/components/ChannelTvSection.tsx +++ b/client/src/components/ChannelPage/components/ChannelTvSection.tsx @@ -17,8 +17,9 @@ import TvFolderSetup from './TvFolderSetup'; /** The server's refusal when it can't tell which Videos folder to switch back to. */ export const CHOOSE_VIDEOS_FOLDER_MESSAGE = 'Choose a Videos folder.'; const SWITCH_FAILED_MESSAGE = "Couldn't switch this channel's layout."; -const HAS_DOWNLOADS_NOTE = "This channel already has downloaded videos, so it can't switch between Videos and TV yet."; +const HAS_DOWNLOADS_NOTE = 'This channel has downloaded videos: switching moves them, and you review the move first.'; const SWITCH_SAVES_NOTE = 'Switching saves right away.'; +const REORGANIZING_NOTE = "This channel's downloaded videos are being moved."; const DEFAULT_FOLDER_TV_NOTE = "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; const NO_VIDEOS_FOLDER_MESSAGE = 'No library folder uses the Videos layout yet.'; @@ -41,6 +42,8 @@ export interface ChannelTvSectionProps { onSwitch: (layout: LibraryLayout, folder?: string) => Promise<void>; createSubfolder: (name: string) => Promise<void>; setFolderLayout: (name: string, layout: LibraryLayout) => Promise<void>; + /** Show the result of the reorganize that left some of this channel's videos unmoved */ + onShowReorganize?: (operationId: number) => void; disabled?: boolean; } @@ -113,6 +116,7 @@ function ChannelTvSection({ onSwitch, createSubfolder, setFolderLayout, + onShowReorganize, disabled = false, }: ChannelTvSectionProps) { const headingId = useId(); @@ -134,7 +138,9 @@ function ChannelTvSection({ } const isTv = tv.layout === 'tv'; - const locked = !tv.canSwitch || pending || disabled; + const reorganizing = Boolean(tv.reorganize?.running); + const unmoved = tv.reorganize?.unmoved ?? null; + const locked = reorganizing || pending || disabled; const videosFolders = folders.filter((folder) => folder.layout === 'videos').map((folder) => folder.name); const runSwitch = async (layout: LibraryLayout, folder?: string) => { @@ -224,10 +230,23 @@ function ChannelTvSection({ {destination} </Typography> <Typography variant="caption" color="text.secondary"> - {tv.canSwitch ? SWITCH_SAVES_NOTE : HAS_DOWNLOADS_NOTE} + {tv.hasDownloads ? HAS_DOWNLOADS_NOTE : SWITCH_SAVES_NOTE} </Typography> </Box> + {reorganizing && <Alert severity="info">{REORGANIZING_NOTE}</Alert>} + + {!reorganizing && unmoved && unmoved.failed > 0 && ( + <Alert + severity="warning" + action={onShowReorganize ? ( + <Button size="small" variant="outlined" onClick={() => onShowReorganize(unmoved.operationId)}>Review</Button> + ) : undefined} + > + {unmoved.failed === 1 ? '1 video was' : `${unmoved.failed} videos were`} not moved when this channel was reorganized. + </Alert> + )} + {error && <Alert severity="error">{error}</Alert>} {switchError && ( diff --git a/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx index dc8fb01eb..9f2f3ae2d 100644 --- a/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx +++ b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx @@ -12,7 +12,6 @@ const videosTv: ChannelTvState = { defaultFolder: '', defaultFolderLayout: 'videos', hasDownloads: false, - canSwitch: true, }; const tvTv: ChannelTvState = { @@ -75,16 +74,36 @@ describe('ChannelTvSection', () => { expect(screen.getByRole('button', { name: 'TV show' })).toHaveAttribute('aria-pressed', 'true'); }); - test('locks both choices and explains why when the channel has downloads', () => { - renderSection({ tv: { ...videosTv, hasDownloads: true, canSwitch: false } }); + test('lets a channel with downloads switch and says the move is reviewed first', () => { + renderSection({ tv: { ...videosTv, hasDownloads: true } }); - expect(screen.getByRole('button', { name: 'Videos' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'TV show' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'TV show' })).toBeEnabled(); expect( - screen.getByText("This channel already has downloaded videos, so it can't switch between Videos and TV yet.") + screen.getByText('This channel has downloaded videos: switching moves them, and you review the move first.') ).toBeInTheDocument(); }); + test('locks both choices while the channel\'s files are being moved', () => { + renderSection({ tv: { ...videosTv, hasDownloads: true, reorganize: { running: true, unmoved: null } } }); + + expect(screen.getByRole('button', { name: 'Videos' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'TV show' })).toBeDisabled(); + expect(screen.getByText("This channel's downloaded videos are being moved.")).toBeInTheDocument(); + }); + + test('offers to review videos a reorganize left unmoved', async () => { + const onShowReorganize = jest.fn(); + const { user } = renderSection({ + tv: { ...tvTv, reorganize: { running: false, unmoved: { operationId: 7, failed: 2, status: 'partial' } } }, + onShowReorganize, + }); + + expect(screen.getByText('2 videos were not moved when this channel was reorganized.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Review' })); + + expect(onShowReorganize).toHaveBeenCalledWith(7); + }); + describe('switching to TV', () => { test('switches straight to the only TV folder', async () => { const { props, user } = renderSection(); diff --git a/client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts index e50f24d3e..808705386 100644 --- a/client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts +++ b/client/src/components/ChannelPage/hooks/__tests__/useChannelTv.test.ts @@ -73,6 +73,18 @@ describe('useChannelTv', () => { await expect(result.current.switchLayout('tv')).rejects.toThrow('has downloads'); }); + test('throws a ReorganizeRequiredError when the channel\'s files must move', async () => { + const change = { type: 'channel', channelId: 'UC1', subFolder: 'TV' }; + axios.put.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 409, data: { error: 'Review the move', reorganizeRequired: true, change } }, + }); + const { result } = renderHook(() => useChannelTv('UC1', 'token')); + await waitFor(() => expect(result.current.tv).not.toBeNull()); + + await expect(result.current.switchLayout('tv')).rejects.toMatchObject({ name: 'ReorganizeRequiredError', change }); + }); + test('refetches when a folder layout changes', async () => { renderHook(() => useChannelTv('UC1', 'token')); await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); diff --git a/client/src/components/ChannelPage/hooks/useChannelTv.ts b/client/src/components/ChannelPage/hooks/useChannelTv.ts index 7cf0bffbd..ec51be085 100644 --- a/client/src/components/ChannelPage/hooks/useChannelTv.ts +++ b/client/src/components/ChannelPage/hooks/useChannelTv.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import axios from 'axios'; import { ChannelTvState, LibraryLayout } from '../../../types/tvShows'; import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; +import { toRequestError } from '../../shared/Reorganize/reorganizeErrors'; export interface ChannelLayoutSwitchResult { settings: { sub_folder: string | null }; @@ -13,7 +14,10 @@ export interface UseChannelTvResult { loading: boolean; error: string | null; refetch: () => Promise<void>; - /** Switch the channel between Videos and TV; throws with the server's refusal message */ + /** + * Switch the channel between Videos and TV; throws with the server's refusal + * message, or a ReorganizeRequiredError when the channel's files must move + */ switchLayout: (layout: LibraryLayout, folder?: string) => Promise<ChannelLayoutSwitchResult>; } @@ -64,7 +68,7 @@ export function useChannelTv(channelId: string | undefined, token: string | null if (seq === requestSeq.current) setTv(response.data.tv); return response.data; } catch (err) { - throw new Error(errorMessage(err, 'Failed to switch the channel\'s layout')); + throw toRequestError(err, 'Failed to switch the channel\'s layout'); } }, [channelId, token]); diff --git a/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts b/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts index 6c1831eca..557e2a413 100644 --- a/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts +++ b/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts @@ -1209,3 +1209,76 @@ test('schedule errors preserve the draft and clear when the affected field is ed act(() => result.current.clearFieldErrors({ autoRemovalFrequency: '0 18 * * *' })); expect(result.current.fieldErrors.autoRemovalFrequency).toBeUndefined(); }); + +test('hands a default subfolder change that moves downloads to the reorganize instead of failing', async () => { + const setSnackbar = jest.fn(); + const change = { type: 'defaultSubfolder', value: 'TV' }; + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: 'Review the move', reorganizeRequired: true, change }), + }); + const { result } = renderHook(() => useConfigSave({ + token: 'token', config: DEFAULT_CONFIG, setInitialConfig: jest.fn(), setSnackbar, + hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), + })); + + await act(async () => { expect(await result.current.saveConfig()).toBe(false); }); + + expect(result.current.reorganizeChange).toEqual(change); + expect(setSnackbar).not.toHaveBeenCalled(); + act(() => result.current.clearReorganizeChange()); + expect(result.current.reorganizeChange).toBeNull(); +}); + +test('reads the saved default subfolder back when the reorganize dialog closes', async () => { + const setInitialConfig = jest.fn(); + const change = { type: 'defaultSubfolder', value: 'TV' }; + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'Review the move', reorganizeRequired: true, change }) }) + // The move was undone on the server: the default is back to Kids. + .mockResolvedValueOnce({ ok: true, json: async () => ({ defaultSubfolder: 'Kids' }) }); + const { result } = renderHook(() => useConfigSave({ + token: 'token', config: DEFAULT_CONFIG, setInitialConfig, setSnackbar: jest.fn(), + hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), + })); + await act(async () => { await result.current.saveConfig(); }); + + let saved: string | null = null; + await act(async () => { saved = await result.current.finishReorganize(); }); + + expect(saved).toBe('Kids'); + expect(result.current.reorganizeChange).toBeNull(); + expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe('/getconfig'); + const updater = setInitialConfig.mock.calls[0][0]; + expect(updater({ ...DEFAULT_CONFIG, defaultSubfolder: 'TV' })).toMatchObject({ defaultSubfolder: 'Kids' }); +}); + +test('reads the saved default subfolder back for a move that ended after the dialog closed', async () => { + const setInitialConfig = jest.fn(); + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => ({ defaultSubfolder: 'Kids' }) }); + const { result } = renderHook(() => useConfigSave({ + token: 'token', config: DEFAULT_CONFIG, setInitialConfig, setSnackbar: jest.fn(), + hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), + })); + + let saved: string | null = null; + await act(async () => { saved = await result.current.readBackDefaultSubfolder(); }); + + expect(saved).toBe('Kids'); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe('/getconfig'); + const updater = setInitialConfig.mock.calls[0][0]; + expect(updater({ ...DEFAULT_CONFIG, defaultSubfolder: 'TV' })).toMatchObject({ defaultSubfolder: 'Kids' }); +}); + +test('finishing without a pending reorganize reads nothing', async () => { + const { result } = renderHook(() => useConfigSave({ + token: 'token', config: DEFAULT_CONFIG, setInitialConfig: jest.fn(), setSnackbar: jest.fn(), + hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), + })); + + let saved: string | null = 'x'; + await act(async () => { saved = await result.current.finishReorganize(); }); + + expect(saved).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); +}); diff --git a/client/src/components/Configuration/hooks/__tests__/useWatchStateHolds.test.ts b/client/src/components/Configuration/hooks/__tests__/useWatchStateHolds.test.ts new file mode 100644 index 000000000..fb314bcc7 --- /dev/null +++ b/client/src/components/Configuration/hooks/__tests__/useWatchStateHolds.test.ts @@ -0,0 +1,58 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useWatchStateHolds } from '../useWatchStateHolds'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), + isAxiosError: (err: unknown): boolean => typeof err === 'object' && err !== null && 'response' in err, +})); + +const axios = require('axios'); + +const HOLD = { id: 1, state: 'failed', serverType: 'jellyfin', title: 'Video' }; + +describe('useWatchStateHolds', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: { holds: [HOLD], counts: { pending: 2, failed: 1 } } }); + }); + + test('loads the restores and their counts', async () => { + const { result } = renderHook(() => useWatchStateHolds('token')); + + await waitFor(() => expect(result.current.holds).toEqual([HOLD])); + expect(result.current.counts).toEqual({ pending: 2, failed: 1 }); + expect(axios.get).toHaveBeenCalledWith('/api/tv/holds', { headers: { 'x-access-token': 'token' } }); + }); + + test('retries a restore and reloads', async () => { + axios.post.mockResolvedValueOnce({ data: {} }); + const { result } = renderHook(() => useWatchStateHolds('token')); + await waitFor(() => expect(result.current.holds).toHaveLength(1)); + + await act(async () => { await result.current.retry(1); }); + + expect(axios.post).toHaveBeenCalledWith('/api/tv/holds/1/retry', undefined, { headers: { 'x-access-token': 'token' } }); + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + test('dismisses a restore', async () => { + axios.post.mockResolvedValueOnce({}); + const { result } = renderHook(() => useWatchStateHolds('token')); + await waitFor(() => expect(result.current.holds).toHaveLength(1)); + + await act(async () => { await result.current.dismiss(1); }); + + expect(axios.post).toHaveBeenCalledWith('/api/tv/holds/1/dismiss', undefined, { headers: { 'x-access-token': 'token' } }); + }); + + test('shows a failed action', async () => { + axios.post.mockRejectedValueOnce({ response: { status: 404, data: { error: 'Restore not found' } } }); + const { result } = renderHook(() => useWatchStateHolds('token')); + await waitFor(() => expect(result.current.holds).toHaveLength(1)); + + await act(async () => { await result.current.retry(1); }); + + expect(result.current.error).toBe('Restore not found'); + }); +}); diff --git a/client/src/components/Configuration/hooks/useConfigSave.ts b/client/src/components/Configuration/hooks/useConfigSave.ts index 01f7d93f0..790f63c26 100644 --- a/client/src/components/Configuration/hooks/useConfigSave.ts +++ b/client/src/components/Configuration/hooks/useConfigSave.ts @@ -2,6 +2,8 @@ import { useCallback, useState } from 'react'; import { SCHEDULE_FIELDS, ScheduleFieldErrors } from '../schedules'; import { ConfigState, SnackbarState } from '../types'; import { CONFIG_UPDATED_EVENT } from '../../../hooks/useConfig'; +import { ReorganizeChange } from '../../../types/reorganize'; +import { reorganizeChangeOf } from '../../shared/Reorganize/reorganizeErrors'; interface UseConfigSaveParams { token: string | null; @@ -12,7 +14,14 @@ interface UseConfigSaveParams { checkPlexConnection: () => void; } -async function getSaveError(response: Response): Promise<{ error: string; fieldErrors: ScheduleFieldErrors }> { +interface SaveFailure { + error: string; + fieldErrors: ScheduleFieldErrors; + /** A default subfolder change that moves downloads, to review in the reorganize dialog */ + reorganizeChange: ReorganizeChange | null; +} + +async function getSaveError(response: Response): Promise<SaveFailure> { try { const body = await response.json(); const fieldErrors: ScheduleFieldErrors = {}; @@ -20,12 +29,12 @@ async function getSaveError(response: Response): Promise<{ error: string; fieldE if (typeof body?.fieldErrors?.[key] === 'string') fieldErrors[key] = body.fieldErrors[key]; } if (typeof body?.error === 'string' && body.error.trim()) { - return { error: body.error, fieldErrors }; + return { error: body.error, fieldErrors, reorganizeChange: reorganizeChangeOf(body) }; } } catch { // Fall through when the server didn't return JSON. } - return { error: 'Failed to save configuration', fieldErrors: {} }; + return { error: 'Failed to save configuration', fieldErrors: {}, reorganizeChange: null }; } export const useConfigSave = ({ @@ -38,6 +47,36 @@ export const useConfigSave = ({ }: UseConfigSaveParams) => { const [isSaving, setIsSaving] = useState(false); const [fieldErrors, setFieldErrors] = useState<ScheduleFieldErrors>({}); + const [reorganizeChange, setReorganizeChange] = useState<ReorganizeChange | null>(null); + const clearReorganizeChange = useCallback(() => setReorganizeChange(null), []); + + /** + * Read the saved default subfolder back into the saved baseline: the server + * undoes a reorganize's change when no video could be moved, so it is read + * rather than assumed, when the review closes and again when the move ends. + * Resolves to the saved value, or null when the read failed. + */ + const readBackDefaultSubfolder = useCallback(async (): Promise<string | null> => { + if (!token) return null; + try { + const response = await fetch('/getconfig', { headers: { 'x-access-token': token } }); + if (!response.ok) return null; + const data = await response.json(); + const saved = typeof data?.defaultSubfolder === 'string' ? data.defaultSubfolder : ''; + setInitialConfig((current) => (current ? { ...current, defaultSubfolder: saved } : current)); + return saved; + } catch { + return null; + } + }, [token, setInitialConfig]); + + /** Close out the reorganize dialog, reading the saved default back when it was for one. */ + const finishReorganize = useCallback(async (): Promise<string | null> => { + const change = reorganizeChange; + setReorganizeChange(null); + if (!change || change.type !== 'defaultSubfolder') return null; + return readBackDefaultSubfolder(); + }, [reorganizeChange, readBackDefaultSubfolder]); const clearFieldErrors = (updates: Partial<ConfigState>) => { setFieldErrors((current) => { const next = { ...current }; @@ -63,6 +102,10 @@ export const useConfigSave = ({ if (!response.ok) { const failure = await getSaveError(response); setFieldErrors(failure.fieldErrors); + if (failure.reorganizeChange) { + setReorganizeChange(failure.reorganizeChange); + return false; + } setSnackbar({ open: true, message: failure.error, @@ -109,5 +152,9 @@ export const useConfigSave = ({ isSaving, fieldErrors, clearFieldErrors, + reorganizeChange, + clearReorganizeChange, + finishReorganize, + readBackDefaultSubfolder, }; }; diff --git a/client/src/components/Configuration/hooks/useScheduleStatus.ts b/client/src/components/Configuration/hooks/useScheduleStatus.ts index 7ac7f01eb..cf1de441c 100644 --- a/client/src/components/Configuration/hooks/useScheduleStatus.ts +++ b/client/src/components/Configuration/hooks/useScheduleStatus.ts @@ -21,7 +21,7 @@ export interface ScheduleRun { export type RunBlockReason = | 'not-registered' | 'running' | 'disabled' | 'cooldown' | 'managed' - | 'downloads-paused' | 'no-media-server' | 'youtube-throttled' | 'downloads-active'; + | 'downloads-paused' | 'no-media-server' | 'youtube-throttled' | 'downloads-active' | 'reorganizing'; export interface ScheduleRunAvailability { available: boolean; diff --git a/client/src/components/Configuration/hooks/useWatchStateHolds.ts b/client/src/components/Configuration/hooks/useWatchStateHolds.ts new file mode 100644 index 000000000..bedbf8d1a --- /dev/null +++ b/client/src/components/Configuration/hooks/useWatchStateHolds.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; +import { WatchHold, WatchHoldsResponse } from '../../../types/reorganize'; +import { serverMessageOf } from '../../shared/Reorganize/reorganizeErrors'; + +export interface UseWatchStateHoldsResult { + holds: WatchHold[]; + counts: { pending: number; failed: number }; + loading: boolean; + error: string | null; + /** The restore a Retry or Dismiss is working on */ + busyId: number | null; + retry: (id: number) => Promise<void>; + dismiss: (id: number) => Promise<void>; + refetch: () => Promise<void>; +} + +const EMPTY_COUNTS = { pending: 0, failed: 0 }; + +/** Watch-state restores a reorganize left for the media servers (GET /api/tv/holds). */ +export function useWatchStateHolds(token: string | null): UseWatchStateHoldsResult { + const [holds, setHolds] = useState<WatchHold[]>([]); + const [counts, setCounts] = useState(EMPTY_COUNTS); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [busyId, setBusyId] = useState<number | null>(null); + + const fetchHolds = useCallback(async () => { + if (!token) return; + setLoading(true); + try { + const response = await axios.get<WatchHoldsResponse>('/api/tv/holds', { headers: { 'x-access-token': token } }); + setHolds(Array.isArray(response.data?.holds) ? response.data.holds : []); + setCounts(response.data?.counts ?? EMPTY_COUNTS); + setError(null); + } catch (err: unknown) { + setError(serverMessageOf(err, 'Failed to load watch state restores')); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + fetchHolds(); + }, [fetchHolds]); + + const act = useCallback(async (id: number, action: 'retry' | 'dismiss', failure: string) => { + if (!token) return; + setBusyId(id); + setError(null); + try { + await axios.post(`/api/tv/holds/${id}/${action}`, undefined, { headers: { 'x-access-token': token } }); + await fetchHolds(); + } catch (err: unknown) { + setError(serverMessageOf(err, failure)); + } finally { + setBusyId(null); + } + }, [token, fetchHolds]); + + const retry = useCallback((id: number) => act(id, 'retry', 'Failed to retry the restore'), [act]); + const dismiss = useCallback((id: number) => act(id, 'dismiss', 'Failed to dismiss the restore'), [act]); + + return { holds, counts, loading, error, busyId, retry, dismiss, refetch: fetchHolds }; +} + +export default useWatchStateHolds; diff --git a/client/src/components/Configuration/sections/CoreSettingsSection.tsx b/client/src/components/Configuration/sections/CoreSettingsSection.tsx index f9deaae89..4c2056c0b 100644 --- a/client/src/components/Configuration/sections/CoreSettingsSection.tsx +++ b/client/src/components/Configuration/sections/CoreSettingsSection.tsx @@ -675,7 +675,7 @@ export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ <Grid item xs={12}> <Box className="border-t pt-3"> - <LibraryFoldersList library={libraryFolders} /> + <LibraryFoldersList library={libraryFolders} token={token} /> </Box> </Grid> diff --git a/client/src/components/Configuration/sections/WatchStatusSection.tsx b/client/src/components/Configuration/sections/WatchStatusSection.tsx index d9ae3135c..0b4fa3917 100644 --- a/client/src/components/Configuration/sections/WatchStatusSection.tsx +++ b/client/src/components/Configuration/sections/WatchStatusSection.tsx @@ -24,6 +24,7 @@ import { MEDIA_SERVER_LABELS } from '../../../utils/mediaServerLabels'; import { formatDateTime } from '../../../utils/formatters'; import { useMediaServerStatus } from '../../../hooks/useMediaServerStatus'; import { useWatchStatusSync, WatchStatusSyncRun } from '../hooks/useWatchStatusSync'; +import { WatchStateRestores } from './components/WatchStateRestores'; type ServerKey = 'plex' | 'jellyfin' | 'emby'; @@ -253,6 +254,10 @@ export function WatchStatusSection({ config, token, onConfigChange }: WatchStatu </Box> )} </Grid> + + <Grid item xs={12}> + <WatchStateRestores token={token} /> + </Grid> </Grid> </ConfigurationCard> ); diff --git a/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx b/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx index 41ada0257..1f71c5b43 100644 --- a/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx +++ b/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx @@ -9,21 +9,23 @@ import { Select, Typography, } from '../../../ui'; -import type { UseLibraryFoldersResult } from '../../../../hooks/useLibraryFolders'; +import { LIBRARY_FOLDERS_UPDATED_EVENT, type UseLibraryFoldersResult } from '../../../../hooks/useLibraryFolders'; import { libraryFolderLabel } from '../../../../utils/libraryLayouts'; import { LibraryFolder, LibraryLayout } from '../../../../types/tvShows'; import { MainFolderTvDialog } from './MainFolderTvDialog'; +import { ReorganizeDialog, useReorganizeRequest, isReorganizeRequired } from '../../../shared/Reorganize'; interface LibraryFoldersListProps { /** The section's useLibraryFolders result, shared so the page loads the folders once */ library: UseLibraryFoldersResult; + token: string | null; } const MAIN_FOLDER = ''; const CHANGE_FAILED = 'Failed to change the folder layout'; const EXPLANATION = "Each folder's layout must match its media server library: Videos for a Plex Other Videos or Jellyfin/Emby Movies library, TV shows for a TV library."; -const HAS_FILES_CAPTION = "Holds downloaded videos, so its layout can't change yet."; +const HAS_FILES_CAPTION = 'Holds downloaded videos: changing its layout moves them, and you review the move first.'; const DEFAULT_TV_NOTE = "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; @@ -66,7 +68,7 @@ const LibraryFolderRow: React.FC<LibraryFolderRowProps> = ({ folder, disabled, o <Select size="small" value={folder.layout} - disabled={disabled || folder.hasFiles} + disabled={disabled} onValueChange={(next) => onLayoutChange(folder, next as LibraryLayout)} inputProps={{ 'aria-label': `Layout for ${label}` }} className="w-full sm:w-40 shrink-0" @@ -82,11 +84,13 @@ const LibraryFolderRow: React.FC<LibraryFolderRowProps> = ({ folder, disabled, o }; /** Library folders with their layouts (Videos or TV shows). */ -export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library }) => { +export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library, token }) => { const { folders, loading, error, refetch, setFolderLayout } = library; const [changing, setChanging] = useState(false); const [changeError, setChangeError] = useState<string | null>(null); const [confirmMainTv, setConfirmMainTv] = useState(false); + // A layout change that moves downloaded files is reviewed in the reorganize dialog. + const reorganize = useReorganizeRequest(); const applyLayout = async (name: string, layout: LibraryLayout) => { setChanging(true); @@ -94,12 +98,25 @@ export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library await setFolderLayout(name, layout); setChangeError(null); } catch (err: unknown) { + if (isReorganizeRequired(err)) { + setChangeError(null); + reorganize.review(err.change); + return; + } setChangeError(err instanceof Error && err.message ? err.message : CHANGE_FAILED); } finally { setChanging(false); } }; + // Channels change layout with their folder, so every listener refetches. + const announceFolderChange = () => window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); + + const handleReorganizeClosed = () => { + reorganize.close(); + announceFolderChange(); + }; + const handleLayoutChange = (folder: LibraryFolder, layout: LibraryLayout) => { if (layout === folder.layout) return; if (folder.name === MAIN_FOLDER && layout === 'tv') { @@ -172,6 +189,15 @@ export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library onCancel={() => setConfirmMainTv(false)} onConfirm={handleConfirmMainTv} /> + + <ReorganizeDialog + open={reorganize.open} + token={token} + change={reorganize.change} + operationId={reorganize.operationId} + onClose={handleReorganizeClosed} + onApplied={announceFolderChange} + /> </Box> ); }; diff --git a/client/src/components/Configuration/sections/components/WatchStateRestores.tsx b/client/src/components/Configuration/sections/components/WatchStateRestores.tsx new file mode 100644 index 000000000..e75fc99cd --- /dev/null +++ b/client/src/components/Configuration/sections/components/WatchStateRestores.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Alert, Box, Button, Typography } from '../../../ui'; +import { WatchHold } from '../../../../types/reorganize'; +import { MEDIA_SERVER_LABELS } from '../../../../utils/mediaServerLabels'; +import { useWatchStateHolds } from '../../hooks/useWatchStateHolds'; + +const EXPLANATION = 'When a reorganize moves downloaded videos, media servers see them as new, unwatched items. ' + + 'Youtarr keeps their watched state and pushes it back once each server has scanned the moved files.'; + +function describeState(hold: WatchHold): string { + if (hold.played) return 'watched'; + const minutes = Math.round((hold.positionMs ?? 0) / 60000); + return `in progress (${minutes} min)`; +} + +function describeWho(hold: WatchHold): string { + const server = MEDIA_SERVER_LABELS[hold.serverType] ?? hold.serverType; + return hold.serverUserName ? `${server}, ${hold.serverUserName}` : server; +} + +interface WatchStateRestoresProps { + token: string | null; +} + +/** Watch state still waiting to reach the media servers after a reorganize, with failed restores to retry or dismiss. */ +export function WatchStateRestores({ token }: WatchStateRestoresProps) { + const { holds, counts, error, busyId, retry, dismiss } = useWatchStateHolds(token); + if (counts.pending === 0 && counts.failed === 0 && !error) return null; + const failed = holds.filter((hold) => hold.state === 'failed'); + + return ( + <Box className="flex flex-col gap-2"> + <Typography variant="subtitle2" className="font-bold">Watch state restores</Typography> + <Typography variant="caption" color="text.secondary">{EXPLANATION}</Typography> + {error && <Alert severity="error">{error}</Alert>} + {counts.pending > 0 && ( + <Typography variant="body2" color="text.secondary"> + {counts.pending === 1 ? '1 restore is' : `${counts.pending} restores are`} waiting for the media servers. + </Typography> + )} + {failed.length > 0 && ( + <Alert severity="warning"> + {failed.length === 1 ? '1 restore' : `${failed.length} restores`} did not reach the media server within 14 days. + {' '}Youtarr keeps its own watched state for these until you dismiss them. + </Alert> + )} + {failed.length > 0 && ( + <ul aria-label="Failed restores" className="m-0 list-none divide-y divide-border p-0"> + {failed.map((hold) => ( + <li key={hold.id} className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between"> + <Box className="min-w-0"> + <Typography variant="body2" className="break-words">{hold.title || hold.youtubeId || `Video ${hold.id}`}</Typography> + <Typography variant="caption" color="text.secondary" className="block"> + {describeWho(hold)}: {describeState(hold)}{hold.lastError ? `. ${hold.lastError}` : ''} + </Typography> + </Box> + <Box className="flex shrink-0 gap-2"> + <Button size="small" variant="outlined" disabled={busyId === hold.id} onClick={() => { void retry(hold.id); }}> + Retry + </Button> + <Button size="small" variant="text" disabled={busyId === hold.id} onClick={() => { void dismiss(hold.id); }}> + Dismiss + </Button> + </Box> + </li> + ))} + </ul> + )} + </Box> + ); +} + +export default WatchStateRestores; diff --git a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx index 4e22afda0..bbcac97cf 100644 --- a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx +++ b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx @@ -6,6 +6,19 @@ import { LibraryFoldersList } from '../LibraryFoldersList'; import type { LibraryFolder } from '../../../../../types/tvShows'; import type { UseLibraryFoldersResult } from '../../../../../hooks/useLibraryFolders'; +jest.mock('../../../../shared/Reorganize', () => { + const actual = jest.requireActual('../../../../shared/Reorganize'); + return { + ...actual, + ReorganizeDialog: function MockReorganizeDialog(props: { open: boolean; change: unknown }) { + const React = require('react'); + return props.open + ? React.createElement('div', { 'data-testid': 'reorganize-dialog' }, JSON.stringify(props.change)) + : null; + }, + }; +}); + const mockSetFolderLayout = jest.fn(); const mockRefetch = jest.fn(); @@ -49,7 +62,7 @@ describe('LibraryFoldersList', () => { }); test('renders a row for each folder with its label', () => { - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(screen.getAllByRole('listitem')).toHaveLength(3); expect(screen.getByText('Main folder')).toBeInTheDocument(); expect(screen.getByText('__Shows')).toBeInTheDocument(); @@ -57,54 +70,68 @@ describe('LibraryFoldersList', () => { }); test('marks the default folder', () => { - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(within(rowFor('Main folder')).getByText('Default')).toBeInTheDocument(); }); test('does not mark folders that are not the default', () => { - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(within(rowFor('__Shows')).queryByText('Default')).not.toBeInTheDocument(); }); test('shows the plural channel count', () => { - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(within(rowFor('Main folder')).getByText('3 channels')).toBeInTheDocument(); }); test('shows the singular channel count', () => { - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(within(rowFor('__Shows')).getByText('1 channel')).toBeInTheDocument(); }); test('shows each folder layout as the selected value', () => { - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(screen.getByRole('button', { name: 'Layout for __Kids' })).toHaveTextContent('TV shows'); }); test('changes a subfolder layout through the hook', async () => { const user = userEvent.setup(); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, '__Shows', 'TV shows'); expect(mockSetFolderLayout).toHaveBeenCalledWith('Shows', 'tv'); }); - test('disables the layout select of a folder that holds downloaded videos', () => { - render(<LibraryFoldersList library={library} />); - expect(screen.getByRole('button', { name: 'Layout for __Kids' })).toBeDisabled(); + test('lets a folder that holds downloaded videos change layout', () => { + render(<LibraryFoldersList library={library} token="token" />); + expect(screen.getByRole('button', { name: 'Layout for __Kids' })).toBeEnabled(); }); - test('explains why a folder with downloaded videos is locked', () => { - render(<LibraryFoldersList library={library} />); + test('says a folder\'s downloaded videos move with a layout change', () => { + render(<LibraryFoldersList library={library} token="token" />); expect( - within(rowFor('__Kids')).getByText("Holds downloaded videos, so its layout can't change yet.") + within(rowFor('__Kids')).getByText('Holds downloaded videos: changing its layout moves them, and you review the move first.') ).toBeInTheDocument(); }); + test('opens the move review when the folder\'s files must move', async () => { + const { ReorganizeRequiredError } = jest.requireActual('../../../../shared/Reorganize'); + mockSetFolderLayout.mockRejectedValueOnce( + new ReorganizeRequiredError('Review the move', { type: 'folderLayout', folder: 'Shows', layout: 'tv' }) + ); + const user = userEvent.setup(); + render(<LibraryFoldersList library={library} token="token" />); + + await chooseLayout(user, '__Shows', 'TV shows'); + + expect(await screen.findByTestId('reorganize-dialog')).toHaveTextContent('"folder":"Shows"'); + expect(screen.queryByText('Review the move')).not.toBeInTheDocument(); + }); + test('switching the main folder to TV asks for confirmation first', async () => { const user = userEvent.setup(); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, 'Main folder', 'TV shows'); @@ -116,7 +143,7 @@ describe('LibraryFoldersList', () => { test('confirming the main folder dialog changes the layout', async () => { const user = userEvent.setup(); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, 'Main folder', 'TV shows'); await user.click(await screen.findByRole('button', { name: 'Use for TV shows' })); @@ -126,7 +153,7 @@ describe('LibraryFoldersList', () => { test('closes the main folder dialog once the change is done', async () => { const user = userEvent.setup(); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, 'Main folder', 'TV shows'); await user.click(await screen.findByRole('button', { name: 'Use for TV shows' })); @@ -138,7 +165,7 @@ describe('LibraryFoldersList', () => { test('cancelling the main folder dialog leaves the layout alone', async () => { const user = userEvent.setup(); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, 'Main folder', 'TV shows'); await user.click(await screen.findByRole('button', { name: 'Cancel' })); @@ -154,7 +181,7 @@ describe('LibraryFoldersList', () => { mockHook({ folders: [{ name: '', layout: 'tv', isDefault: true, hasFiles: false, channels: 0 }], }); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, 'Main folder', 'Videos'); @@ -164,7 +191,7 @@ describe('LibraryFoldersList', () => { test('shows the refusal message from a failed change', async () => { const user = userEvent.setup(); mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, '__Shows', 'TV shows'); @@ -174,7 +201,7 @@ describe('LibraryFoldersList', () => { test('dismisses the refusal message', async () => { const user = userEvent.setup(); mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, '__Shows', 'TV shows'); const alert = await screen.findByRole('alert'); @@ -186,7 +213,7 @@ describe('LibraryFoldersList', () => { test('clears the refusal message after the next successful change', async () => { const user = userEvent.setup(); mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await chooseLayout(user, '__Shows', 'TV shows'); await screen.findByText('A download is running'); @@ -204,37 +231,37 @@ describe('LibraryFoldersList', () => { { name: 'Shows', layout: 'tv', isDefault: true, hasFiles: false, channels: 2 }, ], }); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(screen.getByText(/The default subfolder is a TV folder/)).toBeInTheDocument(); }); test('has no TV note when the default folder uses the Videos layout', () => { - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(screen.queryByText(/The default subfolder is a TV folder/)).not.toBeInTheDocument(); }); test('shows a loading state before the folders arrive', () => { mockHook({ folders: [], loading: true }); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(screen.getByText('Loading library folders...')).toBeInTheDocument(); }); test('keeps the rows visible while refetching', () => { mockHook({ loading: true }); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(screen.getAllByRole('listitem')).toHaveLength(3); }); test('shows the load error', () => { mockHook({ folders: [], error: 'Failed to load library folders' }); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); expect(screen.getByRole('alert')).toHaveTextContent('Failed to load library folders'); }); test('retries loading from the error alert', async () => { const user = userEvent.setup(); mockHook({ folders: [], error: 'Failed to load library folders' }); - render(<LibraryFoldersList library={library} />); + render(<LibraryFoldersList library={library} token="token" />); await user.click(screen.getByRole('button', { name: 'Retry' })); diff --git a/client/src/components/Configuration/sections/components/__tests__/WatchStateRestores.test.tsx b/client/src/components/Configuration/sections/components/__tests__/WatchStateRestores.test.tsx new file mode 100644 index 000000000..9672554cb --- /dev/null +++ b/client/src/components/Configuration/sections/components/__tests__/WatchStateRestores.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { WatchStateRestores } from '../WatchStateRestores'; +import { UseWatchStateHoldsResult } from '../../../hooks/useWatchStateHolds'; + +const mockUseWatchStateHolds = jest.fn(); +jest.mock('../../../hooks/useWatchStateHolds', () => ({ + useWatchStateHolds: (token: string | null) => mockUseWatchStateHolds(token), +})); + +function hookResult(overrides: Partial<UseWatchStateHoldsResult> = {}): UseWatchStateHoldsResult { + return { + holds: [], + counts: { pending: 0, failed: 0 }, + loading: false, + error: null, + busyId: null, + retry: jest.fn(), + dismiss: jest.fn(), + refetch: jest.fn(), + ...overrides, + }; +} + +const FAILED = { + id: 4, state: 'failed' as const, serverType: 'jellyfin' as const, serverUserId: 'u1', serverUserName: 'Ann', + youtubeId: 'a1', title: 'Big Build', channelName: 'Chan', played: true, positionMs: null, attempts: 5, + lastAttemptAt: null, lastError: 'HTTP 404', expiresAt: '2026-10-17T00:00:00Z', +}; + +describe('WatchStateRestores', () => { + test('shows nothing when no restore is waiting', () => { + mockUseWatchStateHolds.mockReturnValue(hookResult()); + render(<WatchStateRestores token="token" />); + + expect(screen.queryByText('Watch state restores')).not.toBeInTheDocument(); + }); + + test('counts restores still waiting for the media servers', () => { + mockUseWatchStateHolds.mockReturnValue(hookResult({ counts: { pending: 3, failed: 0 } })); + render(<WatchStateRestores token="token" />); + + expect(screen.getByText('3 restores are waiting for the media servers.')).toBeInTheDocument(); + }); + + test('lists failed restores with retry and dismiss', async () => { + const result = hookResult({ holds: [FAILED], counts: { pending: 0, failed: 1 } }); + mockUseWatchStateHolds.mockReturnValue(result); + const user = userEvent.setup(); + render(<WatchStateRestores token="token" />); + + const list = within(screen.getByRole('list', { name: 'Failed restores' })); + expect(list.getByText('Big Build')).toBeInTheDocument(); + expect(list.getByText('Jellyfin, Ann: watched. HTTP 404')).toBeInTheDocument(); + + await user.click(list.getByRole('button', { name: 'Retry' })); + await user.click(list.getByRole('button', { name: 'Dismiss' })); + + expect(result.retry).toHaveBeenCalledWith(4); + expect(result.dismiss).toHaveBeenCalledWith(4); + }); +}); diff --git a/client/src/components/Configuration/sections/components/__tests__/runNowHint.test.ts b/client/src/components/Configuration/sections/components/__tests__/runNowHint.test.ts index d648032b5..801d0df0e 100644 --- a/client/src/components/Configuration/sections/components/__tests__/runNowHint.test.ts +++ b/client/src/components/Configuration/sections/components/__tests__/runNowHint.test.ts @@ -50,3 +50,7 @@ test('active download links to download activity', () => { expect(describeRunNowBlock({ ...base, availability: blocked('downloads-active') })?.link) .toEqual({ to: '/downloads/activity', label: 'Download activity' }); }); + +test('a running reorganize shows the server\'s message', () => { + expect(describeRunNowBlock({ ...base, availability: blocked('reorganizing') })?.text).toBe('server says reorganizing'); +}); diff --git a/client/src/components/Configuration/sections/components/__tests__/scheduleDisplay.test.ts b/client/src/components/Configuration/sections/components/__tests__/scheduleDisplay.test.ts index 2a6291b48..d71f074c2 100644 --- a/client/src/components/Configuration/sections/components/__tests__/scheduleDisplay.test.ts +++ b/client/src/components/Configuration/sections/components/__tests__/scheduleDisplay.test.ts @@ -124,6 +124,12 @@ describe('describeRunNowBlockBrief', () => { }))).toBe('Run now unavailable: downloads are paused'); }); + test('says when downloads are being moved', () => { + expect(describeRunNowBlockBrief(status({ + runNow: { available: false, reason: 'reorganizing', message: 'Waiting for the reorganize.', availableAt: null }, + }))).toBe('Run now unavailable: downloads are being moved'); + }); + test('leaves out reasons the row already shows', () => { expect(describeRunNowBlockBrief(status({ runNow: { available: false, reason: 'disabled', message: null, availableAt: null }, diff --git a/client/src/components/Configuration/sections/components/scheduleDisplay.ts b/client/src/components/Configuration/sections/components/scheduleDisplay.ts index f80d1a1a2..b758147ef 100644 --- a/client/src/components/Configuration/sections/components/scheduleDisplay.ts +++ b/client/src/components/Configuration/sections/components/scheduleDisplay.ts @@ -30,6 +30,7 @@ const SHORT_BLOCK_REASONS: Partial<Record<RunBlockReason, string>> = { 'no-media-server': 'no media server', 'youtube-throttled': 'YouTube is throttling', 'downloads-active': 'downloads are running', + reorganizing: 'downloads are being moved', 'not-registered': 'not available', }; diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 133446014..725cb627a 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import isEqual from 'lodash/isEqual'; import { Alert, @@ -48,6 +48,8 @@ import { SETTINGS_PAGES, SettingsIndex } from './SettingsIndex'; import { MaintenanceSection } from './MaintenanceSection'; import { SchedulingSection } from '../Configuration/sections/SchedulingSection'; import { LoggingSection } from '../Configuration/sections/LoggingSection'; +import { ReorganizeDialog, useReorganizeOutcome } from '../shared/Reorganize'; +import { ReorganizeStartResult } from '../../types/reorganize'; interface SettingsProps { token: string | null; @@ -133,7 +135,9 @@ export function Settings({ token }: SettingsProps) { hasPlexServerConfigured, }); - const { saveConfig, isSaving, fieldErrors, clearFieldErrors } = useConfigSave({ + const { + saveConfig, isSaving, fieldErrors, clearFieldErrors, reorganizeChange, finishReorganize, readBackDefaultSubfolder, + } = useConfigSave({ token, config, setInitialConfig, @@ -180,6 +184,63 @@ export function Settings({ token }: SettingsProps) { } }, [validationError, saveConfig, confirmNav]); + // The move started or retried from here, followed to each end (the review may be closed by then). + const [trackedMove, setTrackedMove] = useState<{ operationId: number; requested: string; attempt: number } | null>(null); + // The default subfolder value this flow last put in the form: the requested + // one at the start, the read-back one after an end. The form is moved on + // only while it still holds that value, so an edit made meanwhile is kept. + const formDefaultSetByMove = useRef<string | null>(null); + + // A default subfolder change that moved downloads is applied by the + // reorganize; the rest of the form is saved by saving again. + const handleDefaultSubfolderMoved = useCallback((result: ReorganizeStartResult) => { + if (!reorganizeChange || reorganizeChange.type !== 'defaultSubfolder') return; + const value = reorganizeChange.value; + setInitialConfig((current) => (current ? { ...current, defaultSubfolder: value } : current)); + formDefaultSetByMove.current = value; + if (result.operationId) setTrackedMove({ operationId: result.operationId, requested: value, attempt: 0 }); + setSnackbar({ + open: true, + message: 'The default subfolder changed. Save again to apply your other changes.', + severity: 'info', + }); + }, [reorganizeChange, setInitialConfig]); + + // The server undoes the change when no video could be moved, and applies + // it again when a retry moves some: the form follows the value read back, + // unless the user has edited the field again meanwhile. + const followReadBack = useCallback((requested: string, saved: string | null) => { + const shown = formDefaultSetByMove.current; + if (saved === null || shown === null || saved === shown) return; + setConfig((current) => (current.defaultSubfolder === shown ? { ...current, defaultSubfolder: saved } : current)); + formDefaultSetByMove.current = saved; + setSnackbar({ + open: true, + message: saved === requested + ? 'The retry moved the videos, so the default subfolder change is applied after all.' + : 'The default subfolder change was undone because none of the videos could be moved.', + severity: saved === requested ? 'info' : 'warning', + }); + }, [setConfig]); + + const handleReorganizeClosed = useCallback(async () => { + const requested = reorganizeChange?.type === 'defaultSubfolder' ? reorganizeChange.value : null; + const saved = await finishReorganize(); + if (requested !== null) followReadBack(requested, saved); + }, [reorganizeChange, finishReorganize, followReadBack]); + + const handleReorganizeRetried = useCallback((operationId: number) => { + setTrackedMove((current) => (current && current.operationId === operationId + ? { ...current, attempt: current.attempt + 1 } + : current)); + }, []); + + useReorganizeOutcome(token, trackedMove?.operationId ?? null, () => { + const requested = trackedMove?.requested; + if (requested === undefined) return; + void readBackDefaultSubfolder().then((saved) => followReadBack(requested, saved)); + }, { attempt: trackedMove?.attempt ?? 0 }); + const { versionInfo: ytDlpVersionInfo, updateStatus: ytDlpUpdateStatus, @@ -533,6 +594,15 @@ export function Settings({ token }: SettingsProps) { onSuccess={handlePlexAuthSuccess} /> + <ReorganizeDialog + open={reorganizeChange !== null} + token={token} + change={reorganizeChange} + onClose={() => { void handleReorganizeClosed(); }} + onApplied={handleDefaultSubfolderMoved} + onRetried={handleReorganizeRetried} + /> + <Snackbar open={snackbar.open} autoHideDuration={6000} diff --git a/client/src/components/__tests__/ChannelPage.story.tsx b/client/src/components/__tests__/ChannelPage.story.tsx index e843bfbf6..5939bb159 100644 --- a/client/src/components/__tests__/ChannelPage.story.tsx +++ b/client/src/components/__tests__/ChannelPage.story.tsx @@ -75,7 +75,6 @@ export const Default: Story = { defaultFolder: '', defaultFolderLayout: 'videos', hasDownloads: false, - canSwitch: true, }) ), http.get('/api/library-folders', () => diff --git a/client/src/components/layout/AppShell.tsx b/client/src/components/layout/AppShell.tsx index 98d66481c..12978e4d9 100644 --- a/client/src/components/layout/AppShell.tsx +++ b/client/src/components/layout/AppShell.tsx @@ -7,6 +7,7 @@ import { NavSidebar } from './NavSidebar'; import { BackgroundDecorations } from './BackgroundDecorations'; import UpdateAvailableBanner from './UpdateAvailableBanner'; import DownloadPauseBanner from './DownloadPauseBanner'; +import ReorganizeBanner from './ReorganizeBanner'; import { NavItem } from './navigation'; import { getThemeById, getThemeLayoutCssVars, resolveThemeLayoutPolicy } from '../../themes'; import { useMediaQuery } from '../../hooks/useMediaQuery'; @@ -288,6 +289,7 @@ export function AppShell({ }} > <DownloadPauseBanner token={token} /> + <ReorganizeBanner token={token} /> {children} </div> </main> diff --git a/client/src/components/layout/ReorganizeBanner.tsx b/client/src/components/layout/ReorganizeBanner.tsx new file mode 100644 index 000000000..5eba3365d --- /dev/null +++ b/client/src/components/layout/ReorganizeBanner.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Alert, LinearProgress, Typography } from '../ui'; +import { useActiveReorganize } from '../shared/Reorganize/hooks/useActiveReorganize'; + +interface ReorganizeBannerProps { + token: string | null; +} + +/** + * App-wide notice while a reorganize moves downloaded files: downloads wait + * in the queue until it ends, which would otherwise look like a stall. + */ +export const ReorganizeBanner: React.FC<ReorganizeBannerProps> = ({ token }) => { + const { operation } = useActiveReorganize(token); + if (!operation) return null; + + const total = operation.total ?? 0; + const handled = (operation.done ?? 0) + (operation.failed ?? 0); + return ( + <Alert severity="info" className="mb-4"> + <Typography variant="body2"> + Moving downloaded videos for {operation.label}{total > 0 ? `: ${handled} of ${total}` : ''}. + {' '}Downloads wait in the queue until it finishes. + </Typography> + {total > 0 && ( + <LinearProgress variant="determinate" value={Math.round((handled / total) * 100)} height={6} className="mt-2" /> + )} + </Alert> + ); +}; + +export default ReorganizeBanner; diff --git a/client/src/components/layout/__tests__/ReorganizeBanner.test.tsx b/client/src/components/layout/__tests__/ReorganizeBanner.test.tsx new file mode 100644 index 000000000..b3b0fbdcb --- /dev/null +++ b/client/src/components/layout/__tests__/ReorganizeBanner.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ReorganizeBanner } from '../ReorganizeBanner'; + +const mockUseActiveReorganize = jest.fn(); +jest.mock('../../shared/Reorganize/hooks/useActiveReorganize', () => ({ + useActiveReorganize: (token: string | null) => mockUseActiveReorganize(token), +})); + +describe('ReorganizeBanner', () => { + test('shows nothing when no reorganize runs', () => { + mockUseActiveReorganize.mockReturnValue({ operation: null }); + render(<ReorganizeBanner token="token" />); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByText(/Moving downloaded videos/)).not.toBeInTheDocument(); + }); + + test('says what is being moved and that downloads wait', () => { + mockUseActiveReorganize.mockReturnValue({ operation: { id: 3, label: 'Chan', status: 'running', total: 10, done: 3, failed: 1 } }); + render(<ReorganizeBanner token="token" />); + + expect(screen.getByText(/Moving downloaded videos for Chan: 4 of 10\./)).toBeInTheDocument(); + expect(screen.getByText(/Downloads wait in the queue/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/shared/Reorganize/ReorganizeDialog.tsx b/client/src/components/shared/Reorganize/ReorganizeDialog.tsx new file mode 100644 index 000000000..007504d62 --- /dev/null +++ b/client/src/components/shared/Reorganize/ReorganizeDialog.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, +} from '../../ui'; +import { ReorganizeChange, ReorganizeStartResult } from '../../../types/reorganize'; +import { useReorganizePreview } from './hooks/useReorganizePreview'; +import { useReorganizeOperation } from './hooks/useReorganizeOperation'; +import ReorganizePreviewBody from './ReorganizePreviewBody'; +import ReorganizeOperationBody from './ReorganizeOperationBody'; +import { serverCodeOf, serverMessageOf } from './reorganizeErrors'; +import { countOf } from './reorganizeText'; + +const STALE_PREVIEW_CODE = 'STALE_PREVIEW'; + +export interface ReorganizeDialogProps { + open: boolean; + token: string | null; + /** The change to preview and apply */ + change?: ReorganizeChange | null; + /** Show an operation's progress or result instead of a preview */ + operationId?: number | null; + onClose: () => void; + /** The change was applied (its files move in the background, or nothing had to move) */ + onApplied?: (result: ReorganizeStartResult) => void; + /** A retry of the shown operation started (it applies the settings again when they were undone) */ + onRetried?: (operationId: number) => void; +} + +/** + * Review a change that moves downloaded files (the dry run), start it, and + * follow it to its result. + */ +function ReorganizeDialog({ + open, token, change = null, operationId: initialOperationId = null, onClose, onApplied, onRetried, +}: ReorganizeDialogProps) { + const [operationId, setOperationId] = useState<number | null>(initialOperationId); + const [starting, setStarting] = useState(false); + const [startError, setStartError] = useState<string | null>(null); + // Ref, not state, so a double click can't start two operations. + const startingRef = useRef(false); + + useEffect(() => { + if (!open) return; + setOperationId(initialOperationId); + setStartError(null); + startingRef.current = false; + }, [open, initialOperationId, change]); + + const previewing = open && operationId === null; + const { preview, loading, error: previewError, refresh } = useReorganizePreview(token, previewing ? change : null); + const { operation, error: operationError, retrying, retry } = useReorganizeOperation(token, open ? operationId : null); + + const start = async () => { + if (!preview || !change || startingRef.current) return; + startingRef.current = true; + setStarting(true); + setStartError(null); + try { + const response = await axios.post<ReorganizeStartResult>( + '/api/tv/reorganize', + { change, revision: preview.revision }, + { headers: { 'x-access-token': token || '' } } + ); + onApplied?.(response.data); + if (response.data.operationId) { + setOperationId(response.data.operationId); + } else { + onClose(); + } + } catch (err: unknown) { + startingRef.current = false; + setStartError(serverMessageOf(err, 'Failed to start the move')); + if (serverCodeOf(err) === STALE_PREVIEW_CODE) await refresh(); + } finally { + setStarting(false); + } + }; + + const handleRetry = async () => { + if (await retry() && operationId !== null) onRetried?.(operationId); + }; + + const running = operation !== null && (operation.status === 'running' || operation.status === 'starting'); + const canStart = Boolean(preview) && !loading && !preview?.blocked && !starting; + const startLabel = preview && preview.needed ? `Move ${countOf(preview.totals.videos, 'video')}` : 'Apply'; + + return ( + <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> + <DialogTitle onClose={onClose}>{operationId === null ? 'Review the move' : 'Moving downloads'}</DialogTitle> + <DialogContent> + {operationId === null ? ( + <div className="flex flex-col gap-3 pt-2"> + {loading && <LinearProgress />} + {previewError && <Alert severity="error">{previewError}</Alert>} + {startError && <Alert severity="error">{startError}</Alert>} + {preview && <ReorganizePreviewBody preview={preview} />} + </div> + ) : ( + <div className="pt-2"> + <ReorganizeOperationBody operation={operation} error={operationError} retrying={retrying} onRetry={() => { void handleRetry(); }} /> + </div> + )} + </DialogContent> + <DialogActions> + {operationId === null ? ( + <> + <Button onClick={onClose} variant="outlined">Cancel</Button> + <Button + onClick={() => { void start(); }} + variant="contained" + disabled={!canStart} + startIcon={starting ? <CircularProgress size={14} /> : undefined} + > + {startLabel} + </Button> + </> + ) : ( + <Button onClick={onClose} variant="contained">{running ? 'Close' : 'Done'}</Button> + )} + </DialogActions> + </Dialog> + ); +} + +export default ReorganizeDialog; diff --git a/client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx b/client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx new file mode 100644 index 000000000..3133ac666 --- /dev/null +++ b/client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Alert, Box, Button, LinearProgress, Typography } from '../../ui'; +import { ReorganizeOperation } from '../../../types/reorganize'; +import { agree, countOf, DOWNLOADS_WAIT_NOTE } from './reorganizeText'; + +interface ReorganizeOperationBodyProps { + operation: ReorganizeOperation | null; + error: string | null; + retrying: boolean; + onRetry: () => void; +} + +function isRunning(operation: ReorganizeOperation): boolean { + return operation.status === 'running' || operation.status === 'starting'; +} + +function resultAlert(operation: ReorganizeOperation) { + const done = operation.done ?? 0; + const failed = operation.failed ?? 0; + if (operation.status === 'completed') { + return <Alert severity="success">Moved {countOf(done, 'video')}.</Alert>; + } + if (operation.status === 'partial') { + return ( + <Alert severity="warning"> + Moved {countOf(done, 'video')}. {countOf(failed, 'video')} could not be moved and + {' '}{agree(failed, 'stays where it was', 'stay where they were')}. + </Alert> + ); + } + return <Alert severity="error">{operation.error || 'The move failed.'}</Alert>; +} + +/** A reorganize's progress, then its result with the videos that could not move. */ +function ReorganizeOperationBody({ operation, error, retrying, onRetry }: ReorganizeOperationBodyProps) { + if (!operation) { + return error ? <Alert severity="error">{error}</Alert> : <LinearProgress />; + } + const total = operation.total ?? 0; + const handled = (operation.done ?? 0) + (operation.failed ?? 0); + + if (isRunning(operation)) { + return ( + <Box className="flex flex-col gap-2"> + <Typography variant="body2"> + Moving videos for {operation.label}: {handled} of {total}. + </Typography> + <LinearProgress + variant={total > 0 ? 'determinate' : 'indeterminate'} + value={total > 0 ? Math.round((handled / total) * 100) : undefined} + /> + <Typography variant="caption" color="text.secondary"> + {DOWNLOADS_WAIT_NOTE} You can close this window; the move keeps going. + </Typography> + </Box> + ); + } + + const failedItems = operation.failedItems ?? []; + return ( + <Box className="flex flex-col gap-3"> + {resultAlert(operation)} + {error && <Alert severity="error">{error}</Alert>} + {failedItems.length > 0 && ( + <Box> + <Typography variant="body2" className="font-semibold">Not moved</Typography> + <ul aria-label="Videos not moved" className="max-h-[260px] divide-y divide-border overflow-auto"> + {failedItems.map((item) => ( + <li key={item.id} className="py-2"> + <Typography variant="body2" className="break-words">{item.title || item.youtubeId}</Typography> + <Typography variant="caption" color="text.secondary" className="break-words">{item.error}</Typography> + </li> + ))} + </ul> + <Button variant="outlined" size="small" onClick={onRetry} disabled={retrying} className="mt-2"> + {retrying ? 'Retrying...' : 'Retry these videos'} + </Button> + </Box> + )} + </Box> + ); +} + +export default ReorganizeOperationBody; diff --git a/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx new file mode 100644 index 000000000..a30af9713 --- /dev/null +++ b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { Alert, Box, Chip, Typography } from '../../ui'; +import { ReorganizePreview, ReorganizePreviewItem } from '../../../types/reorganize'; +import { + agree, countOf, DOWNLOADS_WAIT_NOTE, folderName, MOVIE_TAGS_NOTE, serverName, WATCH_STATE_NOTE, +} from './reorganizeText'; + +interface ReorganizePreviewBodyProps { + preview: ReorganizePreview; +} + +function MoveRow({ item }: { item: ReorganizePreviewItem }) { + return ( + <li className="flex flex-col gap-0.5 py-2"> + <Box className="flex flex-wrap items-center gap-2"> + <Typography variant="body2" className="font-medium break-words">{item.title || item.youtubeId}</Typography> + {item.episode && <Chip label={item.episode} size="small" variant="outlined" />} + </Box> + <Typography variant="caption" color="text.secondary" className="break-all">From: {item.from}</Typography> + <Typography variant="caption" color="text.secondary" className="break-all">To: {item.to}</Typography> + </li> + ); +} + +function summaryLines(preview: ReorganizePreview): string[] { + const { totals } = preview; + const lines: string[] = []; + const { toTv, toVideos, betweenFolders, unchanged } = totals; + if (toTv > 0) lines.push(`${countOf(toTv, 'video')} ${agree(toTv, 'becomes a TV episode', 'become TV episodes')}.`); + if (toVideos > 0) lines.push(`${countOf(toVideos, 'video')} ${agree(toVideos, 'is', 'are')} saved movie-style again.`); + if (betweenFolders > 0) lines.push(`${countOf(betweenFolders, 'video')} ${agree(betweenFolders, 'moves', 'move')} to another folder.`); + if (unchanged > 0) { + lines.push(`${countOf(unchanged, 'video')} ${agree(unchanged, 'is already where it belongs', 'are already where they belong')}.`); + } + return lines; +} + +function noteLines(preview: ReorganizePreview): string[] { + const { totals } = preview; + const notes: string[] = []; + const { overridePlaced, adopted, uploadDateOnly, downloadTime, movieTags } = totals; + if (overridePlaced > 0) { + notes.push(`${countOf(overridePlaced, 'video')} downloaded to another folder with a download override ` + + `${agree(overridePlaced, 'moves', 'move')} with the channel.`); + } + if (adopted > 0) { + notes.push(`${countOf(adopted, 'file')} named by the Plex TV Series preset ${agree(adopted, 'keeps its', 'keep their')} episode ` + + `${agree(adopted, 'number', 'numbers')}.`); + } + if (uploadDateOnly > 0) { + notes.push(`${countOf(uploadDateOnly, 'video')} ${agree(uploadDateOnly, 'has', 'have')} no exact upload time, so ` + + `${agree(uploadDateOnly, 'it is', 'they are')} numbered by upload day.`); + } + if (downloadTime > 0) { + notes.push(`${countOf(downloadTime, 'video')} ${agree(downloadTime, 'has', 'have')} no upload date, so ` + + `${agree(downloadTime, 'it is', 'they are')} numbered by download time.`); + } + if (movieTags > 0) notes.push(`${countOf(movieTags, 'video')} ${agree(movieTags, 'keeps', 'keep')} ${MOVIE_TAGS_NOTE}`); + return notes; +} + +function problemLines(preview: ReorganizePreview): string[] { + const { totals } = preview; + const lines: string[] = []; + const stays = (count: number) => agree(count, 'stays where it is', 'stay where they are'); + if (totals.missing > 0) { + lines.push(`${countOf(totals.missing, 'video')} ${agree(totals.missing, 'has', 'have')} no file on disk and ` + + `${agree(totals.missing, 'is', 'are')} left as recorded.`); + } + if (totals.noName > 0) { + lines.push(`${countOf(totals.noName, 'video')} could not be given a file name and ${stays(totals.noName)}.`); + } + if (totals.noDate > 0) { + lines.push(`${countOf(totals.noDate, 'video')} ${agree(totals.noDate, 'has', 'have')} no date to number ` + + `${agree(totals.noDate, 'it', 'them')} by and ${stays(totals.noDate)}.`); + } + if (totals.unsafeName > 0) { + lines.push(`${countOf(totals.unsafeName, 'video')} would land outside the downloads folder and ${stays(totals.unsafeName)}.`); + } + if (totals.collisions > 0) { + lines.push(`${countOf(totals.collisions, 'file')} would replace a file that is already there. ` + + `${agree(totals.collisions, 'Its video fails', 'Those videos fail')} until it is removed.`); + } + return lines; +} + +function BlockedAlert({ blocked }: { blocked: NonNullable<ReorganizePreview['blocked']> }) { + // Something else is running, or nothing can be planned: only the first ends on its own. + const suffix = blocked.reason === 'problems' ? '' : ' You can start the move once that finishes.'; + return <Alert severity="warning">{blocked.message}{suffix}</Alert>; +} + +/** The dry run of a reorganize: what moves where, and what to know first. */ +function ReorganizePreviewBody({ preview }: ReorganizePreviewBodyProps) { + const problems = problemLines(preview); + if (!preview.needed) { + return ( + <Box className="flex flex-col gap-3"> + {preview.blocked && <BlockedAlert blocked={preview.blocked} />} + {problems.length > 0 && ( + <Alert severity="warning"> + {problems.map((line) => <Typography key={line} variant="body2">{line}</Typography>)} + </Alert> + )} + {!preview.blocked && ( + <Alert severity="info">No downloaded files need to move. The change is applied without moving anything.</Alert> + )} + </Box> + ); + } + const notes = noteLines(preview); + const createdShows = preview.shows.filter((show) => show.action !== 'keep'); + const truncated = preview.totals.videos > preview.items.length; + + return ( + <Box className="flex flex-col gap-3"> + {preview.blocked && <BlockedAlert blocked={preview.blocked} />} + <Box> + <Typography variant="body2" className="font-semibold"> + {countOf(preview.totals.videos, 'downloaded video')} move for {preview.change.label}. + </Typography> + {summaryLines(preview).map((line) => ( + <Typography key={line} variant="body2" color="text.secondary">{line}</Typography> + ))} + <Typography variant="caption" color="text.secondary">{DOWNLOADS_WAIT_NOTE}</Typography> + </Box> + {createdShows.length > 0 && ( + <Box> + <Typography variant="body2" className="font-semibold">Shows</Typography> + {createdShows.map((show) => ( + <Typography key={`${show.libraryFolder}/${show.folderName}`} variant="body2" color="text.secondary"> + {show.action === 'create' ? 'New show' : 'Moves to'}: {folderName(show.libraryFolder)}/{show.folderName} + </Typography> + ))} + </Box> + )} + {problems.length > 0 && ( + <Alert severity="warning"> + {problems.map((line) => <Typography key={line} variant="body2">{line}</Typography>)} + </Alert> + )} + {preview.watchState.length > 0 && ( + <Alert severity="info"> + <Typography variant="body2">{WATCH_STATE_NOTE}</Typography> + {preview.watchState.map((server) => ( + <Typography key={server.serverType} variant="body2"> + {serverName(server.serverType)}: {countOf(server.videos, 'video')} watched or in progress + {' '}({countOf(server.users, 'user')}). + </Typography> + ))} + </Alert> + )} + {notes.length > 0 && ( + <Box> + {notes.map((line) => <Typography key={line} variant="caption" color="text.secondary" className="block">{line}</Typography>)} + </Box> + )} + <Box> + <Typography variant="body2" className="font-semibold">Moves</Typography> + <ul aria-label="Planned moves" className="max-h-[320px] divide-y divide-border overflow-auto"> + {preview.items.map((item) => <MoveRow key={item.youtubeId} item={item} />)} + </ul> + {truncated && ( + <Typography variant="caption" color="text.secondary"> + Showing the first {preview.items.length} of {preview.totals.videos}. + </Typography> + )} + </Box> + </Box> + ); +} + +export default ReorganizePreviewBody; diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx new file mode 100644 index 000000000..ea0fe4789 --- /dev/null +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import ReorganizeDialog from '../ReorganizeDialog'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), + isAxiosError: (err: unknown): boolean => typeof err === 'object' && err !== null && 'response' in err, +})); + +const axios = require('axios'); + +const CHANGE = { type: 'channelLayout' as const, channelId: 'UC1', layout: 'tv' as const }; +const PREVIEW = { + revision: 'rev', + needed: true, + change: { type: 'channel', channelId: 'UC1', subFolder: 'TV', label: 'Chan' }, + totals: { + videos: 2, toTv: 2, toVideos: 0, betweenFolders: 0, unchanged: 0, missing: 0, collisions: 0, noName: 0, noDate: 0, + overridePlaced: 0, adopted: 0, uploadDateOnly: 0, downloadTime: 0, movieTags: 0, + }, + shows: [], + items: [], + problems: [], + watchState: [], + blocked: null, +}; + +function renderDialog(props: Partial<React.ComponentProps<typeof ReorganizeDialog>> = {}) { + const onClose = jest.fn(); + const onApplied = jest.fn(); + render(<ReorganizeDialog open token="token" change={CHANGE} onClose={onClose} onApplied={onApplied} {...props} />); + return { onClose, onApplied, user: userEvent.setup() }; +} + +describe('ReorganizeDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('previews the change and starts it with the preview\'s revision', async () => { + axios.post.mockImplementation(async (url: string) => (url === '/api/tv/reorganize/preview' + ? { data: PREVIEW } + : { data: { operationId: 3, applied: false } })); + axios.get.mockResolvedValue({ data: { id: 3, label: 'Chan', status: 'running', total: 2, done: 0, failed: 0 } }); + const { onApplied, user } = renderDialog(); + + await user.click(await screen.findByRole('button', { name: 'Move 2 videos' })); + + expect(axios.post).toHaveBeenCalledWith('/api/tv/reorganize', { change: CHANGE, revision: 'rev' }, { headers: { 'x-access-token': 'token' } }); + expect(onApplied).toHaveBeenCalledWith({ operationId: 3, applied: false }); + expect(await screen.findByText('Moving videos for Chan: 0 of 2.')).toBeInTheDocument(); + }); + + test('can\'t start while something blocks the move', async () => { + axios.post.mockResolvedValueOnce({ data: { ...PREVIEW, blocked: { reason: 'download-running', message: 'Wait for the download.' } } }); + renderDialog(); + + expect(await screen.findByRole('button', { name: 'Move 2 videos' })).toBeDisabled(); + }); + + test('closes after applying a change with nothing to move', async () => { + axios.post.mockImplementation(async (url: string) => (url === '/api/tv/reorganize/preview' + ? { data: { ...PREVIEW, needed: false } } + : { data: { operationId: null, applied: true } })); + const { onApplied, onClose, user } = renderDialog(); + + await user.click(await screen.findByRole('button', { name: 'Apply' })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(onApplied).toHaveBeenCalledWith({ operationId: null, applied: true }); + }); + + test('previews again when the preview went stale', async () => { + axios.post + .mockResolvedValueOnce({ data: PREVIEW }) + .mockRejectedValueOnce({ response: { status: 409, data: { error: 'Files changed since the preview.', code: 'STALE_PREVIEW' } } }) + .mockResolvedValueOnce({ data: { ...PREVIEW, revision: 'rev2' } }); + const { user } = renderDialog(); + + await user.click(await screen.findByRole('button', { name: 'Move 2 videos' })); + + expect(await screen.findByText('Files changed since the preview.')).toBeInTheDocument(); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(3)); + }); + + test('tells the parent when a retry of the shown operation starts', async () => { + axios.get.mockResolvedValue({ data: { + id: 7, label: 'Chan', status: 'partial', total: 2, done: 1, failed: 1, + failedItems: [{ id: 1, youtubeId: 'a1', title: 'First', channelId: 'UC1', error: 'EEXIST' }], + } }); + axios.post.mockResolvedValueOnce({ data: { operationId: 7 } }); + const onRetried = jest.fn(); + const { user } = renderDialog({ change: null, operationId: 7, onRetried }); + + await user.click(await screen.findByRole('button', { name: 'Retry these videos' })); + + await waitFor(() => expect(onRetried).toHaveBeenCalledWith(7)); + }); + + test('does not report a refused retry as started', async () => { + axios.get.mockResolvedValue({ data: { + id: 7, label: 'Chan', status: 'partial', total: 2, done: 1, failed: 1, + failedItems: [{ id: 1, youtubeId: 'a1', title: 'First', channelId: 'UC1', error: 'EEXIST' }], + } }); + axios.post.mockRejectedValueOnce({ response: { status: 409, data: { error: 'A download is running.' } } }); + const onRetried = jest.fn(); + const { user } = renderDialog({ change: null, operationId: 7, onRetried }); + + await user.click(await screen.findByRole('button', { name: 'Retry these videos' })); + + expect(await screen.findByText('A download is running.')).toBeInTheDocument(); + expect(onRetried).not.toHaveBeenCalled(); + }); + + test('shows an operation\'s result when opened on it', async () => { + axios.get.mockResolvedValue({ data: { id: 7, label: 'Chan', status: 'completed', total: 2, done: 2, failed: 0, failedItems: [] } }); + renderDialog({ change: null, operationId: 7 }); + + expect(await screen.findByText('Moved 2 videos.')).toBeInTheDocument(); + expect(axios.post).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx new file mode 100644 index 000000000..e3f17ffcd --- /dev/null +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import ReorganizeOperationBody from '../ReorganizeOperationBody'; +import { ReorganizeOperation } from '../../../../types/reorganize'; + +const operation = (overrides: Partial<ReorganizeOperation> = {}): ReorganizeOperation => ({ + id: 3, label: 'Chan', status: 'running', total: 4, done: 1, failed: 1, failedItems: [], ...overrides, +}); + +function renderBody(op: ReorganizeOperation | null, extra: { error?: string | null; onRetry?: () => void } = {}) { + const onRetry = extra.onRetry ?? jest.fn(); + render(<ReorganizeOperationBody operation={op} error={extra.error ?? null} retrying={false} onRetry={onRetry} />); + return { onRetry, user: userEvent.setup() }; +} + +describe('ReorganizeOperationBody', () => { + test('shows progress while running', () => { + renderBody(operation()); + + expect(screen.getByText('Moving videos for Chan: 2 of 4.')).toBeInTheDocument(); + expect(screen.getByText(/You can close this window/)).toBeInTheDocument(); + }); + + test('reports a finished move', () => { + renderBody(operation({ status: 'completed', done: 4, failed: 0 })); + + expect(screen.getByText('Moved 4 videos.')).toBeInTheDocument(); + }); + + test('lists the videos that could not move and retries them', async () => { + const { onRetry, user } = renderBody(operation({ + status: 'partial', done: 3, failed: 1, + failedItems: [{ id: 8, youtubeId: 'a1', title: 'First', channelId: 'UC1', error: 'A file already exists' }], + })); + + expect(screen.getByText('Moved 3 videos. 1 video could not be moved and stays where it was.')).toBeInTheDocument(); + expect(screen.getByText('A file already exists')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Retry these videos' })); + + expect(onRetry).toHaveBeenCalled(); + }); + + test('shows why a move failed', () => { + renderBody(operation({ status: 'failed', done: 0, error: 'No video could be moved, so the settings change was undone.' })); + + expect(screen.getByText('No video could be moved, so the settings change was undone.')).toBeInTheDocument(); + }); + + test('shows a load error before the operation is known', () => { + renderBody(null, { error: 'Reorganize not found' }); + + expect(screen.getByText('Reorganize not found')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx new file mode 100644 index 000000000..b02a91c8f --- /dev/null +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ReorganizePreviewBody from '../ReorganizePreviewBody'; +import { ReorganizePreview } from '../../../../types/reorganize'; + +const TOTALS = { + videos: 2, toTv: 2, toVideos: 0, betweenFolders: 0, unchanged: 1, missing: 0, collisions: 0, noName: 0, noDate: 0, + unsafeName: 0, overridePlaced: 0, adopted: 0, uploadDateOnly: 0, downloadTime: 0, movieTags: 0, +}; + +const preview = (overrides: Partial<ReorganizePreview> = {}): ReorganizePreview => ({ + revision: 'rev', + needed: true, + change: { type: 'channel', channelId: 'UC1', subFolder: 'TV', label: 'Chan' }, + totals: TOTALS, + shows: [{ name: 'Chan', libraryFolder: 'TV', folderName: 'Chan', action: 'create' }], + items: [ + { youtubeId: 'a1', title: 'First', from: '__Kids/Chan/A [a1].mp4', to: '__TV/Chan/Season 2024/S2024E01 - First [a1].mp4', episode: 'S2024E01', flags: [] }, + { youtubeId: 'b2', title: 'Second', from: '__Kids/Chan/B [b2].mp4', to: '__TV/Chan/Season 2024/S2024E02 - Second [b2].mp4', episode: 'S2024E02', flags: [] }, + ], + problems: [], + watchState: [], + blocked: null, + ...overrides, +}); + +describe('ReorganizePreviewBody', () => { + test('summarizes what moves for whom', () => { + render(<ReorganizePreviewBody preview={preview()} />); + + expect(screen.getByText('2 downloaded videos move for Chan.')).toBeInTheDocument(); + expect(screen.getByText('2 videos become TV episodes.')).toBeInTheDocument(); + expect(screen.getByText('1 video is already where it belongs.')).toBeInTheDocument(); + }); + + test('lists each move with its old and new path and episode', () => { + render(<ReorganizePreviewBody preview={preview()} />); + + const moves = within(screen.getByRole('list', { name: 'Planned moves' })); + expect(moves.getAllByRole('listitem')).toHaveLength(2); + expect(moves.getByText('From: __Kids/Chan/A [a1].mp4')).toBeInTheDocument(); + expect(moves.getByText('To: __TV/Chan/Season 2024/S2024E01 - First [a1].mp4')).toBeInTheDocument(); + expect(moves.getByText('S2024E01')).toBeInTheDocument(); + }); + + test('names the shows it creates', () => { + render(<ReorganizePreviewBody preview={preview()} />); + + expect(screen.getByText('New show: __TV/Chan')).toBeInTheDocument(); + }); + + test('says when only part of the list is shown', () => { + render(<ReorganizePreviewBody preview={preview({ totals: { ...TOTALS, videos: 250 } })} />); + + expect(screen.getByText('Showing the first 2 of 250.')).toBeInTheDocument(); + }); + + test('warns about videos that can\'t move', () => { + render(<ReorganizePreviewBody preview={preview({ totals: { ...TOTALS, missing: 1, collisions: 2 } })} />); + + expect(screen.getByText('1 video has no file on disk and is left as recorded.')).toBeInTheDocument(); + expect(screen.getByText(/2 files would replace a file that is already there/)).toBeInTheDocument(); + }); + + test('explains the watch state media servers lose', () => { + render(<ReorganizePreviewBody preview={preview({ watchState: [{ serverType: 'jellyfin', videos: 3, users: 2 }] })} />); + + expect(screen.getByText(/Youtarr keeps its own watched state/)).toBeInTheDocument(); + expect(screen.getByText('Jellyfin: 3 videos watched or in progress (2 users).')).toBeInTheDocument(); + }); + + test('says why the move can\'t start yet', () => { + render(<ReorganizePreviewBody preview={preview({ blocked: { reason: 'download-running', message: 'Wait for the current download to finish, then try again.' } })} />); + + expect(screen.getByText(/Wait for the current download to finish/)).toBeInTheDocument(); + }); + + test('says when nothing has to move', () => { + render(<ReorganizePreviewBody preview={preview({ needed: false })} />); + + expect(screen.getByText(/No downloaded files need to move/)).toBeInTheDocument(); + }); + + test('still lists the problems when nothing can move, and why the change is refused', () => { + render(<ReorganizePreviewBody preview={preview({ + needed: false, + items: [], + totals: { ...TOTALS, videos: 0, toTv: 0, unchanged: 0, noName: 3 }, + blocked: { reason: 'problems', message: 'None of the downloaded videos can be moved: 3 could not be given a destination.' }, + })} />); + + expect(screen.getByText('3 videos could not be given a file name and stay where they are.')).toBeInTheDocument(); + expect(screen.getByText(/None of the downloaded videos can be moved/)).toBeInTheDocument(); + expect(screen.queryByText(/once that finishes/)).not.toBeInTheDocument(); + expect(screen.queryByText(/No downloaded files need to move/)).not.toBeInTheDocument(); + }); + + test('warns about a destination outside the downloads folder', () => { + render(<ReorganizePreviewBody preview={preview({ totals: { ...TOTALS, unsafeName: 1 } })} />); + + expect(screen.getByText(/1 video would land outside the downloads folder/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/shared/Reorganize/__tests__/reorganizeErrors.test.ts b/client/src/components/shared/Reorganize/__tests__/reorganizeErrors.test.ts new file mode 100644 index 000000000..c0eb875d1 --- /dev/null +++ b/client/src/components/shared/Reorganize/__tests__/reorganizeErrors.test.ts @@ -0,0 +1,47 @@ +import { + ReorganizeRequiredError, isReorganizeRequired, reorganizeChangeOf, serverCodeOf, serverMessageOf, toRequestError, +} from '../reorganizeErrors'; + +jest.mock('axios', () => ({ + isAxiosError: (err: unknown): boolean => typeof err === 'object' && err !== null && 'response' in err, +})); + +const CHANGE = { type: 'folderLayout' as const, folder: 'Kids', layout: 'tv' as const }; +const axiosError = (data: unknown) => ({ response: { status: 409, data } }); + +describe('reorganizeErrors', () => { + test('reads the change to preview from a refusal body', () => { + expect(reorganizeChangeOf({ error: 'Review', reorganizeRequired: true, change: CHANGE })).toEqual(CHANGE); + }); + + test('finds no change in other refusals', () => { + expect(reorganizeChangeOf({ error: 'No' })).toBeNull(); + expect(reorganizeChangeOf(null)).toBeNull(); + }); + + test('reads the server message and code of a request error', () => { + const err = axiosError({ error: 'Stale', code: 'STALE_PREVIEW' }); + expect(serverMessageOf(err, 'fallback')).toBe('Stale'); + expect(serverCodeOf(err)).toBe('STALE_PREVIEW'); + }); + + test('falls back for errors without a server message', () => { + expect(serverMessageOf(new Error('network'), 'fallback')).toBe('fallback'); + expect(serverCodeOf(new Error('network'))).toBeNull(); + }); + + test('turns a reorganize refusal into a ReorganizeRequiredError', () => { + const err = toRequestError(axiosError({ error: 'Review the move', reorganizeRequired: true, change: CHANGE }), 'fallback'); + + expect(isReorganizeRequired(err)).toBe(true); + expect((err as ReorganizeRequiredError).change).toEqual(CHANGE); + expect(err.message).toBe('Review the move'); + }); + + test('turns any other refusal into a plain error', () => { + const err = toRequestError(axiosError({ error: 'Nope' }), 'fallback'); + + expect(isReorganizeRequired(err)).toBe(false); + expect(err.message).toBe('Nope'); + }); +}); diff --git a/client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts b/client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts new file mode 100644 index 000000000..0d5c73671 --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts @@ -0,0 +1,57 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import WebSocketContext from '../../../../../contexts/WebSocketContext'; +import { useActiveReorganize } from '../useActiveReorganize'; + +jest.mock('axios', () => ({ get: jest.fn() })); + +const axios = require('axios'); + +type Subscriber = { filter: (msg: unknown) => boolean; callback: (payload: unknown) => void }; + +function makeWrapper(subscribers: Subscriber[]) { + const value = { + socket: null, + subscribe: (filter: (msg: unknown) => boolean, callback: (payload: unknown) => void) => { + subscribers.push({ filter, callback }); + }, + unsubscribe: jest.fn(), + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(WebSocketContext.Provider, { value }, children); + }; +} + +describe('useActiveReorganize', () => { + let subscribers: Subscriber[]; + const progress = (status: string) => ({ + type: 'tvReorganizeProgress', payload: { operationId: 3, status, total: 4, done: 1, failed: 0, label: 'Chan' }, + }); + const send = (message: { type: string; payload: unknown }) => subscribers + .filter((s) => s.filter(message)).forEach((s) => s.callback(message.payload)); + + beforeEach(() => { + jest.clearAllMocks(); + subscribers = []; + axios.get.mockResolvedValue({ data: { operation: null } }); + }); + + test('loads the running reorganize', async () => { + axios.get.mockResolvedValueOnce({ data: { operation: { id: 3, label: 'Chan', status: 'running' } } }); + + const { result } = renderHook(() => useActiveReorganize('token'), { wrapper: makeWrapper(subscribers) }); + + await waitFor(() => expect(result.current.operation).toMatchObject({ id: 3 })); + }); + + test('follows progress broadcasts and clears when the reorganize ends', async () => { + const { result } = renderHook(() => useActiveReorganize('token'), { wrapper: makeWrapper(subscribers) }); + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + + act(() => send(progress('running'))); + expect(result.current.operation).toMatchObject({ id: 3, label: 'Chan', done: 1, total: 4 }); + + act(() => send(progress('completed'))); + expect(result.current.operation).toBeNull(); + }); +}); diff --git a/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOperation.test.ts b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOperation.test.ts new file mode 100644 index 000000000..20ce3ac8a --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOperation.test.ts @@ -0,0 +1,127 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import WebSocketContext from '../../../../../contexts/WebSocketContext'; +import { useReorganizeOperation } from '../useReorganizeOperation'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), + isAxiosError: (err: unknown): boolean => typeof err === 'object' && err !== null && 'response' in err, +})); + +const axios = require('axios'); + +type Subscriber = { filter: (msg: unknown) => boolean; callback: (payload: unknown) => void }; + +function makeWrapper(subscribers: Subscriber[]) { + const value = { + socket: null, + subscribe: (filter: (msg: unknown) => boolean, callback: (payload: unknown) => void) => { + subscribers.push({ filter, callback }); + }, + unsubscribe: (callback: (payload: unknown) => void) => { + const index = subscribers.findIndex((s) => s.callback === callback); + if (index >= 0) subscribers.splice(index, 1); + }, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(WebSocketContext.Provider, { value }, children); + }; +} + +function broadcast(subscribers: Subscriber[], message: { type: string; payload?: unknown }) { + subscribers.filter((s) => s.filter(message)).forEach((s) => s.callback(message.payload)); +} + +const RUNNING = { id: 3, label: 'Chan', status: 'running', total: 4, done: 0, failed: 0 }; + +describe('useReorganizeOperation', () => { + let subscribers: Subscriber[]; + + beforeEach(() => { + jest.clearAllMocks(); + subscribers = []; + }); + + const render = (operationId: number | null = 3) => renderHook( + () => useReorganizeOperation('token', operationId), { wrapper: makeWrapper(subscribers) } + ); + + test('loads the operation', async () => { + axios.get.mockResolvedValueOnce({ data: RUNNING }); + + const { result } = render(); + + await waitFor(() => expect(result.current.operation).toEqual(RUNNING)); + expect(axios.get).toHaveBeenCalledWith('/api/tv/operations/3', { headers: { 'x-access-token': 'token' } }); + }); + + test('updates the counts from progress broadcasts', async () => { + axios.get.mockResolvedValueOnce({ data: RUNNING }); + const { result } = render(); + await waitFor(() => expect(result.current.operation).toEqual(RUNNING)); + + act(() => broadcast(subscribers, { + type: 'tvReorganizeProgress', payload: { operationId: 3, status: 'running', total: 4, done: 2, failed: 1, label: 'Chan' }, + })); + + expect(result.current.operation).toMatchObject({ done: 2, failed: 1 }); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + test('ignores another operation\'s progress', async () => { + axios.get.mockResolvedValueOnce({ data: RUNNING }); + const { result } = render(); + await waitFor(() => expect(result.current.operation).toEqual(RUNNING)); + + act(() => broadcast(subscribers, { + type: 'tvReorganizeProgress', payload: { operationId: 9, status: 'running', total: 1, done: 1, failed: 0, label: 'X' }, + })); + + expect(result.current.operation).toEqual(RUNNING); + }); + + test('reloads the result when the operation ends', async () => { + axios.get.mockResolvedValueOnce({ data: RUNNING }).mockResolvedValueOnce({ data: { ...RUNNING, status: 'completed', done: 4 } }); + const { result } = render(); + await waitFor(() => expect(result.current.operation).toEqual(RUNNING)); + + act(() => broadcast(subscribers, { + type: 'tvReorganizeProgress', payload: { operationId: 3, status: 'completed', total: 4, done: 4, failed: 0, label: 'Chan' }, + })); + + await waitFor(() => expect(result.current.operation?.status).toBe('completed')); + }); + + test('retries failed videos and reloads', async () => { + axios.get.mockResolvedValue({ data: { ...RUNNING, status: 'partial' } }); + axios.post.mockResolvedValueOnce({ data: { operationId: 3 } }); + const { result } = render(); + await waitFor(() => expect(result.current.operation).not.toBeNull()); + + let started: boolean | undefined; + await act(async () => { started = await result.current.retry(); }); + + expect(started).toBe(true); + expect(axios.post).toHaveBeenCalledWith('/api/tv/operations/3/retry', undefined, { headers: { 'x-access-token': 'token' } }); + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + test('shows a refused retry', async () => { + axios.get.mockResolvedValue({ data: { ...RUNNING, status: 'partial' } }); + axios.post.mockRejectedValueOnce({ response: { status: 409, data: { error: 'A download is running.' } } }); + const { result } = render(); + await waitFor(() => expect(result.current.operation).not.toBeNull()); + + let started: boolean | undefined; + await act(async () => { started = await result.current.retry(); }); + + expect(started).toBe(false); + expect(result.current.error).toBe('A download is running.'); + }); + + test('does nothing without an operation', () => { + render(null); + expect(axios.get).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOutcome.test.ts b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOutcome.test.ts new file mode 100644 index 000000000..f8ea57762 --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeOutcome.test.ts @@ -0,0 +1,108 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import WebSocketContext from '../../../../../contexts/WebSocketContext'; +import { useReorganizeOutcome } from '../useReorganizeOutcome'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), + isAxiosError: (err: unknown): boolean => typeof err === 'object' && err !== null && 'response' in err, +})); + +const axios = require('axios'); + +type Subscriber = { filter: (msg: unknown) => boolean; callback: (payload: unknown) => void }; + +function makeWrapper(subscribers: Subscriber[]) { + const value = { + socket: null, + subscribe: (filter: (msg: unknown) => boolean, callback: (payload: unknown) => void) => { + subscribers.push({ filter, callback }); + }, + unsubscribe: (callback: (payload: unknown) => void) => { + const index = subscribers.findIndex((s) => s.callback === callback); + if (index >= 0) subscribers.splice(index, 1); + }, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(WebSocketContext.Provider, { value }, children); + }; +} + +function broadcast(subscribers: Subscriber[], message: { type: string; payload?: unknown }) { + subscribers.filter((s) => s.filter(message)).forEach((s) => s.callback(message.payload)); +} + +const RUNNING = { id: 3, label: 'Chan', status: 'running', total: 2, done: 0, failed: 0, finishedAt: null }; +const FAILED = { ...RUNNING, status: 'failed', failed: 2, error: 'undone', finishedAt: '2026-10-03T12:00:00.000Z' }; + +describe('useReorganizeOutcome', () => { + let subscribers: Subscriber[]; + + beforeEach(() => { + jest.clearAllMocks(); + subscribers = []; + }); + + test('calls back once when the operation ends', async () => { + axios.get.mockResolvedValueOnce({ data: RUNNING }).mockResolvedValueOnce({ data: FAILED }); + const onFinished = jest.fn(); + const { rerender } = renderHook(() => useReorganizeOutcome('token', 3, onFinished), { wrapper: makeWrapper(subscribers) }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + expect(onFinished).not.toHaveBeenCalled(); + + act(() => broadcast(subscribers, { type: 'tvReorganizeProgress', payload: { operationId: 3, status: 'failed', total: 2, done: 0, failed: 2 } })); + + await waitFor(() => expect(onFinished).toHaveBeenCalledWith(expect.objectContaining({ id: 3, status: 'failed' }))); + rerender(); + expect(onFinished).toHaveBeenCalledTimes(1); + }); + + test('calls back again when a retry of the same operation ends', async () => { + axios.get + .mockResolvedValueOnce({ data: FAILED }) + .mockResolvedValueOnce({ data: { ...RUNNING, status: 'completed', done: 2, finishedAt: '2026-10-03T12:05:00.000Z' } }); + const onFinished = jest.fn(); + const { rerender } = renderHook( + ({ attempt }: { attempt: number }) => useReorganizeOutcome('token', 3, onFinished, { attempt }), + { wrapper: makeWrapper(subscribers), initialProps: { attempt: 0 } } + ); + await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1)); + + rerender({ attempt: 1 }); + + await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(2)); + expect(onFinished).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'completed' })); + }); + + test('does not call back again while a retry has not ended', async () => { + axios.get.mockResolvedValue({ data: FAILED }); + const onFinished = jest.fn(); + const { rerender } = renderHook( + ({ attempt }: { attempt: number }) => useReorganizeOutcome('token', 3, onFinished, { attempt }), + { wrapper: makeWrapper(subscribers), initialProps: { attempt: 0 } } + ); + await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1)); + + rerender({ attempt: 1 }); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(onFinished).toHaveBeenCalledTimes(1); + }); + + test('calls back for an operation that had already ended when it was loaded', async () => { + axios.get.mockResolvedValueOnce({ data: { ...RUNNING, status: 'partial', finishedAt: '2026-10-03T12:00:00.000Z' } }); + const onFinished = jest.fn(); + renderHook(() => useReorganizeOutcome('token', 3, onFinished), { wrapper: makeWrapper(subscribers) }); + + await waitFor(() => expect(onFinished).toHaveBeenCalledWith(expect.objectContaining({ status: 'partial' }))); + }); + + test('does nothing without an operation', () => { + const onFinished = jest.fn(); + renderHook(() => useReorganizeOutcome('token', null, onFinished), { wrapper: makeWrapper(subscribers) }); + + expect(axios.get).not.toHaveBeenCalled(); + expect(onFinished).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts new file mode 100644 index 000000000..b20797d1f --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts @@ -0,0 +1,52 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useReorganizePreview } from '../useReorganizePreview'; + +jest.mock('axios', () => ({ + post: jest.fn(), + isAxiosError: (err: unknown): boolean => typeof err === 'object' && err !== null && 'response' in err, +})); + +const axios = require('axios'); + +const CHANGE = { type: 'channelLayout' as const, channelId: 'UC1', layout: 'tv' as const }; +const PREVIEW = { revision: 'rev', needed: true }; + +describe('useReorganizePreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('previews the change', async () => { + axios.post.mockResolvedValueOnce({ data: PREVIEW }); + + const { result } = renderHook(() => useReorganizePreview('token', CHANGE)); + + await waitFor(() => expect(result.current.preview).toEqual(PREVIEW)); + expect(axios.post).toHaveBeenCalledWith('/api/tv/reorganize/preview', { change: CHANGE }, { headers: { 'x-access-token': 'token' } }); + expect(result.current.loading).toBe(false); + }); + + test('does nothing without a change', () => { + renderHook(() => useReorganizePreview('token', null)); + expect(axios.post).not.toHaveBeenCalled(); + }); + + test('shows the server\'s refusal', async () => { + axios.post.mockRejectedValueOnce({ response: { status: 400, data: { error: 'Nothing to move' } } }); + + const { result } = renderHook(() => useReorganizePreview('token', CHANGE)); + + await waitFor(() => expect(result.current.error).toBe('Nothing to move')); + expect(result.current.preview).toBeNull(); + }); + + test('previews again on refresh', async () => { + axios.post.mockResolvedValueOnce({ data: PREVIEW }).mockResolvedValueOnce({ data: { ...PREVIEW, revision: 'rev2' } }); + const { result } = renderHook(() => useReorganizePreview('token', CHANGE)); + await waitFor(() => expect(result.current.preview).toEqual(PREVIEW)); + + await act(async () => { await result.current.refresh(); }); + + expect(result.current.preview).toEqual({ ...PREVIEW, revision: 'rev2' }); + }); +}); diff --git a/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeRequest.test.ts b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeRequest.test.ts new file mode 100644 index 000000000..a50c6211e --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizeRequest.test.ts @@ -0,0 +1,30 @@ +import { renderHook, act } from '@testing-library/react'; +import { useReorganizeRequest } from '../useReorganizeRequest'; + +const CHANGE = { type: 'defaultSubfolder' as const, value: 'TV' }; + +describe('useReorganizeRequest', () => { + test('starts closed', () => { + const { result } = renderHook(() => useReorganizeRequest()); + expect(result.current).toMatchObject({ open: false, change: null, operationId: null }); + }); + + test('opens on a change to review', () => { + const { result } = renderHook(() => useReorganizeRequest()); + act(() => result.current.review(CHANGE)); + expect(result.current).toMatchObject({ open: true, change: CHANGE, operationId: null }); + }); + + test('opens on an operation to show', () => { + const { result } = renderHook(() => useReorganizeRequest()); + act(() => result.current.showOperation(4)); + expect(result.current).toMatchObject({ open: true, change: null, operationId: 4 }); + }); + + test('closes', () => { + const { result } = renderHook(() => useReorganizeRequest()); + act(() => result.current.review(CHANGE)); + act(() => result.current.close()); + expect(result.current.open).toBe(false); + }); +}); diff --git a/client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts b/client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts new file mode 100644 index 000000000..6fb343e70 --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts @@ -0,0 +1,59 @@ +import { useCallback, useContext, useEffect, useState } from 'react'; +import axios from 'axios'; +import WebSocketContext from '../../../../contexts/WebSocketContext'; +import { ReorganizeOperation, ReorganizeProgressMessage } from '../../../../types/reorganize'; +import { REORGANIZE_PROGRESS_MESSAGE } from './useReorganizeOperation'; + +export interface UseActiveReorganizeResult { + /** The running reorganize, or null */ + operation: ReorganizeOperation | null; +} + +/** The reorganize running right now, if any (for the app-wide notice). */ +export function useActiveReorganize(token: string | null): UseActiveReorganizeResult { + const [operation, setOperation] = useState<ReorganizeOperation | null>(null); + const ws = useContext(WebSocketContext); + + const fetchActive = useCallback(async () => { + if (!token) return; + try { + const response = await axios.get<{ operation: ReorganizeOperation | null }>('/api/tv/operations/active', { + headers: { 'x-access-token': token }, + }); + setOperation(response.data?.operation ?? null); + } catch { + // The notice is informational; a failed check leaves it as it was. + } + }, [token]); + + useEffect(() => { + fetchActive(); + }, [fetchActive]); + + useEffect(() => { + if (!ws) return undefined; + const progressFilter = (msg: { type?: string }) => msg.type === REORGANIZE_PROGRESS_MESSAGE; + const onProgress = (payload: ReorganizeProgressMessage) => { + if (payload.status !== 'running') { + setOperation(null); + return; + } + setOperation({ + id: payload.operationId, label: payload.label, status: payload.status, + total: payload.total, done: payload.done, failed: payload.failed, + }); + }; + const reconnectFilter = (msg: { type?: string }) => msg.type === 'connectionRestored'; + const onReconnect = () => { fetchActive(); }; + ws.subscribe(progressFilter, onProgress); + ws.subscribe(reconnectFilter, onReconnect); + return () => { + ws.unsubscribe(onProgress); + ws.unsubscribe(onReconnect); + }; + }, [ws, fetchActive]); + + return { operation }; +} + +export default useActiveReorganize; diff --git a/client/src/components/shared/Reorganize/hooks/useReorganizeOperation.ts b/client/src/components/shared/Reorganize/hooks/useReorganizeOperation.ts new file mode 100644 index 000000000..ceca521a1 --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/useReorganizeOperation.ts @@ -0,0 +1,94 @@ +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import WebSocketContext from '../../../../contexts/WebSocketContext'; +import { ReorganizeOperation, ReorganizeProgressMessage } from '../../../../types/reorganize'; +import { serverMessageOf } from '../reorganizeErrors'; + +export const REORGANIZE_PROGRESS_MESSAGE = 'tvReorganizeProgress'; + +export interface UseReorganizeOperationResult { + operation: ReorganizeOperation | null; + error: string | null; + retrying: boolean; + /** Retry the operation's failed videos; resolves to whether the retry started */ + retry: () => Promise<boolean>; + refetch: () => Promise<void>; +} + +/** + * A reorganize's progress and result: loaded over REST, kept current by the + * tvReorganizeProgress broadcasts (counts while it runs, a full reload when it + * ends, for its failed videos). + */ +export function useReorganizeOperation(token: string | null, operationId: number | null): UseReorganizeOperationResult { + const [operation, setOperation] = useState<ReorganizeOperation | null>(null); + const [error, setError] = useState<string | null>(null); + const [retrying, setRetrying] = useState(false); + const ws = useContext(WebSocketContext); + const requestSeq = useRef(0); + + const fetchOperation = useCallback(async () => { + if (!token || !operationId) return; + const seq = ++requestSeq.current; + try { + const response = await axios.get<ReorganizeOperation>(`/api/tv/operations/${operationId}`, { + headers: { 'x-access-token': token }, + }); + if (seq === requestSeq.current) { + setOperation(response.data); + setError(null); + } + } catch (err: unknown) { + if (seq === requestSeq.current) setError(serverMessageOf(err, 'Failed to load the reorganize')); + } + }, [token, operationId]); + + useEffect(() => { + setOperation(null); + fetchOperation(); + }, [fetchOperation]); + + useEffect(() => { + if (!ws || !operationId) return undefined; + const progressFilter = (msg: { type?: string; payload?: { operationId?: number } }) => ( + msg.type === REORGANIZE_PROGRESS_MESSAGE && msg.payload?.operationId === operationId + ); + const onProgress = (payload: ReorganizeProgressMessage) => { + if (payload.status === 'running') { + setOperation((current) => (current + ? { ...current, status: payload.status, total: payload.total, done: payload.done, failed: payload.failed } + : current)); + return; + } + fetchOperation(); + }; + const reconnectFilter = (msg: { type?: string }) => msg.type === 'connectionRestored'; + const onReconnect = () => { fetchOperation(); }; + ws.subscribe(progressFilter, onProgress); + ws.subscribe(reconnectFilter, onReconnect); + return () => { + ws.unsubscribe(onProgress); + ws.unsubscribe(onReconnect); + }; + }, [ws, operationId, fetchOperation]); + + const retry = useCallback(async (): Promise<boolean> => { + if (!token || !operationId) return false; + setRetrying(true); + setError(null); + try { + await axios.post(`/api/tv/operations/${operationId}/retry`, undefined, { headers: { 'x-access-token': token } }); + await fetchOperation(); + return true; + } catch (err: unknown) { + setError(serverMessageOf(err, 'Failed to retry the reorganize')); + return false; + } finally { + setRetrying(false); + } + }, [token, operationId, fetchOperation]); + + return { operation, error, retrying, retry, refetch: fetchOperation }; +} + +export default useReorganizeOperation; diff --git a/client/src/components/shared/Reorganize/hooks/useReorganizeOutcome.ts b/client/src/components/shared/Reorganize/hooks/useReorganizeOutcome.ts new file mode 100644 index 000000000..381cb2909 --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/useReorganizeOutcome.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; +import { ReorganizeOperation } from '../../../../types/reorganize'; +import { useReorganizeOperation } from './useReorganizeOperation'; + +const RUNNING_STATUSES = new Set<ReorganizeOperation['status']>(['starting', 'running']); + +export interface UseReorganizeOutcomeOptions { + /** Bumped when a retry of the operation starts, so its end is reported too */ + attempt?: number; +} + +/** + * Follow a reorganize to its end and call back once per run with its final + * state, whether or not the dialog showing it is still open. The server + * undoes the settings change when no video could be moved (and applies it + * again when a retry moves some), so what the caller saved optimistically + * must be read back at each end. Each run ends with its own `finishedAt`, + * which tells a retry's end from the one already reported. + */ +export function useReorganizeOutcome( + token: string | null, + operationId: number | null, + onFinished: (operation: ReorganizeOperation) => void, + { attempt = 0 }: UseReorganizeOutcomeOptions = {} +): void { + const { operation, refetch } = useReorganizeOperation(token, operationId); + const handledEnd = useRef<string | null>(null); + const onFinishedRef = useRef(onFinished); + onFinishedRef.current = onFinished; + + // A retry reopened the operation on the server: read it again. + useEffect(() => { + if (operationId !== null && attempt > 0) void refetch(); + }, [operationId, attempt, refetch]); + + useEffect(() => { + if (!operation || operation.id === null || RUNNING_STATUSES.has(operation.status)) return; + const end = `${operation.id}:${operation.finishedAt ?? ''}`; + if (handledEnd.current === end) return; + handledEnd.current = end; + onFinishedRef.current(operation); + }, [operation]); +} + +export default useReorganizeOutcome; diff --git a/client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts b/client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts new file mode 100644 index 000000000..a2f4ec199 --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { ReorganizeChange, ReorganizePreview } from '../../../../types/reorganize'; +import { serverMessageOf } from '../reorganizeErrors'; + +export interface UseReorganizePreviewResult { + preview: ReorganizePreview | null; + loading: boolean; + error: string | null; + /** Compute the preview again (after a stale-preview refusal, or a retry) */ + refresh: () => Promise<void>; +} + +/** The dry run of a reorganize: what would move where. Nothing is written. */ +export function useReorganizePreview(token: string | null, change: ReorganizeChange | null): UseReorganizePreviewResult { + const [preview, setPreview] = useState<ReorganizePreview | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + // Only the newest request may set state. + const requestSeq = useRef(0); + const changeKey = change ? JSON.stringify(change) : null; + + const fetchPreview = useCallback(async () => { + if (!token || !changeKey) return; + const seq = ++requestSeq.current; + setLoading(true); + setError(null); + try { + const response = await axios.post<ReorganizePreview>( + '/api/tv/reorganize/preview', + { change: JSON.parse(changeKey) }, + { headers: { 'x-access-token': token } } + ); + if (seq === requestSeq.current) setPreview(response.data); + } catch (err: unknown) { + if (seq === requestSeq.current) { + setPreview(null); + setError(serverMessageOf(err, 'Failed to preview the move')); + } + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [token, changeKey]); + + useEffect(() => { + setPreview(null); + fetchPreview(); + return () => { + requestSeq.current += 1; + }; + }, [fetchPreview]); + + return { preview, loading, error, refresh: fetchPreview }; +} + +export default useReorganizePreview; diff --git a/client/src/components/shared/Reorganize/hooks/useReorganizeRequest.ts b/client/src/components/shared/Reorganize/hooks/useReorganizeRequest.ts new file mode 100644 index 000000000..608433f56 --- /dev/null +++ b/client/src/components/shared/Reorganize/hooks/useReorganizeRequest.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from 'react'; +import { ReorganizeChange } from '../../../../types/reorganize'; + +interface ReorganizeRequest { + change: ReorganizeChange | null; + operationId: number | null; +} + +export interface UseReorganizeRequestResult { + open: boolean; + change: ReorganizeChange | null; + operationId: number | null; + /** Open the dialog on the preview of a change */ + review: (change: ReorganizeChange) => void; + /** Open the dialog on an operation's progress or result */ + showOperation: (operationId: number) => void; + close: () => void; +} + +/** Which reorganize the dialog shows, for the components that open it. */ +export function useReorganizeRequest(): UseReorganizeRequestResult { + const [request, setRequest] = useState<ReorganizeRequest | null>(null); + const review = useCallback((change: ReorganizeChange) => setRequest({ change, operationId: null }), []); + const showOperation = useCallback((operationId: number) => setRequest({ change: null, operationId }), []); + const close = useCallback(() => setRequest(null), []); + return { + open: request !== null, + change: request?.change ?? null, + operationId: request?.operationId ?? null, + review, + showOperation, + close, + }; +} + +export default useReorganizeRequest; diff --git a/client/src/components/shared/Reorganize/index.ts b/client/src/components/shared/Reorganize/index.ts new file mode 100644 index 000000000..91b06e763 --- /dev/null +++ b/client/src/components/shared/Reorganize/index.ts @@ -0,0 +1,6 @@ +export { default as ReorganizeDialog } from './ReorganizeDialog'; +export type { ReorganizeDialogProps } from './ReorganizeDialog'; +export { useReorganizeRequest } from './hooks/useReorganizeRequest'; +export { useActiveReorganize } from './hooks/useActiveReorganize'; +export { useReorganizeOutcome } from './hooks/useReorganizeOutcome'; +export { ReorganizeRequiredError, isReorganizeRequired, reorganizeChangeOf, toRequestError } from './reorganizeErrors'; diff --git a/client/src/components/shared/Reorganize/reorganizeErrors.ts b/client/src/components/shared/Reorganize/reorganizeErrors.ts new file mode 100644 index 000000000..888d1a0d4 --- /dev/null +++ b/client/src/components/shared/Reorganize/reorganizeErrors.ts @@ -0,0 +1,57 @@ +import axios from 'axios'; +import { ReorganizeChange } from '../../../types/reorganize'; + +/** Thrown for a change the server sends to the reorganize preview instead of saving. */ +export class ReorganizeRequiredError extends Error { + readonly change: ReorganizeChange; + + constructor(message: string, change: ReorganizeChange) { + super(message); + this.name = 'ReorganizeRequiredError'; + this.change = change; + } +} + +interface ErrorBody { + error?: unknown; + reorganizeRequired?: unknown; + change?: unknown; + code?: unknown; +} + +/** The change to preview from a refusal body, or null for any other refusal. */ +export function reorganizeChangeOf(body: unknown): ReorganizeChange | null { + const data = body as ErrorBody | null | undefined; + if (!data || data.reorganizeRequired !== true || !data.change || typeof data.change !== 'object') return null; + return data.change as ReorganizeChange; +} + +/** The server's refusal message from an axios error, or the fallback. */ +export function serverMessageOf(err: unknown, fallback: string): string { + if (axios.isAxiosError(err)) { + const data = err.response?.data as ErrorBody | undefined; + if (typeof data?.error === 'string' && data.error) return data.error; + } + return fallback; +} + +/** The server's error code from an axios error, if any. */ +export function serverCodeOf(err: unknown): string | null { + if (!axios.isAxiosError(err)) return null; + const code = (err.response?.data as ErrorBody | undefined)?.code; + return typeof code === 'string' ? code : null; +} + +/** + * The error to throw for a failed request: a ReorganizeRequiredError when the + * server asked for the reorganize preview, else an Error with its message. + */ +export function toRequestError(err: unknown, fallback: string): Error { + const message = serverMessageOf(err, fallback); + const change = axios.isAxiosError(err) ? reorganizeChangeOf(err.response?.data) : null; + return change ? new ReorganizeRequiredError(message, change) : new Error(message); +} + +export function isReorganizeRequired(err: unknown): err is ReorganizeRequiredError { + return err instanceof ReorganizeRequiredError; +} diff --git a/client/src/components/shared/Reorganize/reorganizeText.ts b/client/src/components/shared/Reorganize/reorganizeText.ts new file mode 100644 index 000000000..d3ffa4d89 --- /dev/null +++ b/client/src/components/shared/Reorganize/reorganizeText.ts @@ -0,0 +1,34 @@ +import { ReorganizeWatchState } from '../../../types/reorganize'; + +export function countOf(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +/** The singular or plural wording for a count. */ +export function agree(count: number, singular: string, plural: string): string { + return count === 1 ? singular : plural; +} + +const SERVER_NAMES: Record<ReorganizeWatchState['serverType'], string> = { + plex: 'Plex', + jellyfin: 'Jellyfin', + emby: 'Emby', +}; + +export function serverName(serverType: ReorganizeWatchState['serverType']): string { + return SERVER_NAMES[serverType] || serverType; +} + +/** "__TV Shows" for a subfolder, "the main folder" for ''. */ +export function folderName(libraryFolder: string): string { + return libraryFolder ? `__${libraryFolder}` : 'the main folder'; +} + +export const DOWNLOADS_WAIT_NOTE = 'Downloads wait in the queue until the move finishes.'; + +export const WATCH_STATE_NOTE = 'Media servers show moved videos as new, unwatched items. Youtarr keeps its own watched ' + + 'state and restores it on Plex (the server owner), Jellyfin and Emby once they have scanned the moved files. ' + + 'Other Plex accounts keep their history in Youtarr, but not on Plex.'; + +export const MOVIE_TAGS_NOTE = 'keep movie tags inside the video file. The NFO files are rewritten, but Plex Personal ' + + 'Media may keep showing the old title.'; diff --git a/client/src/hooks/__tests__/useLibraryFolders.test.ts b/client/src/hooks/__tests__/useLibraryFolders.test.ts index 43c9d037c..c9b03738a 100644 --- a/client/src/hooks/__tests__/useLibraryFolders.test.ts +++ b/client/src/hooks/__tests__/useLibraryFolders.test.ts @@ -69,6 +69,18 @@ describe('useLibraryFolders', () => { await expect(result.current.setFolderLayout('', 'tv')).rejects.toThrow('holds downloads'); }); + test('throws a ReorganizeRequiredError when the folder\'s files must move', async () => { + const change = { type: 'folderLayout', folder: '', layout: 'tv' }; + axios.put.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 409, data: { error: 'Review the move', reorganizeRequired: true, change } }, + }); + const { result } = renderHook(() => useLibraryFolders('token')); + await waitFor(() => expect(result.current.folders).toEqual(FOLDERS)); + + await expect(result.current.setFolderLayout('', 'tv')).rejects.toMatchObject({ name: 'ReorganizeRequiredError', change }); + }); + test('refetches when subfolders change', async () => { renderHook(() => useLibraryFolders('token')); await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); diff --git a/client/src/hooks/useLibraryFolders.ts b/client/src/hooks/useLibraryFolders.ts index 3dc39061e..abb6ead42 100644 --- a/client/src/hooks/useLibraryFolders.ts +++ b/client/src/hooks/useLibraryFolders.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import { LibraryFolder, LibraryFoldersResponse, LibraryLayout } from '../types/tvShows'; import { buildLayoutResolver, LayoutResolver } from '../utils/libraryLayouts'; import { SUBFOLDERS_UPDATED_EVENT } from './useSubfolders'; +import { toRequestError } from '../components/shared/Reorganize/reorganizeErrors'; export const LIBRARY_FOLDERS_UPDATED_EVENT = 'library-folders-updated'; @@ -17,7 +18,10 @@ export interface UseLibraryFoldersResult { /** Layout of a library folder ('' = main folder); videos until loaded */ layoutOf: LayoutResolver; refetch: () => Promise<void>; - /** Change a folder's layout; throws with the server's refusal message */ + /** + * Change a folder's layout; throws with the server's refusal message, or a + * ReorganizeRequiredError when the folder's files must move + */ setFolderLayout: (name: string, layout: LibraryLayout) => Promise<void>; } @@ -61,7 +65,7 @@ export function useLibraryFolders(token: string | null): UseLibraryFoldersResult { headers: { 'x-access-token': token } } ); } catch (err) { - throw new Error(errorMessage(err, 'Failed to change the folder layout')); + throw toRequestError(err, 'Failed to change the folder layout'); } if (Array.isArray(response.data?.folders)) setFolders(response.data.folders); window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); diff --git a/client/src/types/reorganize.ts b/client/src/types/reorganize.ts new file mode 100644 index 000000000..463dce592 --- /dev/null +++ b/client/src/types/reorganize.ts @@ -0,0 +1,155 @@ +/** A settings change that moves downloaded files (POST /api/tv/reorganize[/preview]). */ +export type ReorganizeChange = + | { type: 'channelLayout'; channelId: string; layout: 'videos' | 'tv'; folder?: string } + | { type: 'channel'; channelId: string; subFolder: string | null; previousSubFolder?: string | null } + | { type: 'folderLayout'; folder: string; layout: 'videos' | 'tv'; previousLayout?: 'videos' | 'tv' } + | { type: 'defaultSubfolder'; value: string; previousValue?: string }; + +export type ReorganizeFlag = 'override-placed' | 'adopted' | 'upload-date-only' | 'download-time' | 'movie-tags'; + +export type ReorganizeProblemKind = 'missing' | 'collision' | 'no-name' | 'no-date' | 'unsafe-name'; + +export interface ReorganizePreviewItem { + youtubeId: string; + title: string | null; + /** Paths relative to the downloads folder */ + from: string | null; + to: string | null; + /** e.g. "S2024E03151200" for a video that becomes an episode */ + episode: string | null; + flags: ReorganizeFlag[]; +} + +export interface ReorganizeProblem { + videoId: number; + youtubeId: string; + title: string | null; + problem: ReorganizeProblemKind; + /** The path in the way, for a collision */ + detail: string | null; +} + +export interface ReorganizePreviewShow { + name: string; + libraryFolder: string; + folderName: string; + action: 'create' | 'move' | 'keep'; +} + +export interface ReorganizeTotals { + videos: number; + toTv: number; + toVideos: number; + betweenFolders: number; + unchanged: number; + missing: number; + collisions: number; + noName: number; + noDate: number; + unsafeName: number; + overridePlaced: number; + adopted: number; + uploadDateOnly: number; + downloadTime: number; + movieTags: number; +} + +export interface ReorganizeWatchState { + serverType: 'plex' | 'jellyfin' | 'emby'; + videos: number; + users: number; +} + +export interface ReorganizeBlocker { + /** 'problems' when nothing can move because no video could be planned */ + reason: string; + message: string; +} + +/** POST /api/tv/reorganize/preview */ +export interface ReorganizePreview { + revision: string; + needed: boolean; + change: ReorganizeChange & { label: string }; + totals: ReorganizeTotals; + shows: ReorganizePreviewShow[]; + /** The first 200 moves */ + items: ReorganizePreviewItem[]; + /** The first 200 videos that can't move or need attention */ + problems: ReorganizeProblem[]; + watchState: ReorganizeWatchState[]; + blocked: ReorganizeBlocker | null; +} + +/** POST /api/tv/reorganize */ +export interface ReorganizeStartResult { + operationId: number | null; + applied: boolean; +} + +export type ReorganizeOperationStatus = 'starting' | 'running' | 'completed' | 'partial' | 'failed'; + +export interface ReorganizeFailedItem { + id: number; + youtubeId: string; + title: string | null; + channelId: string | null; + error: string | null; +} + +/** GET /api/tv/operations/:id */ +export interface ReorganizeOperation { + id: number | null; + label: string; + status: ReorganizeOperationStatus; + changeType?: ReorganizeChange['type']; + change?: ReorganizeChange; + total?: number; + done?: number; + failed?: number; + error?: string | null; + startedAt?: string | null; + finishedAt?: string | null; + failedItems?: ReorganizeFailedItem[]; +} + +/** tvReorganizeProgress WebSocket payload */ +export interface ReorganizeProgressMessage { + operationId: number; + status: ReorganizeOperationStatus; + total: number; + done: number; + failed: number; + label: string; +} + +/** A channel's reorganize state on GET /api/channels/:channelId/tv */ +export interface ChannelReorganizeState { + running: boolean; + unmoved: { operationId: number; failed: number; status: ReorganizeOperationStatus } | null; +} + +export type WatchHoldState = 'pending' | 'restored' | 'failed' | 'dismissed'; + +/** A watch-state restore from GET /api/tv/holds */ +export interface WatchHold { + id: number; + state: WatchHoldState; + serverType: 'plex' | 'jellyfin' | 'emby'; + serverUserId: string; + serverUserName: string | null; + youtubeId: string | null; + title: string | null; + channelName: string | null; + played: boolean; + positionMs: number | null; + attempts: number; + lastAttemptAt: string | null; + lastError: string | null; + expiresAt: string; +} + +export interface WatchHoldsResponse { + holds: WatchHold[]; + counts: { pending: number; failed: number }; +} diff --git a/client/src/types/tvShows.ts b/client/src/types/tvShows.ts index 1f5a3f886..0a5a9b992 100644 --- a/client/src/types/tvShows.ts +++ b/client/src/types/tvShows.ts @@ -1,3 +1,5 @@ +import type { ChannelReorganizeState } from './reorganize'; + /** Layout of a library folder: movie-style videos or TV shows. */ export type LibraryLayout = 'videos' | 'tv'; @@ -8,7 +10,7 @@ export interface LibraryFolder { layout: LibraryLayout; /** The default subfolder (the main folder when no default subfolder is set) */ isDefault: boolean; - /** Holds downloaded files, so its layout can't change yet */ + /** Holds downloaded files: changing its layout moves them (the reorganize) */ hasFiles: boolean; /** Enabled channels that download to this folder */ channels: number; @@ -37,8 +39,10 @@ export interface ChannelTvState { tvFolders: string[]; defaultFolder: string; defaultFolderLayout: LibraryLayout; + /** Switching layouts moves the channel's files, reviewed in the reorganize preview */ hasDownloads: boolean; - canSwitch: boolean; + /** Whether a reorganize is moving the channel's files, and videos one left unmoved */ + reorganize?: ChannelReorganizeState; } /** Episode details on a video in listing and detail responses. */ diff --git a/docs/DATABASE.md b/docs/DATABASE.md index d4131562f..736000443 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -36,6 +36,9 @@ Youtarr uses MariaDB/MySQL for storing: | `subfolders` | `Subfolder` | Durable registry of known subfolder names (id, name unique, created_at, updated_at). Backfilled from channels, playlists, and video file paths by the `add-subfolders-table` migration; kept current by register-on-create and register-on-download-override. `layout` (STRING(10) NOT NULL, default `'videos'`): `'videos'` saves movie-style, `'tv'` saves channels as TV shows; the main folder's layout is the `mainFolderLayout` config field. | | `tv_shows` | `TvShow` | TV shows Youtarr writes into TV-layout folders. `channel_id` (owner's YouTube channel id; not a foreign key, untracked channels get shows too), `kind` (`'channel'`: the whole channel as one show, created on the channel's switch to TV or its first download into a TV folder; `'title'`: reserved for title-pattern shows), `name`, `folder_name` and `library_folder` (`''` = main folder): the show's location, pinned at creation so uploader renames and default-folder changes never start a second show folder, `external_key` (written to `tvshow.nfo`; the channel id for channel shows), `previous_videos_folder` (the channel's `sub_folder` before it switched to TV, used when switching back), plus `position`, `exclude_terms`, `poster_source`, `retired_at` for title shows. Unique `(library_folder, folder_name)` (case-insensitive under `utf8mb4_unicode_ci`); a taken or invalid folder name gets the channel id appended. | | `video_classifications` | `VideoClassification` | One row per video that belongs to a show (`youtube_id` primary key): `channel_id`, `show_id` (foreign key `video_classifications_show_id_fk` to `tv_shows`, RESTRICT), `status` (`assigned`; `pending_number`/`duplicate`/`unsupported`/`error` are reserved for title shows), `season`/`episode` (channel shows: UTC upload year and MMDDHHMM, bumped to the next free number on a collision), `source` (`date` for channel shows), `timestamp_source` (`timestamp` or `upload_date`), `pattern_id`, `episode_title`, `file_stem` (the on-disk name, reused on re-download), `title_opt_out`. Unique `(show_id, season, episode)`; rows without a number hold NULLs and never collide. Rows are kept when a video's files are deleted, so a re-download returns to the same number. | +| `tv_reorganize_operations` | `TvReorganizeOperation` | One row per reorganize: moving downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts. `change_type` (`channel`, `folderLayout`, `defaultSubfolder`), `scope`, `settings_change` (MEDIUMTEXT JSON: the approved change, its label, and the shows it pins), `settings_applied`, `revision` (the preview token it was started with), `status` (`running`, `completed`, `partial`, `failed`), `total_items`/`done_items`/`failed_items`, `error`, `started_at`/`finished_at`. A row left `running` by a restart is resumed at startup. | +| `tv_reorganize_items` | `TvReorganizeItem` | One row per video of an operation (foreign key `tv_reorganize_items_operation_id_fk`, CASCADE): `youtube_id`, `video_id`, `channel_id` (owner), `title`, `files` (JSON: every file's source and destination, the old and new video and audio paths, the layouts), `classification` (JSON episode assignment, or NULL when the video becomes movie-style), `status` (`pending`, `done`, `failed`), `files_moved` (the files reached their destination; true for every done item and for a failed item whose metadata or row update failed after the move, which a retry finishes), `error`. Failed items, and pending ones of an operation that ended early, can be retried. | +| `watch_status_holds` | `WatchStatusHold` | Watch state Youtarr protects while a reorganize moves a video: one row per (`video_id`, `server_type`, `server_user_id`) (unique) with a `snapshot` (JSON: played, play count, resume position, percent, last watched, and `fromPath`, the file path before the move), `operation_id`, `state` (`pending`, `restored`, `failed` after 14 days, `dismissed`), `attempts`, `last_attempt_at`, `last_error` (push-back attempts), `last_pushed_at` (the last attempt that actually wrote the state to the server, as opposed to finding it there) and `expires_at`. While a hold is pending or failed, watch status sync never downgrades that `video_watch_status` row. | | `video_watch_status` | `VideoWatchStatus` | Per-video, per-media-server, per-user watch state pulled by the watch status sync. Absence of a row means never synced/unknown, not unwatched. Columns: `video_id`, `server_type` (`plex`/`jellyfin`/`emby`), `server_user_id` (Plex owner is `'1'`), `played`, `play_count`, `position_ms`, `percent_watched`, `last_watched_at`, `last_synced_at`. Unique index on `(video_id, server_type, server_user_id)`. | | `media_server_users` | `MediaServerUser` | Media-server account directory populated during watch status sync: `server_type`, `server_user_id`, `server_user_name`. Unique index on `(server_type, server_user_id)`. Used to display which users watched a video. | | `watch_status_sync_cursors` | `WatchStatusSyncCursor` | Durable per-server watch-status sync cursor (unique `server_type`, `cursor` DATETIME). Today only Plex uses it: the newest play-history event scanned, so incremental pulls never permanently skip events. Deleting a row forces a full history re-scan on the next sync. | diff --git a/migrations/20261003140340-add-tv-reorganize.js b/migrations/20261003140340-add-tv-reorganize.js new file mode 100644 index 000000000..6158ccd7e --- /dev/null +++ b/migrations/20261003140340-add-tv-reorganize.js @@ -0,0 +1,143 @@ +'use strict'; + +const { + createTableIfNotExists, + dropTableIfExists, + addColumnIfMissing, + addIndexIfMissing, +} = require('./helpers'); + +const TABLE_OPTIONS = { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' }; +// Named explicitly: MariaDB 12.1+ names an unnamed constraint `1`. +const ITEM_OPERATION_FK_NAME = 'tv_reorganize_items_operation_id_fk'; + +async function hasForeignKey(queryInterface, tableName) { + const [rows] = await queryInterface.sequelize.query( + `SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = :tableName AND CONSTRAINT_TYPE = 'FOREIGN KEY'`, + { replacements: { tableName } } + ); + return rows.length > 0; +} + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // A reorganize: moving downloaded files to where changed settings say they + // belong (a channel or library folder switching between videos and TV). + // Recorded so an interrupted run resumes on startup. + await createTableIfNotExists(queryInterface, 'tv_reorganize_operations', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + // channel | folderLayout | defaultSubfolder + change_type: { type: Sequelize.STRING(32), allowNull: false }, + // The channel id, library folder name, or new default subfolder. + scope: { type: Sequelize.STRING(255), allowNull: false, defaultValue: '' }, + // JSON: the approved settings change and the show locations it pins + // (a folder's change can pin hundreds of shows, hence MEDIUMTEXT). + settings_change: { type: Sequelize.TEXT('medium'), allowNull: false }, + settings_applied: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + revision: { type: Sequelize.STRING(64), allowNull: false }, + // running | completed | partial | failed + status: { type: Sequelize.STRING(16), allowNull: false }, + total_items: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + done_items: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + failed_items: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + error: { type: Sequelize.TEXT, allowNull: true }, + started_at: { type: Sequelize.DATE, allowNull: true }, + finished_at: { type: Sequelize.DATE, allowNull: true }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + await addIndexIfMissing(queryInterface, 'tv_reorganize_operations', ['status'], { + name: 'tv_reorganize_operations_status_idx', + }); + + // One video of an operation: every file's source and destination, and + // the episode assignment it gets (null when it becomes movie-style). + await createTableIfNotExists(queryInterface, 'tv_reorganize_items', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + operation_id: { type: Sequelize.INTEGER, allowNull: false }, + youtube_id: { type: Sequelize.STRING(20), allowNull: false }, + // Videos.id + video_id: { type: Sequelize.INTEGER, allowNull: false }, + // Owner channel's YouTube id. + channel_id: { type: Sequelize.STRING(64), allowNull: true }, + title: { type: Sequelize.STRING(512), allowNull: true }, + // JSON array of { from, to }, plus the old and new video and audio paths. + files: { type: Sequelize.TEXT, allowNull: false }, + // JSON episode assignment, or null. + classification: { type: Sequelize.TEXT, allowNull: true }, + // pending | done | failed + status: { type: Sequelize.STRING(16), allowNull: false }, + // The files reached their destination (a failed item can still have + // moved: its metadata or row update failed afterwards, and a retry + // finishes it). + files_moved: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + error: { type: Sequelize.TEXT, allowNull: true }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + // Databases that ran an earlier build of this (unreleased) migration. + await addColumnIfMissing(queryInterface, 'tv_reorganize_items', 'files_moved', { + type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, + }); + + if (!(await hasForeignKey(queryInterface, 'tv_reorganize_items'))) { + await queryInterface.addConstraint('tv_reorganize_items', { + fields: ['operation_id'], + type: 'foreign key', + name: ITEM_OPERATION_FK_NAME, + references: { table: 'tv_reorganize_operations', field: 'id' }, + onUpdate: 'cascade', + onDelete: 'cascade', + }); + } + await addIndexIfMissing(queryInterface, 'tv_reorganize_items', ['operation_id', 'status'], { + name: 'tv_reorganize_items_operation_status_idx', + }); + await addIndexIfMissing(queryInterface, 'tv_reorganize_items', ['channel_id', 'status'], { + name: 'tv_reorganize_items_channel_status_idx', + }); + + // Watch state Youtarr protects while a reorganize moves a video's files: + // the media servers report the moved item as new (unwatched) until the + // state is pushed back, and the sync must not overwrite Youtarr's rows. + await createTableIfNotExists(queryInterface, 'watch_status_holds', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + // Videos.id, like video_watch_status.video_id. + video_id: { type: Sequelize.INTEGER, allowNull: false }, + server_type: { type: Sequelize.STRING(16), allowNull: false }, + server_user_id: { type: Sequelize.STRING(255), allowNull: false }, + operation_id: { type: Sequelize.INTEGER, allowNull: true }, + // JSON: played, playCount, positionMs, percentWatched, lastWatchedAt. + snapshot: { type: Sequelize.TEXT, allowNull: false }, + // pending | restored | failed | dismissed + state: { type: Sequelize.STRING(16), allowNull: false }, + attempts: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + last_attempt_at: { type: Sequelize.DATE, allowNull: true }, + // When the state was last written to the server (an attempt that found + // the state already there writes nothing): the server stamps the item + // with this time, which the sync must not read as a new watch. + last_pushed_at: { type: Sequelize.DATE, allowNull: true }, + last_error: { type: Sequelize.TEXT, allowNull: true }, + expires_at: { type: Sequelize.DATE, allowNull: false }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + // Databases that ran an earlier build of this (unreleased) migration. + await addColumnIfMissing(queryInterface, 'watch_status_holds', 'last_pushed_at', { type: Sequelize.DATE, allowNull: true }); + await addIndexIfMissing(queryInterface, 'watch_status_holds', ['video_id', 'server_type', 'server_user_id'], { + name: 'watch_status_holds_video_server_user_uq', + unique: true, + }); + await addIndexIfMissing(queryInterface, 'watch_status_holds', ['state'], { + name: 'watch_status_holds_state_idx', + }); + }, + + async down(queryInterface) { + await dropTableIfExists(queryInterface, 'watch_status_holds'); + await dropTableIfExists(queryInterface, 'tv_reorganize_items'); + await dropTableIfExists(queryInterface, 'tv_reorganize_operations'); + }, +}; diff --git a/server/__tests__/server.additional-routes.test.js b/server/__tests__/server.additional-routes.test.js index 6dd099133..0f8ad5d40 100644 --- a/server/__tests__/server.additional-routes.test.js +++ b/server/__tests__/server.additional-routes.test.js @@ -277,6 +277,17 @@ const createServerModule = ({ jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/reorganize', () => ({ + lock: { coversAnyVideo: jest.fn(() => false) }, + initialize: jest.fn(), + recoverInterrupted: jest.fn().mockResolvedValue(undefined), + channelState: jest.fn().mockResolvedValue({ running: false, unmoved: null }), + preview: jest.fn(), start: jest.fn(), retry: jest.fn(), getOperation: jest.fn(), getActive: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusHolds', () => ({ + describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.apikeys.test.js b/server/__tests__/server.apikeys.test.js index 6125a22ef..11308c2b5 100644 --- a/server/__tests__/server.apikeys.test.js +++ b/server/__tests__/server.apikeys.test.js @@ -287,6 +287,17 @@ const createServerModule = ({ jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/reorganize', () => ({ + lock: { coversAnyVideo: jest.fn(() => false) }, + initialize: jest.fn(), + recoverInterrupted: jest.fn().mockResolvedValue(undefined), + channelState: jest.fn().mockResolvedValue({ running: false, unmoved: null }), + preview: jest.fn(), start: jest.fn(), retry: jest.fn(), getOperation: jest.fn(), getActive: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusHolds', () => ({ + describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.auth-sessions.test.js b/server/__tests__/server.auth-sessions.test.js index 02da1c021..3bbf66d62 100644 --- a/server/__tests__/server.auth-sessions.test.js +++ b/server/__tests__/server.auth-sessions.test.js @@ -245,6 +245,17 @@ const createServerModule = ({ jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/reorganize', () => ({ + lock: { coversAnyVideo: jest.fn(() => false) }, + initialize: jest.fn(), + recoverInterrupted: jest.fn().mockResolvedValue(undefined), + channelState: jest.fn().mockResolvedValue({ running: false, unmoved: null }), + preview: jest.fn(), start: jest.fn(), retry: jest.fn(), getOperation: jest.fn(), getActive: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusHolds', () => ({ + describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.core.test.js b/server/__tests__/server.core.test.js index 8915c1947..d809024d3 100644 --- a/server/__tests__/server.core.test.js +++ b/server/__tests__/server.core.test.js @@ -267,6 +267,17 @@ const createServerModule = ({ jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/reorganize', () => ({ + lock: { coversAnyVideo: jest.fn(() => false) }, + initialize: jest.fn(), + recoverInterrupted: jest.fn().mockResolvedValue(undefined), + channelState: jest.fn().mockResolvedValue({ running: false, unmoved: null }), + preview: jest.fn(), start: jest.fn(), retry: jest.fn(), getOperation: jest.fn(), getActive: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusHolds', () => ({ + describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.plex-routes.test.js b/server/__tests__/server.plex-routes.test.js index a5d0b4550..5f820a8e5 100644 --- a/server/__tests__/server.plex-routes.test.js +++ b/server/__tests__/server.plex-routes.test.js @@ -163,6 +163,17 @@ const setupServer = async ({ authEnabled = 'false', passwordHash = null } = {}) jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/reorganize', () => ({ + lock: { coversAnyVideo: jest.fn(() => false) }, + initialize: jest.fn(), + recoverInterrupted: jest.fn().mockResolvedValue(undefined), + channelState: jest.fn().mockResolvedValue({ running: false, unmoved: null }), + preview: jest.fn(), start: jest.fn(), retry: jest.fn(), getOperation: jest.fn(), getActive: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusHolds', () => ({ + describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 19b9d4fe5..041561fa7 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -429,6 +429,17 @@ const createServerModule = ({ jest.doMock('../modules/tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); jest.doMock('../modules/tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); jest.doMock('../modules/tvShows/layoutGuards', () => ({ assertVideoOnlyDestination: jest.fn(), isMp3Format: jest.fn(() => false) })); + jest.doMock('../modules/reorganize', () => ({ + lock: { coversAnyVideo: jest.fn(() => false) }, + initialize: jest.fn(), + recoverInterrupted: jest.fn().mockResolvedValue(undefined), + channelState: jest.fn().mockResolvedValue({ running: false, unmoved: null }), + preview: jest.fn(), start: jest.fn(), retry: jest.fn(), getOperation: jest.fn(), getActive: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusHolds', () => ({ + describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), + })); + jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/models/index.js b/server/models/index.js index 679d37fa1..5d0972b58 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -16,6 +16,9 @@ const WatchStatusSyncCursor = require('./watchstatussynccursor'); const ScheduledTaskRun = require('./scheduledtaskrun'); const TvShow = require('./tvshow'); const VideoClassification = require('./videoclassification'); +const TvReorganizeOperation = require('./tvreorganizeoperation'); +const TvReorganizeItem = require('./tvreorganizeitem'); +const WatchStatusHold = require('./watchstatushold'); Job.hasMany(JobVideo, { foreignKey: 'job_id', as: 'jobVideos' }); Job.hasMany(JobVideoDownload, { foreignKey: 'job_id', as: 'jobVideoDownloads' }); @@ -39,6 +42,9 @@ VideoWatchStatus.belongsTo(Video, { foreignKey: 'video_id', as: 'video' }); TvShow.hasMany(VideoClassification, { foreignKey: 'show_id', as: 'classifications' }); VideoClassification.belongsTo(TvShow, { foreignKey: 'show_id', as: 'show' }); +TvReorganizeOperation.hasMany(TvReorganizeItem, { foreignKey: 'operation_id', as: 'items' }); +TvReorganizeItem.belongsTo(TvReorganizeOperation, { foreignKey: 'operation_id', as: 'operation' }); + module.exports = { Job, JobVideo, @@ -57,4 +63,7 @@ module.exports = { ScheduledTaskRun, TvShow, VideoClassification, + TvReorganizeOperation, + TvReorganizeItem, + WatchStatusHold, }; diff --git a/server/models/tvreorganizeitem.js b/server/models/tvreorganizeitem.js new file mode 100644 index 000000000..64d98e1b9 --- /dev/null +++ b/server/models/tvreorganizeitem.js @@ -0,0 +1,29 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class TvReorganizeItem extends Model {} + +TvReorganizeItem.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + operation_id: { type: DataTypes.INTEGER, allowNull: false }, + youtube_id: { type: DataTypes.STRING(20), allowNull: false }, + video_id: { type: DataTypes.INTEGER, allowNull: false }, + channel_id: { type: DataTypes.STRING(64), allowNull: true }, + title: { type: DataTypes.STRING(512), allowNull: true }, + files: { type: DataTypes.TEXT, allowNull: false }, + classification: { type: DataTypes.TEXT, allowNull: true }, + status: { type: DataTypes.STRING(16), allowNull: false }, + files_moved: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + error: { type: DataTypes.TEXT, allowNull: true }, + }, + { + sequelize, + modelName: 'TvReorganizeItem', + tableName: 'tv_reorganize_items', + timestamps: true, + underscored: true, + } +); + +module.exports = TvReorganizeItem; diff --git a/server/models/tvreorganizeoperation.js b/server/models/tvreorganizeoperation.js new file mode 100644 index 000000000..20aefdcb7 --- /dev/null +++ b/server/models/tvreorganizeoperation.js @@ -0,0 +1,31 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class TvReorganizeOperation extends Model {} + +TvReorganizeOperation.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + change_type: { type: DataTypes.STRING(32), allowNull: false }, + scope: { type: DataTypes.STRING(255), allowNull: false, defaultValue: '' }, + settings_change: { type: DataTypes.TEXT('medium'), allowNull: false }, + settings_applied: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + revision: { type: DataTypes.STRING(64), allowNull: false }, + status: { type: DataTypes.STRING(16), allowNull: false }, + total_items: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + done_items: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + failed_items: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + error: { type: DataTypes.TEXT, allowNull: true }, + started_at: { type: DataTypes.DATE, allowNull: true }, + finished_at: { type: DataTypes.DATE, allowNull: true }, + }, + { + sequelize, + modelName: 'TvReorganizeOperation', + tableName: 'tv_reorganize_operations', + timestamps: true, + underscored: true, + } +); + +module.exports = TvReorganizeOperation; diff --git a/server/models/watchstatushold.js b/server/models/watchstatushold.js new file mode 100644 index 000000000..b766825aa --- /dev/null +++ b/server/models/watchstatushold.js @@ -0,0 +1,30 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class WatchStatusHold extends Model {} + +WatchStatusHold.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + video_id: { type: DataTypes.INTEGER, allowNull: false }, + server_type: { type: DataTypes.STRING(16), allowNull: false }, + server_user_id: { type: DataTypes.STRING(255), allowNull: false }, + operation_id: { type: DataTypes.INTEGER, allowNull: true }, + snapshot: { type: DataTypes.TEXT, allowNull: false }, + state: { type: DataTypes.STRING(16), allowNull: false }, + attempts: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + last_attempt_at: { type: DataTypes.DATE, allowNull: true }, + last_pushed_at: { type: DataTypes.DATE, allowNull: true }, + last_error: { type: DataTypes.TEXT, allowNull: true }, + expires_at: { type: DataTypes.DATE, allowNull: false }, + }, + { + sequelize, + modelName: 'WatchStatusHold', + tableName: 'watch_status_holds', + timestamps: true, + underscored: true, + } +); + +module.exports = WatchStatusHold; diff --git a/server/modules/__tests__/jobModule.test.js b/server/modules/__tests__/jobModule.test.js index 8b906c66c..05ac38b2c 100644 --- a/server/modules/__tests__/jobModule.test.js +++ b/server/modules/__tests__/jobModule.test.js @@ -1104,6 +1104,76 @@ describe('JobModule', () => { }); }); + describe('download jobs while a reorganize moves files', () => { + let lock; + let token; + + beforeEach(() => { + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue(JSON.stringify({ plexApiKey: 'test-key' })); + JobModule = require('../jobModule'); + lock = require('../reorganize/reorganizeLock'); + JobModule.addJob = jest.fn().mockResolvedValue('new-job-id'); + JobModule.updateJob = jest.fn(); + token = lock.acquire({ label: 'Chan' }); + }); + + afterEach(() => { + lock.release(token); + }); + + test('queues a new download job instead of starting it', async () => { + JobModule.jobs = {}; + + await JobModule.addOrUpdateJob({ jobType: 'Manually Added Urls' }); + + expect(JobModule.addJob).toHaveBeenCalledWith(expect.objectContaining({ status: 'Pending' })); + }); + + test('starts a job that is not a download', async () => { + JobModule.jobs = {}; + + await JobModule.addOrUpdateJob({ jobType: 'Import Subscriptions' }); + + expect(JobModule.addJob).toHaveBeenCalledWith(expect.objectContaining({ status: 'In Progress' })); + }); + + test('keeps a queued download Pending when its turn comes', async () => { + JobModule.jobs = {}; + + const result = await JobModule.addOrUpdateJob({ id: 'next-job', jobType: 'Channel Downloads' }, true); + + expect(result).toBeUndefined(); + expect(JobModule.updateJob).not.toHaveBeenCalled(); + }); + + test('holds pending jobs', async () => { + const mockAction = jest.fn(); + JobModule.jobs = { 'job-1': { status: 'Pending', action: mockAction } }; + + await JobModule.startNextJob(); + + expect(mockAction).not.toHaveBeenCalled(); + }); + + test('starts held jobs when the reorganize ends', async () => { + const mockAction = jest.fn(); + JobModule.jobs = { 'job-1': { status: 'Pending', action: mockAction } }; + + lock.release(token); + await new Promise(setImmediate); + await new Promise(setImmediate); + + expect(mockAction).toHaveBeenCalled(); + }); + + test('skips a library repair', async () => { + await expect(JobModule.backfillFromCompleteList()).resolves.toMatchObject({ + status: 'skipped', message: expect.stringContaining('reorganized'), + }); + }); + }); + describe('addOrUpdateJob', () => { beforeEach(() => { fs.existsSync.mockReturnValue(false); diff --git a/server/modules/__tests__/rescanRunSummary.test.js b/server/modules/__tests__/rescanRunSummary.test.js index e37b3c955..03be73be3 100644 --- a/server/modules/__tests__/rescanRunSummary.test.js +++ b/server/modules/__tests__/rescanRunSummary.test.js @@ -96,6 +96,15 @@ describe('rescanRunSummary', () => { expect(toRunRecord({ processed: 100 })).toEqual(expect.objectContaining({ outcome: 'completed' })); }); + test('reports a scan skipped while downloads were being reorganized', () => { + expect(toRunRecord({ skipped: true, reason: 'reorganizing' })).toEqual({ + status: 'skipped', + outcome: 'skipped', + message: 'Skipped while downloads were being reorganized.', + details: null, + }); + }); + test('reports a scan that was skipped because one was already running', () => { expect(toRunRecord({ skipped: true, reason: 'already-running' })).toEqual({ status: 'skipped', diff --git a/server/modules/__tests__/scheduledTaskManager.test.js b/server/modules/__tests__/scheduledTaskManager.test.js index 94a909262..d2c053440 100644 --- a/server/modules/__tests__/scheduledTaskManager.test.js +++ b/server/modules/__tests__/scheduledTaskManager.test.js @@ -604,4 +604,61 @@ describe('scheduledTaskManager', () => { expect(snapshot.blocker).toBeNull(); }); }); + + describe('cross-task blocker (a reorganize moving files)', () => { + const reorganizing = { reason: 'reorganizing', message: 'Waiting for the reorganize of Chan to finish.' }; + let recorder; + + beforeEach(() => { + recorder = { + start: jest.fn().mockResolvedValue({ id: 1 }), + finish: jest.fn().mockResolvedValue(undefined), + record: jest.fn().mockResolvedValue(undefined), + recordSkipped: jest.fn().mockResolvedValue(undefined), + }; + manager.setRunRecorder(recorder); + manager.updateTask({ id, expression, run }); + }); + + afterEach(() => manager.setExclusiveBlocker(null)); + + test('refuses a manual run with the blocker\'s reason', async () => { + manager.setExclusiveBlocker((taskId) => (taskId === id ? reorganizing : null)); + + const result = await manager.runNow(id); + + expect(result).toMatchObject({ started: false, reason: 'reorganizing' }); + expect(run).not.toHaveBeenCalled(); + }); + + test('skips a scheduled occurrence and records why', async () => { + manager.setExclusiveBlocker(() => reorganizing); + + await cron.schedule.mock.calls[0][1](); + + expect(run).not.toHaveBeenCalled(); + expect(recorder.record).toHaveBeenCalledWith(expect.objectContaining({ + taskKey: id, trigger: 'scheduled', status: 'skipped', message: reorganizing.message, + })); + }); + + test('lets tasks the blocker does not name run', async () => { + manager.setExclusiveBlocker((taskId) => (taskId === 'other' ? reorganizing : null)); + + await cron.schedule.mock.calls[0][1](); + + expect(run).toHaveBeenCalled(); + }); + + test('fails open when the blocker throws', async () => { + manager.setExclusiveBlocker(() => { throw new Error('boom'); }); + + await expect(manager.getRunBlocker(id)).resolves.toBeNull(); + }); + + test('reports whether a task is running by id', () => { + expect(manager.isTaskRunningById(id)).toBe(false); + expect(manager.isTaskRunningById('unknown')).toBe(false); + }); + }); }); diff --git a/server/modules/__tests__/sidecarWriter.test.js b/server/modules/__tests__/sidecarWriter.test.js new file mode 100644 index 000000000..a3f434d1d --- /dev/null +++ b/server/modules/__tests__/sidecarWriter.test.js @@ -0,0 +1,143 @@ +jest.mock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../configModule', () => ({ getConfig: jest.fn(), getImagePath: jest.fn() })); +jest.mock('../tvShows/episodePlacement', () => ({ earliestEpisodeDate: jest.fn() })); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ID = 'abcdefghijk'; +const INFO = { id: ID, title: 'Big Build', upload_date: '20240315', uploader: 'Builder', duration: 600, description: 'Plot' }; + +describe('sidecarWriter', () => { + let sidecarWriter; + let configModule; + let episodePlacement; + let root; + let videoDir; + let videoPath; + let imageDir; + + beforeEach(() => { + jest.resetModules(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sidecar-writer-')); + videoDir = path.join(root, 'Show', 'Season 2024'); + imageDir = path.join(root, 'images'); + fs.mkdirSync(videoDir, { recursive: true }); + fs.mkdirSync(imageDir); + videoPath = path.join(videoDir, `S2024E03151200 - Big Build [${ID}].mp4`); + fs.writeFileSync(videoPath, 'video'); + configModule = require('../configModule'); + configModule.getConfig.mockReturnValue({}); + configModule.getImagePath.mockReturnValue(imageDir); + episodePlacement = require('../tvShows/episodePlacement'); + episodePlacement.earliestEpisodeDate.mockResolvedValue('2024-03-15'); + sidecarWriter = require('../sidecarWriter'); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const sibling = (suffix) => path.join(videoDir, `S2024E03151200 - Big Build [${ID}]${suffix}`); + const episode = { showTitle: 'Builder', season: 2024, episode: 3151200, episodeTitle: 'Big Build' }; + + describe('writeVideoSidecars', () => { + it('writes an episode NFO for an episode', async () => { + await sidecarWriter.writeVideoSidecars({ videoPath, info: INFO, episode }); + + const nfo = fs.readFileSync(sibling('.nfo'), 'utf8'); + expect(nfo).toContain('<episodedetails>'); + expect(nfo).toContain('<episode>3151200</episode>'); + }); + + it('writes the episode NFO even when video NFO files are turned off', async () => { + configModule.getConfig.mockReturnValue({ writeVideoNfoFiles: false }); + + await sidecarWriter.writeVideoSidecars({ videoPath, info: INFO, episode }); + + expect(fs.existsSync(sibling('.nfo'))).toBe(true); + }); + + it('writes a movie NFO for a movie-style video', async () => { + await sidecarWriter.writeVideoSidecars({ videoPath, info: INFO }); + + expect(fs.readFileSync(sibling('.nfo'), 'utf8')).toContain('<movie>'); + }); + + it('writes no movie NFO when video NFO files are turned off', async () => { + configModule.getConfig.mockReturnValue({ writeVideoNfoFiles: false }); + + const written = await sidecarWriter.writeVideoSidecars({ videoPath, info: INFO }); + + expect(written).toEqual([]); + expect(fs.existsSync(sibling('.nfo'))).toBe(false); + }); + + it('copies the thumbnail as fanart and backdrop when those are turned on', async () => { + configModule.getConfig.mockReturnValue({ writeVideoNfoFiles: false, writeVideoFanart: true, writeBackdropImages: true }); + fs.writeFileSync(sibling('.jpg'), 'thumb'); + + await sidecarWriter.writeVideoSidecars({ videoPath, info: INFO }); + + expect(fs.readFileSync(sibling('-fanart.jpg'), 'utf8')).toBe('thumb'); + expect(fs.readFileSync(sibling('-backdrop.jpg'), 'utf8')).toBe('thumb'); + }); + + it('keeps an existing fanart file', async () => { + configModule.getConfig.mockReturnValue({ writeVideoNfoFiles: false, writeVideoFanart: true }); + fs.writeFileSync(sibling('.jpg'), 'thumb'); + fs.writeFileSync(sibling('-fanart.jpg'), 'custom'); + + await sidecarWriter.writeVideoSidecars({ videoPath, info: INFO }); + + expect(fs.readFileSync(sibling('-fanart.jpg'), 'utf8')).toBe('custom'); + }); + }); + + describe('writeFolderArt', () => { + it('copies the cached channel avatar as poster.jpg', () => { + fs.writeFileSync(path.join(imageDir, 'channelthumb-UC1.jpg'), 'avatar'); + + const written = sidecarWriter.writeFolderArt({ channelId: 'UC1', folderPath: videoDir }); + + expect(written).toEqual([path.join(videoDir, 'poster.jpg')]); + expect(fs.readFileSync(path.join(videoDir, 'poster.jpg'), 'utf8')).toBe('avatar'); + }); + + it('copies the banner only when backdrops are turned on', () => { + fs.writeFileSync(path.join(imageDir, 'channelbanner-UC1.jpg'), 'banner'); + + sidecarWriter.writeFolderArt({ channelId: 'UC1', folderPath: videoDir }); + expect(fs.existsSync(path.join(videoDir, 'backdrop.jpg'))).toBe(false); + + configModule.getConfig.mockReturnValue({ writeBackdropImages: true }); + sidecarWriter.writeFolderArt({ channelId: 'UC1', folderPath: videoDir }); + expect(fs.readFileSync(path.join(videoDir, 'backdrop.jpg'), 'utf8')).toBe('banner'); + }); + + it('writes nothing into a folder that does not exist', () => { + fs.writeFileSync(path.join(imageDir, 'channelthumb-UC1.jpg'), 'avatar'); + + expect(sidecarWriter.writeFolderArt({ channelId: 'UC1', folderPath: path.join(root, 'missing') })).toEqual([]); + }); + }); + + describe('writeShowMetadata', () => { + it('writes tvshow.nfo with the earliest episode date and the show art', async () => { + const showDir = path.join(root, 'Show'); + fs.writeFileSync(path.join(imageDir, 'channelthumb-UC1.jpg'), 'avatar'); + + await sidecarWriter.writeShowMetadata({ + show: { id: 3, name: 'Builder', channel_id: 'UC1', external_key: 'UC1' }, + showDir, + plot: 'About', + }); + + const nfo = fs.readFileSync(path.join(showDir, 'tvshow.nfo'), 'utf8'); + expect(nfo).toContain('<premiered>2024-03-15</premiered>'); + expect(nfo).toContain('<uniqueid type="custom">UC1</uniqueid>'); + expect(fs.existsSync(path.join(showDir, 'poster.jpg'))).toBe(true); + }); + }); +}); diff --git a/server/modules/__tests__/videoInfoStore.test.js b/server/modules/__tests__/videoInfoStore.test.js new file mode 100644 index 000000000..3fbab573f --- /dev/null +++ b/server/modules/__tests__/videoInfoStore.test.js @@ -0,0 +1,107 @@ +jest.mock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../configModule', () => ({ getJobsPath: jest.fn() })); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ID = 'abcdefghijk'; + +describe('videoInfoStore', () => { + let videoInfoStore; + let configModule; + let root; + + beforeEach(() => { + jest.resetModules(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'video-info-store-')); + fs.mkdirSync(path.join(root, 'info')); + configModule = require('../configModule'); + configModule.getJobsPath.mockReturnValue(root); + videoInfoStore = require('../videoInfoStore'); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const writeInfo = (info) => fs.writeFileSync(path.join(root, 'info', `${ID}.info.json`), JSON.stringify(info)); + const storedInfo = () => JSON.parse(fs.readFileSync(path.join(root, 'info', `${ID}.info.json`), 'utf8')); + + it('reads the stored info.json', async () => { + writeInfo({ id: ID, title: 'Title' }); + + await expect(videoInfoStore.readInfo(ID)).resolves.toEqual({ id: ID, title: 'Title' }); + }); + + it('returns null when the info.json is missing', async () => { + await expect(videoInfoStore.readInfo(ID)).resolves.toBeNull(); + }); + + it('returns null when the info.json is not valid JSON', async () => { + fs.writeFileSync(path.join(root, 'info', `${ID}.info.json`), '{broken'); + + await expect(videoInfoStore.readInfo(ID)).resolves.toBeNull(); + }); + + it('rewrites stored final paths that point at moved files', async () => { + writeInfo({ id: ID, _actual_filepath: '/old/a.mp4', _actual_video_filepath: '/old/a.mp4', _actual_audio_filepath: '/old/a.mp3' }); + + const changed = await videoInfoStore.rewriteActualPaths(ID, new Map([['/old/a.mp4', '/new/a.mp4'], ['/old/a.mp3', '/new/a.mp3']])); + + expect(changed).toBe(true); + expect(storedInfo()).toMatchObject({ + _actual_filepath: '/new/a.mp4', + _actual_video_filepath: '/new/a.mp4', + _actual_audio_filepath: '/new/a.mp3', + }); + }); + + it('leaves the file alone when no stored path moved', async () => { + writeInfo({ id: ID, _actual_filepath: '/other/a.mp4' }); + + await expect(videoInfoStore.rewriteActualPaths(ID, new Map([['/old/a.mp4', '/new/a.mp4']]))).resolves.toBe(false); + expect(storedInfo()._actual_filepath).toBe('/other/a.mp4'); + }); + + it('does nothing when the info.json is missing', async () => { + await expect(videoInfoStore.rewriteActualPaths(ID, new Map([['/old/a.mp4', '/new/a.mp4']]))).resolves.toBe(false); + }); +}); + +describe('videoInfoStore.readInfoOrFallback', () => { + let videoInfoStore; + let root; + + beforeEach(() => { + jest.resetModules(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'video-info-fallback-')); + fs.mkdirSync(path.join(root, 'info')); + require('../configModule').getJobsPath.mockReturnValue(root); + videoInfoStore = require('../videoInfoStore'); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const video = { youtubeId: ID, youTubeVideoName: 'Row title', youTubeChannelName: 'Row channel', originalDate: '20240315' }; + + it('builds the info from the videos row when the info.json is gone', async () => { + await expect(videoInfoStore.readInfoOrFallback(video)).resolves.toEqual({ + id: ID, title: 'Row title', uploader: 'Row channel', channel: 'Row channel', upload_date: '20240315', + }); + }); + + it('fills a missing upload date from the videos row', async () => { + fs.writeFileSync(path.join(root, 'info', `${ID}.info.json`), JSON.stringify({ id: ID, title: 'Stored' })); + + await expect(videoInfoStore.readInfoOrFallback(video)).resolves.toEqual({ id: ID, title: 'Stored', upload_date: '20240315' }); + }); + + it('keeps a stored timestamp as is', async () => { + fs.writeFileSync(path.join(root, 'info', `${ID}.info.json`), JSON.stringify({ id: ID, timestamp: 1710504000 })); + + await expect(videoInfoStore.readInfoOrFallback(video)).resolves.toEqual({ id: ID, timestamp: 1710504000 }); + }); +}); diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index 7c0ab4916..93b7b55f7 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -1693,6 +1693,18 @@ describe('VideosModule', () => { VideosModule._backfillRunning = false; }); + test('skips the scan while a reorganize moves files', async () => { + const lock = require('../reorganize/reorganizeLock'); + const token = lock.acquire({ label: 'Chan' }); + try { + await expect(VideosModule.backfillVideoMetadata({ trigger: 'startup' })) + .resolves.toEqual({ skipped: true, reason: 'reorganizing' }); + expect(VideosModule._backfillRunning).toBeFalsy(); + } finally { + lock.release(token); + } + }); + test('should release lock on success', async () => { mockFs.readdir.mockResolvedValueOnce([]); mockVideo.count.mockResolvedValueOnce(0); diff --git a/server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js b/server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js new file mode 100644 index 000000000..e192c321a --- /dev/null +++ b/server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js @@ -0,0 +1,115 @@ +// Real filesystem: the move must never replace another file and must be safe +// to repeat after an interruption, which a mocked fs could not show. +jest.mock('../../../logger', () => ({ debug: jest.fn(), warn: jest.fn(), info: jest.fn(), error: jest.fn() })); + +const fs = require('fs'); +const fsExtra = require('fs-extra'); +const os = require('os'); +const path = require('path'); +const { moveFileNoClobber, NO_CLOBBER_STAGING_SUFFIX } = require('../fileOperations'); + +function exdev() { + return Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' }); +} + +describe('filesystem/fileOperations moveFileNoClobber', () => { + let root; + let src; + let dest; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'move-no-clobber-')); + src = path.join(root, 'Channel', 'Title [abcdefghijk].mp4'); + dest = path.join(root, 'Show', 'Season 2024', 'S2024E01151200 - Title [abcdefghijk].mp4'); + fs.mkdirSync(path.dirname(src), { recursive: true }); + fs.writeFileSync(src, 'video'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('moves the file, creating the destination folder', async () => { + await expect(moveFileNoClobber(src, dest)).resolves.toBe('moved'); + + expect(fs.readFileSync(dest, 'utf8')).toBe('video'); + expect(fs.existsSync(src)).toBe(false); + }); + + it('refuses a destination that holds a different file and leaves both files', async () => { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, 'someone else'); + + await expect(moveFileNoClobber(src, dest)).rejects.toMatchObject({ code: 'EEXIST' }); + + expect(fs.readFileSync(dest, 'utf8')).toBe('someone else'); + expect(fs.readFileSync(src, 'utf8')).toBe('video'); + }); + + it('reports a source that was already moved', async () => { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.renameSync(src, dest); + + await expect(moveFileNoClobber(src, dest)).resolves.toBe('already-moved'); + expect(fs.readFileSync(dest, 'utf8')).toBe('video'); + }); + + it('finishes a copy that stopped before removing the source', async () => { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + const { atime, mtime } = fs.statSync(src); + fs.utimesSync(dest, atime, mtime); + + await expect(moveFileNoClobber(src, dest)).resolves.toBe('already-moved'); + + expect(fs.existsSync(src)).toBe(false); + expect(fs.readFileSync(dest, 'utf8')).toBe('video'); + }); + + it('removes a staging copy left by an interrupted move before moving', async () => { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(`${dest}${NO_CLOBBER_STAGING_SUFFIX}`, 'partial'); + + await moveFileNoClobber(src, dest); + + expect(fs.existsSync(`${dest}${NO_CLOBBER_STAGING_SUFFIX}`)).toBe(false); + expect(fs.readFileSync(dest, 'utf8')).toBe('video'); + }); + + it('throws ENOENT when neither the source nor the destination exists', async () => { + fs.unlinkSync(src); + + await expect(moveFileNoClobber(src, dest)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('copies through a staging file when the destination is on another filesystem', async () => { + const realRename = fs.promises.rename; + const renames = []; + jest.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => { + renames.push([from, to]); + if (from === src) throw exdev(); + return realRename(from, to); + }); + + await expect(moveFileNoClobber(src, dest)).resolves.toBe('moved'); + + expect(renames).toContainEqual([`${dest}${NO_CLOBBER_STAGING_SUFFIX}`, dest]); + expect(fs.readFileSync(dest, 'utf8')).toBe('video'); + expect(fs.existsSync(src)).toBe(false); + }); + + it('keeps the source and removes the staging file when a cross-filesystem copy fails', async () => { + jest.spyOn(fs.promises, 'rename').mockRejectedValue(exdev()); + jest.spyOn(fsExtra, 'copy').mockImplementation(async (from, to) => { + fs.writeFileSync(to, 'part'); + throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }); + }); + + await expect(moveFileNoClobber(src, dest, { retries: 0 })).rejects.toMatchObject({ code: 'ENOSPC' }); + + expect(fs.readFileSync(src, 'utf8')).toBe('video'); + expect(fs.existsSync(`${dest}${NO_CLOBBER_STAGING_SUFFIX}`)).toBe(false); + expect(fs.existsSync(dest)).toBe(false); + }); +}); diff --git a/server/modules/filesystem/fileOperations.js b/server/modules/filesystem/fileOperations.js index b279829c6..86041ff3d 100644 --- a/server/modules/filesystem/fileOperations.js +++ b/server/modules/filesystem/fileOperations.js @@ -5,6 +5,7 @@ const fs = require('fs-extra'); const fsPromises = require('fs').promises; +const path = require('path'); const { execFile, execFileSync } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); @@ -258,6 +259,112 @@ async function appendFile(filePath, content, encoding = 'utf8') { await fsPromises.appendFile(filePath, content, encoding); } +// Staging name of a file being copied across filesystems by a move that must +// never replace an existing file. Distinct from REPLACE_STAGING_SUFFIX, so a +// leftover of either is never mistaken for the other. +const NO_CLOBBER_STAGING_SUFFIX = '.reorganize.part'; +// A preserved timestamp passes through a Date (whole milliseconds) and a +// float of seconds, so a copy's mtime can differ from its source's by a hair. +const COPY_MTIME_TOLERANCE_MS = 2; + +function destinationExistsError(dest) { + return Object.assign(new Error(`A file already exists at ${dest}`), { code: 'EEXIST', path: dest }); +} + +// A path is absent when it or one of its parent folders is missing, and +// when a parent turns out to be a file (ENOTDIR). +async function statOrNull(filePath) { + try { + return await fsPromises.stat(filePath); + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null; + throw err; + } +} + +async function copyWithFallback(src, dest) { + try { + await fs.copy(src, dest, { overwrite: true, preserveTimestamps: true }); + } catch (err) { + if (!(err && err.code === 'EPERM' && err.syscall === 'copyfile')) throw err; + await fs.remove(dest); + await execFileAsync('cp', ['--', src, dest]); + } +} + +/** + * Move one file to a destination that must not already hold another file. + * Safe to call again after an interruption: a leftover staging copy is + * removed, a source already moved is reported as such, and a destination + * that is a finished copy of the source (same size and modification time, + * left when a cross-filesystem move stopped before removing the source) + * completes the move. + * + * On one filesystem the move is a rename. Across filesystems (a __subfolder + * can be another mount) the file is copied to `<dest>.reorganize.part`, + * renamed into place, and only then is the source removed, so no moment + * exists in which neither copy is complete. + * + * @param {string} src - Source file path + * @param {string} dest - Destination file path + * @param {Object} [options] + * @param {number} [options.retries=3] - Attempts for the cross-filesystem copy + * @param {number} [options.delayMs=200] - Base backoff between copy attempts + * @returns {Promise<'moved'|'already-moved'>} + * @throws {Error} code EEXIST when another file holds the destination, ENOENT when neither exists + */ +async function moveFileNoClobber(src, dest, { retries = 3, delayMs = 200 } = {}) { + const staging = `${dest}${NO_CLOBBER_STAGING_SUFFIX}`; + await safeRemove(staging); + + const [srcStat, destStat] = await Promise.all([statOrNull(src), statOrNull(dest)]); + if (!srcStat) { + if (destStat) return 'already-moved'; + throw Object.assign(new Error(`Source file is missing: ${src}`), { code: 'ENOENT', path: src }); + } + if (destStat) { + // The same file under another spelling (a case-only rename on a + // case-insensitive filesystem): rename it. + if (srcStat.ino === destStat.ino && srcStat.dev === destStat.dev) { + await fsPromises.rename(src, dest); + return 'moved'; + } + if (srcStat.size === destStat.size && Math.abs(srcStat.mtimeMs - destStat.mtimeMs) < COPY_MTIME_TOLERANCE_MS) { + await fsPromises.unlink(src); + return 'already-moved'; + } + throw destinationExistsError(dest); + } + + await fsPromises.mkdir(path.dirname(dest), { recursive: true }); + try { + await fsPromises.rename(src, dest); + return 'moved'; + } catch (err) { + if (err.code !== 'EXDEV') throw err; + } + + for (let attempt = 0; ; attempt++) { + try { + await copyWithFallback(src, staging); + break; + } catch (err) { + await safeRemove(staging); + if (attempt >= retries) throw err; + await sleep(delayMs * Math.pow(2, attempt)); + } + } + try { + if (await statOrNull(dest)) throw destinationExistsError(dest); + await fsPromises.rename(staging, dest); + } catch (err) { + await safeRemove(staging); + throw err; + } + await fsPromises.unlink(src); + return 'moved'; +} + /** * Check if a path is a file * @@ -284,6 +391,8 @@ module.exports = { sleep, moveWithRetries, replaceFileWithRetries, + moveFileNoClobber, + NO_CLOBBER_STAGING_SUFFIX, safeRemove, safeCopy, copySyncWithFallback, diff --git a/server/modules/jobModule.js b/server/modules/jobModule.js index 3f7dffa55..2249e8c7a 100644 --- a/server/modules/jobModule.js +++ b/server/modules/jobModule.js @@ -18,6 +18,7 @@ const { isDownloadJob, isSpecificUrlDownloadJob } = require('./download/jobTypes const downloadCleanup = require('./download/downloadCleanup'); const { serializeAuxData, parseAuxData } = require('./jobAuxData'); const storageGuard = require('./storageGuard'); +const reorganizeLock = require('./reorganize/reorganizeLock'); const { unchangedSinceRead } = require('./videoRowGuard'); const videosModule = require('./videosModule'); const logger = require('../logger'); @@ -82,6 +83,13 @@ class JobModule { logger.error({ err }, 'Failed to start queued job after downloads resumed'); }); }); + // Download jobs held while a reorganize moved files start once it ends. + reorganizeLock.on('released', () => { + if (this.getInProgressJobId()) return; + this.startNextJob().catch((err) => { + logger.error({ err }, 'Failed to start queued job after the reorganize finished'); + }); + }); // Schedule a daily backfill from complete.list and run an initial backfill this.scheduleDailyBackfill(); @@ -491,6 +499,10 @@ class JobModule { logger.info('Downloads are paused for storage; holding queued jobs'); return; } + if (reorganizeLock.isActive()) { + logger.info('Downloads are being reorganized; holding queued jobs'); + return; + } const jobs = this.getAllJobs(); for (let id in jobs) { if (jobs[id].status !== 'Pending') continue; @@ -587,10 +599,13 @@ class JobModule { async addOrUpdateJob(jobData, isNextJob = false) { let jobId; const inProgressJobId = this.getInProgressJobId(); + // A reorganize moves files the download would write next to, so download + // jobs wait for it in the queue. + const heldForReorganize = reorganizeLock.isActive() && isDownloadJob(jobData.jobType); if (!isNextJob) { - if (inProgressJobId) { + if (inProgressJobId || heldForReorganize) { // If there is a job in progress, create a new job with status Pending - logger.info({ jobType: jobData.jobType }, 'A job is already in progress. Adding job to the queue'); + logger.info({ jobType: jobData.jobType, heldForReorganize }, 'A job is already in progress. Adding job to the queue'); jobData.status = 'Pending'; jobId = await this.addJob(jobData); } else { @@ -599,7 +614,7 @@ class JobModule { jobData.status = 'In Progress'; jobId = await this.addJob(jobData); } - } else if (isNextJob && !inProgressJobId) { + } else if (isNextJob && !inProgressJobId && !heldForReorganize) { // If this is a next job and there's no job in progress, update its status to In Progress logger.info('This is a "next job", flipping from Pending to In Progress'); await this.updateJob(jobData.id, { @@ -608,6 +623,8 @@ class JobModule { }); jobId = jobData.id; this.emitJobsUpdated(jobId, 'In Progress'); + } else if (heldForReorganize) { + logger.info({ jobId: jobData.id }, 'Downloads are being reorganized; the queued job stays Pending'); } else { logger.warn('Cannot start next job as a job is already in progress'); } @@ -805,6 +822,10 @@ class JobModule { if (this._archiveRepairRunning) { return { status: 'skipped', outcome: 'skipped', message: 'A library repair was already running.' }; } + // Files are moving; the repair would recreate rows from stale paths. + if (reorganizeLock.isActive()) { + return { status: 'skipped', outcome: 'skipped', message: 'Skipped while downloads were being reorganized.' }; + } this._archiveRepairRunning = true; try { return await this._repairFromArchive(); diff --git a/server/modules/mediaServers/__tests__/mediaServerSync.test.js b/server/modules/mediaServers/__tests__/mediaServerSync.test.js index f4a0d7402..fa1c3cc85 100644 --- a/server/modules/mediaServers/__tests__/mediaServerSync.test.js +++ b/server/modules/mediaServers/__tests__/mediaServerSync.test.js @@ -714,4 +714,34 @@ describe('mediaServerSync', () => { { public: false, mediaType: 'video' } ); }); + + describe('while a reorganize moves files', () => { + test('puts the sync off and runs it when the reorganize ends', async () => { + const lock = require('../../reorganize/reorganizeLock'); + const token = lock.acquire({ label: 'Chan' }); + Playlist.findByPk.mockResolvedValue(null); + + await mediaServerSync.syncPlaylist(5); + expect(Playlist.findByPk).not.toHaveBeenCalled(); + + lock.release(token); + await new Promise(setImmediate); + await new Promise(setImmediate); + + expect(Playlist.findByPk).toHaveBeenCalledWith(5); + }); + + test('reports whether any playlist sync is in flight', async () => { + let finish; + Playlist.findByPk.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + + const pending = mediaServerSync.syncPlaylist(5); + await new Promise(setImmediate); + expect(mediaServerSync.isAnySyncInFlight()).toBe(true); + + finish(null); + await pending; + expect(mediaServerSync.isAnySyncInFlight()).toBe(false); + }); + }); }); diff --git a/server/modules/mediaServers/__tests__/watchStateCompare.test.js b/server/modules/mediaServers/__tests__/watchStateCompare.test.js new file mode 100644 index 000000000..a5d5243ce --- /dev/null +++ b/server/modules/mediaServers/__tests__/watchStateCompare.test.js @@ -0,0 +1,33 @@ +const { isAtLeast, isLaterWatch } = require('../watchStateCompare'); + +describe('watchStateCompare', () => { + describe('isAtLeast', () => { + it('needs played for a played snapshot', () => { + expect(isAtLeast({ played: true }, { played: true })).toBe(true); + expect(isAtLeast({ played: false, positionMs: 999999 }, { played: true })).toBe(false); + }); + + it('accepts played or a resume position no earlier than an in-progress snapshot', () => { + expect(isAtLeast({ played: true }, { played: false, positionMs: 60000 })).toBe(true); + expect(isAtLeast({ played: false, positionMs: 60000 }, { played: false, positionMs: 60000 })).toBe(true); + expect(isAtLeast({ played: false, positionMs: 59000 }, { played: false, positionMs: 60000 })).toBe(false); + }); + }); + + describe('isLaterWatch', () => { + it('counts finishing a video the snapshot had in progress', () => { + expect(isLaterWatch({ played: true, lastWatchedAt: null }, { played: false, positionMs: 1000, lastWatchedAt: null })).toBe(true); + }); + + it('counts a last-watched time more than a second after the snapshot\'s', () => { + const snapshot = { played: true, lastWatchedAt: '2026-10-01T00:00:00.000Z' }; + expect(isLaterWatch({ played: true, lastWatchedAt: new Date('2026-10-01T00:00:00.500Z') }, snapshot)).toBe(false); + expect(isLaterWatch({ played: true, lastWatchedAt: new Date('2026-10-01T00:00:02Z') }, snapshot)).toBe(true); + }); + + it('cannot tell without both times', () => { + expect(isLaterWatch({ played: true, lastWatchedAt: null }, { played: true, lastWatchedAt: '2026-10-01T00:00:00Z' })).toBe(false); + expect(isLaterWatch({ played: true, lastWatchedAt: new Date() }, { played: true, lastWatchedAt: null })).toBe(false); + }); + }); +}); diff --git a/server/modules/mediaServers/__tests__/watchStatusHolds.test.js b/server/modules/mediaServers/__tests__/watchStatusHolds.test.js new file mode 100644 index 000000000..d1aa4f935 --- /dev/null +++ b/server/modules/mediaServers/__tests__/watchStatusHolds.test.js @@ -0,0 +1,307 @@ +const VIDEO = { id: 7, filePath: '/data/__TV/Chan/Season 2024/S2024E03151200 - Title [id1].mp4' }; +const NEW_PATH = '/srv/media/__TV/Chan/Season 2024/S2024E03151200 - Title [id1].mp4'; +const OLD_PATH = '/srv/media/Chan/Chan - Title - id1/Chan - Title [id1].mp4'; +const FROM_PATH = '/data/Chan/Chan - Title - id1/Chan - Title [id1].mp4'; +// A move between two TV folders keeps the show, season and file names. +const TV_VIDEO = { id: 7, filePath: '/data/__NewTV/Chan/Season 2024/S2024E03151200 - Title [id1].mp4' }; +const TV_FROM_PATH = '/data/__OldTV/Chan/Season 2024/S2024E03151200 - Title [id1].mp4'; + +const entry = (overrides = {}) => ({ + path: NEW_PATH, serverUserId: '1', played: false, playCount: 0, positionMs: 0, percentWatched: 0, lastWatchedAt: null, + ...overrides, +}); + +const holdRow = (snapshot, overrides = {}) => ({ + id: 3, video_id: 7, server_type: 'plex', server_user_id: '1', state: 'pending', snapshot: JSON.stringify(snapshot), + update: jest.fn().mockResolvedValue(undefined), + ...overrides, +}); + +describe('watchStatusHolds', () => { + let holds; + let WatchStatusHold; + let VideoWatchStatus; + + beforeEach(() => { + jest.resetModules(); + jest.doMock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + jest.doMock('../../../models/watchstatushold', () => ({ + findAll: jest.fn().mockResolvedValue([]), + findByPk: jest.fn(), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue([0]), + })); + jest.doMock('../../../models/videowatchstatus', () => ({ findAll: jest.fn().mockResolvedValue([]) })); + jest.doMock('../../../models/video', () => ({ findAll: jest.fn().mockResolvedValue([]) })); + jest.doMock('../../../models/mediaserveruser', () => ({ findAll: jest.fn().mockResolvedValue([]) })); + jest.doMock('../adapters/plexAdapter', () => ({ PLEX_OWNER_ACCOUNT_ID: '1' })); + WatchStatusHold = require('../../../models/watchstatushold'); + VideoWatchStatus = require('../../../models/videowatchstatus'); + holds = require('../watchStatusHolds'); + }); + + describe('isHoldable', () => { + it('protects watched and in-progress rows', () => { + expect(holds.isHoldable({ server_type: 'jellyfin', server_user_id: 'u', played: true, position_ms: null })).toBe(true); + expect(holds.isHoldable({ server_type: 'emby', server_user_id: 'u', played: false, position_ms: 5000 })).toBe(true); + }); + + it('skips rows with nothing to protect', () => { + expect(holds.isHoldable({ server_type: 'jellyfin', server_user_id: 'u', played: false, position_ms: 0 })).toBe(false); + }); + + it('skips Plex accounts other than the owner', () => { + expect(holds.isHoldable({ server_type: 'plex', server_user_id: '5', played: true, position_ms: null })).toBe(false); + expect(holds.isHoldable({ server_type: 'plex', server_user_id: '1', played: true, position_ms: null })).toBe(true); + }); + }); + + describe('createHolds', () => { + it('snapshots each protected row with a 14-day deadline', async () => { + const now = new Date('2026-10-03T00:00:00Z'); + VideoWatchStatus.findAll.mockResolvedValue([ + { video_id: 7, server_type: 'jellyfin', server_user_id: 'u1', played: true, play_count: 2, position_ms: null, percent_watched: 100, last_watched_at: '2026-09-01T00:00:00Z' }, + { video_id: 7, server_type: 'plex', server_user_id: '5', played: true, play_count: 1, position_ms: null, percent_watched: 100, last_watched_at: null }, + ]); + + require('../../../models/video').findAll.mockResolvedValue([{ id: 7, filePath: FROM_PATH }]); + + const count = await holds.createHolds({ operationId: 9, videoIds: [7], now }); + + expect(count).toBe(1); + expect(WatchStatusHold.create).toHaveBeenCalledWith(expect.objectContaining({ + video_id: 7, server_type: 'jellyfin', server_user_id: 'u1', operation_id: 9, state: 'pending', + expires_at: new Date('2026-10-17T00:00:00Z'), + })); + expect(JSON.parse(WatchStatusHold.create.mock.calls[0][0].snapshot)) + .toMatchObject({ played: true, playCount: 2, fromPath: FROM_PATH }); + }); + + it('keeps the stronger state when an active hold already protects the row', async () => { + VideoWatchStatus.findAll.mockResolvedValue([ + { video_id: 7, server_type: 'jellyfin', server_user_id: 'u1', played: false, play_count: 0, position_ms: 1000, percent_watched: 5, last_watched_at: null }, + ]); + const existing = holdRow({ played: true, playCount: 1, positionMs: null, percentWatched: 100, lastWatchedAt: null }, + { server_type: 'jellyfin', server_user_id: 'u1' }); + WatchStatusHold.findAll.mockResolvedValue([existing]); + + await holds.createHolds({ operationId: 10, videoIds: [7] }); + + expect(WatchStatusHold.create).not.toHaveBeenCalled(); + expect(JSON.parse(existing.update.mock.calls[0][0].snapshot)).toMatchObject({ played: true, positionMs: 1000 }); + }); + + it('makes a re-held row due for a fresh push: the earlier push went to the earlier location', async () => { + VideoWatchStatus.findAll.mockResolvedValue([ + { video_id: 7, server_type: 'jellyfin', server_user_id: 'u1', played: true, play_count: 1, position_ms: null, percent_watched: 100, last_watched_at: null }, + ]); + const existing = holdRow({ played: true, playCount: 1, positionMs: null, percentWatched: 100, lastWatchedAt: null }, + { server_type: 'jellyfin', server_user_id: 'u1', state: 'restored', attempts: 2, last_attempt_at: new Date('2026-10-01T00:00:00Z'), last_error: null }); + WatchStatusHold.findAll.mockResolvedValue([existing]); + + await holds.createHolds({ operationId: 11, videoIds: [7] }); + + expect(existing.update).toHaveBeenCalledWith(expect.objectContaining({ + state: 'pending', operation_id: 11, attempts: 0, last_attempt_at: null, last_pushed_at: null, last_error: null, + })); + }); + + it('does nothing for videos without protected rows', async () => { + await expect(holds.createHolds({ operationId: 1, videoIds: [7] })).resolves.toBe(0); + expect(WatchStatusHold.create).not.toHaveBeenCalled(); + }); + }); + + describe('applyHolds', () => { + const watched = { played: true, playCount: 1, positionMs: null, percentWatched: 100, lastWatchedAt: '2026-09-01T00:00:00.000Z' }; + + it('passes matches through when no hold applies', async () => { + const matches = [{ video: VIDEO, entry: entry() }]; + + await expect(holds.applyHolds('plex', matches)).resolves.toEqual(matches); + }); + + it('drops a downgrade of a held row', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow(watched)]); + + await expect(holds.applyHolds('plex', [{ video: VIDEO, entry: entry() }])).resolves.toEqual([]); + }); + + it('drops the reset of a held row whose server still lists the old path', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow(watched, { server_type: 'jellyfin', server_user_id: 'u1' })]); + const cleared = { serverUserId: 'u1', played: false, playCount: 0, positionMs: 0, percentWatched: null, lastWatchedAt: null }; + + await expect(holds.applyHolds('jellyfin', [{ video: VIDEO, entry: cleared }])).resolves.toEqual([]); + }); + + it('ends the hold when the item at the new path shows the state, keeping the row\'s history', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow(watched)]); + const match = { video: VIDEO, entry: entry({ played: true, playCount: 1 }) }; + + const kept = await holds.applyHolds('plex', [match]); + + expect(kept).toHaveLength(1); + expect(kept[0].entry).toMatchObject({ played: true, playCount: 1, lastWatchedAt: '2026-09-01T00:00:00.000Z' }); + expect(WatchStatusHold.update).toHaveBeenCalledWith(expect.objectContaining({ state: 'restored' }), { where: { id: [3] } }); + }); + + it('writes but keeps the hold when only the old path shows the state', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow(watched)]); + const match = { video: VIDEO, entry: entry({ path: OLD_PATH, played: true, playCount: 1 }) }; + + await expect(holds.applyHolds('plex', [match])).resolves.toEqual([match]); + expect(WatchStatusHold.update).not.toHaveBeenCalled(); + }); + + it('keeps the hold when only a stale item at the old TV path shows the state', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow({ ...watched, fromPath: TV_FROM_PATH })]); + const stale = entry({ path: '/srv/media/__OldTV/Chan/Season 2024/S2024E03151200 - Title [id1].mp4', played: true, playCount: 1 }); + + await expect(holds.applyHolds('plex', [{ video: TV_VIDEO, entry: stale }])).resolves.toHaveLength(1); + expect(WatchStatusHold.update).not.toHaveBeenCalled(); + }); + + it('ends the hold when the item at the new TV path shows the state', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow({ ...watched, fromPath: TV_FROM_PATH })]); + const current = entry({ path: '/srv/media/__NewTV/Chan/Season 2024/S2024E03151200 - Title [id1].mp4', played: true, playCount: 1 }); + + await holds.applyHolds('plex', [{ video: TV_VIDEO, entry: current }]); + + expect(WatchStatusHold.update).toHaveBeenCalledWith(expect.objectContaining({ state: 'restored' }), { where: { id: [3] } }); + }); + + it('accepts a newer state at the new path and ends the hold', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow({ played: false, playCount: 0, positionMs: 60000, percentWatched: 10, lastWatchedAt: '2026-09-01T00:00:00.000Z' })]); + const match = { video: VIDEO, entry: entry({ positionMs: 1000, lastWatchedAt: new Date('2026-10-02T00:00:00Z') }) }; + + await expect(holds.applyHolds('plex', [match])).resolves.toEqual([match]); + expect(WatchStatusHold.update).toHaveBeenCalled(); + }); + + describe('after a push', () => { + const pushedAt = new Date('2026-10-03T12:00:00Z'); + const history = { played: true, playCount: 3, positionMs: null, percentWatched: 100, lastWatchedAt: '2026-09-01T00:00:00.000Z' }; + const pushed = (overrides = {}) => holdRow(history, { + attempts: 1, last_attempt_at: pushedAt, last_pushed_at: pushedAt, last_error: null, ...overrides, + }); + + it('keeps the historical count and last-watched time when the server echoes the push', async () => { + WatchStatusHold.findAll.mockResolvedValue([pushed()]); + const echo = entry({ played: true, playCount: 1, lastWatchedAt: new Date('2026-10-03T12:00:04Z') }); + + const kept = await holds.applyHolds('plex', [{ video: VIDEO, entry: echo }]); + + expect(kept).toHaveLength(1); + expect(kept[0].entry).toMatchObject({ played: true, playCount: 3, lastWatchedAt: '2026-09-01T00:00:00.000Z' }); + expect(WatchStatusHold.update).toHaveBeenCalledWith(expect.objectContaining({ state: 'restored' }), { where: { id: [3] } }); + }); + + it('keeps preserving the history once the hold has ended', async () => { + WatchStatusHold.findAll.mockResolvedValue([pushed({ state: 'restored' })]); + const echo = entry({ played: true, playCount: 1, lastWatchedAt: new Date('2026-10-03T12:00:04Z') }); + + const kept = await holds.applyHolds('plex', [{ video: VIDEO, entry: echo }]); + + expect(kept[0].entry).toMatchObject({ playCount: 3, lastWatchedAt: '2026-09-01T00:00:00.000Z' }); + expect(WatchStatusHold.update).not.toHaveBeenCalled(); + }); + + it('treats a watch near a push that was skipped (nothing written) as the real watch it is', async () => { + WatchStatusHold.findAll.mockResolvedValue([pushed({ last_pushed_at: null })]); + const watched = entry({ played: true, playCount: 1, lastWatchedAt: new Date('2026-10-03T12:00:04Z') }); + + const kept = await holds.applyHolds('plex', [{ video: VIDEO, entry: watched }]); + + expect(kept[0].entry).toBe(watched); + }); + + it('accepts a real later watch as it is', async () => { + WatchStatusHold.findAll.mockResolvedValue([pushed({ state: 'restored' })]); + const later = entry({ played: true, playCount: 2, lastWatchedAt: new Date('2026-10-03T15:00:00Z') }); + + const kept = await holds.applyHolds('plex', [{ video: VIDEO, entry: later }]); + + expect(kept[0].entry).toBe(later); + }); + + it('asks for another push when the server shows less than the held state', async () => { + WatchStatusHold.findAll.mockResolvedValue([pushed()]); + + await expect(holds.applyHolds('plex', [{ video: VIDEO, entry: entry() }])).resolves.toEqual([]); + expect(WatchStatusHold.update).toHaveBeenCalledWith( + { last_error: expect.stringMatching(/less than/) }, { where: { id: [3] } } + ); + }); + + it('does not ask for a push while the stale item at the old path is all the server lists', async () => { + WatchStatusHold.findAll.mockResolvedValue([pushed({ snapshot: JSON.stringify({ ...history, fromPath: TV_FROM_PATH }) })]); + const stale = entry({ path: '/srv/media/__OldTV/Chan/Season 2024/S2024E03151200 - Title [id1].mp4', played: false }); + + await holds.applyHolds('plex', [{ video: TV_VIDEO, entry: stale }]); + + expect(WatchStatusHold.update).not.toHaveBeenCalled(); + }); + }); + + it('drops a lower resume position for an in-progress hold', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow({ played: false, playCount: 0, positionMs: 60000, percentWatched: 10, lastWatchedAt: null })]); + + await expect(holds.applyHolds('plex', [{ video: VIDEO, entry: entry({ positionMs: 0 }) }])).resolves.toEqual([]); + }); + }); + + it('releases the holds of videos an operation did not move', async () => { + WatchStatusHold.update.mockResolvedValue([2]); + + await expect(holds.releaseUnmovedHolds({ operationId: 9, videoIds: [7, 8] })).resolves.toBe(2); + expect(WatchStatusHold.update).toHaveBeenCalledWith( + { state: 'dismissed' }, + { where: { operation_id: 9, video_id: [7, 8], state: ['pending', 'failed'] } } + ); + }); + + it('marks pending holds past their deadline as failed', async () => { + WatchStatusHold.update.mockResolvedValue([2]); + + await expect(holds.expireHolds(new Date('2026-10-20T00:00:00Z'))).resolves.toBe(2); + expect(WatchStatusHold.update.mock.calls[0][0]).toMatchObject({ state: 'failed' }); + }); + + it('reopens a failed hold for another 14 days', async () => { + const hold = holdRow({ played: true }, { state: 'failed' }); + WatchStatusHold.findByPk.mockResolvedValue(hold); + + await holds.reopenHold(3, new Date('2026-10-03T00:00:00Z')); + + expect(hold.update).toHaveBeenCalledWith(expect.objectContaining({ + state: 'pending', last_attempt_at: null, expires_at: new Date('2026-10-17T00:00:00Z'), + })); + }); + + it('dismisses a hold', async () => { + const hold = holdRow({ played: true }, { state: 'failed' }); + WatchStatusHold.findByPk.mockResolvedValue(hold); + + await expect(holds.dismissHold(3)).resolves.toBe(true); + expect(hold.update).toHaveBeenCalledWith({ state: 'dismissed' }); + }); + + it('reports a missing hold when dismissing', async () => { + WatchStatusHold.findByPk.mockResolvedValue(null); + + await expect(holds.dismissHold(99)).resolves.toBe(false); + }); + + it('describes holds with their video and server user names', async () => { + WatchStatusHold.findAll.mockResolvedValue([holdRow({ played: false, positionMs: 61000 }, { + state: 'failed', server_type: 'jellyfin', server_user_id: 'u1', attempts: 3, last_error: 'HTTP 404', + })]); + require('../../../models/video').findAll.mockResolvedValue([{ id: 7, youtubeId: 'id1', youTubeVideoName: 'Title', youTubeChannelName: 'Chan' }]); + require('../../../models/mediaserveruser').findAll.mockResolvedValue([{ server_type: 'jellyfin', server_user_id: 'u1', server_user_name: 'Ann' }]); + + await expect(holds.describeHolds({ states: ['failed'] })).resolves.toEqual([expect.objectContaining({ + id: 3, state: 'failed', serverType: 'jellyfin', serverUserName: 'Ann', title: 'Title', played: false, + positionMs: 61000, attempts: 3, lastError: 'HTTP 404', + })]); + }); +}); diff --git a/server/modules/mediaServers/__tests__/watchStatusPushBack.test.js b/server/modules/mediaServers/__tests__/watchStatusPushBack.test.js new file mode 100644 index 000000000..69fc00df3 --- /dev/null +++ b/server/modules/mediaServers/__tests__/watchStatusPushBack.test.js @@ -0,0 +1,205 @@ +const NEW_PATH = '/data/__TV/Chan/Season 2024/S2024E01 [id1].mp4'; +const FROM_PATH = '/data/__Kids/Chan/Chan - Title - id1/Chan - Title [id1].mp4'; + +const hold = (overrides = {}) => ({ + id: 1, video_id: 7, server_type: 'jellyfin', server_user_id: 'u1', attempts: 0, + snapshot: JSON.stringify({ played: true, positionMs: null, fromPath: FROM_PATH }), + update: jest.fn().mockResolvedValue(undefined), + ...overrides, +}); + +const matches = (byPath) => new Map(Object.entries(byPath)); + +describe('watchStatusPushBack', () => { + let pushBack; + let WatchStatusHold; + let Video; + let serverRegistry; + let adapter; + + beforeEach(() => { + jest.resetModules(); + jest.doMock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + jest.doMock('../../../models/watchstatushold', () => ({ findAll: jest.fn().mockResolvedValue([]) })); + jest.doMock('../../../models/video', () => ({ findAll: jest.fn().mockResolvedValue([]) })); + jest.doMock('../../configModule', () => ({ getConfig: jest.fn(() => ({})) })); + jest.doMock('../serverRegistry', () => ({ getEnabledAdapters: jest.fn(() => []) })); + jest.doMock('../watchStatusHolds', () => ({ HOLD_STATE: { PENDING: 'pending' } })); + WatchStatusHold = require('../../../models/watchstatushold'); + Video = require('../../../models/video'); + serverRegistry = require('../serverRegistry'); + adapter = { + serverType: 'jellyfin', + // The new item: a full match at the new path, no match at the old one. + resolveItemMatchesByPaths: jest.fn(async () => matches({ [NEW_PATH]: { id: 'item-1', score: 5 }, [FROM_PATH]: null })), + getWatchState: jest.fn().mockResolvedValue(null), + setWatchState: jest.fn().mockResolvedValue(undefined), + }; + serverRegistry.getEnabledAdapters.mockReturnValue([adapter]); + Video.findAll.mockResolvedValue([{ id: 7, filePath: NEW_PATH }]); + pushBack = require('../watchStatusPushBack'); + }); + + it('pushes the held state to the item at the video\'s current path', async () => { + const pending = hold(); + WatchStatusHold.findAll.mockResolvedValue([pending]); + + const result = await pushBack.pushPendingHolds(); + + expect(adapter.resolveItemMatchesByPaths).toHaveBeenCalledWith(expect.arrayContaining([NEW_PATH, FROM_PATH])); + expect(adapter.setWatchState).toHaveBeenCalledWith('item-1', 'u1', expect.objectContaining({ played: true, positionMs: null })); + expect(result).toEqual({ pushed: 1, notIndexed: 0, failed: 0 }); + expect(pending.update).toHaveBeenCalledWith(expect.objectContaining({ attempts: 1, last_error: null, last_pushed_at: expect.any(Date) })); + }); + + it('records that the server has not indexed the moved file yet', async () => { + const pending = hold(); + WatchStatusHold.findAll.mockResolvedValue([pending]); + adapter.resolveItemMatchesByPaths.mockResolvedValue(new Map()); + + const result = await pushBack.pushPendingHolds(); + + expect(result.notIndexed).toBe(1); + expect(adapter.setWatchState).not.toHaveBeenCalled(); + expect(pending.update.mock.calls[0][0].last_error).toMatch(/not indexed/); + }); + + it('does not push to a stale item that matches the old path better than the new one', async () => { + const pending = hold({ snapshot: JSON.stringify({ played: true, positionMs: null, fromPath: '/data/__OldTV/Chan/Season 2024/S2024E01 [id1].mp4' }) }); + WatchStatusHold.findAll.mockResolvedValue([pending]); + // Before the server rescans, the old item is the only candidate: it shares + // Chan/Season 2024/<file> with the new path and everything with the old. + adapter.resolveItemMatchesByPaths.mockResolvedValue(new Map([ + [NEW_PATH, { id: 'stale', score: 3 }], + ['/data/__OldTV/Chan/Season 2024/S2024E01 [id1].mp4', { id: 'stale', score: 5 }], + ])); + + const result = await pushBack.pushPendingHolds(); + + expect(adapter.setWatchState).not.toHaveBeenCalled(); + expect(result.notIndexed).toBe(1); + }); + + it('pushes to the new item when the server still lists the old one as well', async () => { + const oldPath = '/data/__OldTV/Chan/Season 2024/S2024E01 [id1].mp4'; + const pending = hold({ snapshot: JSON.stringify({ played: true, positionMs: null, fromPath: oldPath }) }); + WatchStatusHold.findAll.mockResolvedValue([pending]); + adapter.resolveItemMatchesByPaths.mockResolvedValue(new Map([ + [NEW_PATH, { id: 'new', score: 5 }], + [oldPath, { id: 'stale', score: 5 }], + ])); + + const result = await pushBack.pushPendingHolds(); + + expect(adapter.setWatchState).toHaveBeenCalledWith('new', 'u1', expect.objectContaining({ played: true })); + expect(result.pushed).toBe(1); + }); + + it('pushes to the only matching item on an explicit retry', async () => { + const pending = hold({ id: 4, snapshot: JSON.stringify({ played: true, positionMs: null, fromPath: '/data/__OldTV/Chan/Season 2024/S2024E01 [id1].mp4' }) }); + WatchStatusHold.findAll.mockResolvedValue([pending]); + adapter.resolveItemMatchesByPaths.mockResolvedValue(new Map([ + [NEW_PATH, { id: 'only', score: 3 }], + ['/data/__OldTV/Chan/Season 2024/S2024E01 [id1].mp4', { id: 'only', score: 3 }], + ])); + + const result = await pushBack.pushPendingHolds({ holdIds: [4] }); + + expect(adapter.setWatchState).toHaveBeenCalledWith('only', 'u1', expect.objectContaining({ played: true })); + expect(result.pushed).toBe(1); + }); + + it('reads the item\'s state first and leaves a state at least as watched alone', async () => { + const pending = hold(); + WatchStatusHold.findAll.mockResolvedValue([pending]); + adapter.getWatchState.mockResolvedValue({ played: true, playCount: 1, positionMs: 0, lastWatchedAt: new Date() }); + + const result = await pushBack.pushPendingHolds(); + + expect(adapter.getWatchState).toHaveBeenCalledWith('item-1', 'u1'); + expect(adapter.setWatchState).not.toHaveBeenCalled(); + expect(result.pushed).toBe(1); + expect(pending.update).toHaveBeenCalledWith(expect.objectContaining({ attempts: 1, last_error: null })); + expect(pending.update.mock.calls[0][0]).not.toHaveProperty('last_pushed_at'); + }); + + it('does not overwrite a watch made after the snapshot', async () => { + const pending = hold({ snapshot: JSON.stringify({ played: false, positionMs: 60000, lastWatchedAt: '2026-10-01T00:00:00.000Z' }) }); + WatchStatusHold.findAll.mockResolvedValue([pending]); + adapter.getWatchState.mockResolvedValue({ played: false, playCount: 0, positionMs: 1000, lastWatchedAt: new Date('2026-10-03T00:00:00Z') }); + + await pushBack.pushPendingHolds(); + + expect(adapter.setWatchState).not.toHaveBeenCalled(); + }); + + it('writes when the server shows less than the held state', async () => { + const pending = hold({ snapshot: JSON.stringify({ played: false, positionMs: 60000, lastWatchedAt: null }) }); + WatchStatusHold.findAll.mockResolvedValue([pending]); + adapter.getWatchState.mockResolvedValue({ played: false, playCount: 0, positionMs: 0, lastWatchedAt: null }); + + await pushBack.pushPendingHolds(); + + expect(adapter.setWatchState).toHaveBeenCalledWith('item-1', 'u1', expect.objectContaining({ positionMs: 60000 })); + }); + + it('records a failed write and carries on', async () => { + const first = hold(); + const second = hold({ id: 2, server_user_id: 'u2' }); + WatchStatusHold.findAll.mockResolvedValue([first, second]); + adapter.setWatchState.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined); + + const result = await pushBack.pushPendingHolds(); + + expect(result).toEqual({ pushed: 1, notIndexed: 0, failed: 1 }); + expect(first.update.mock.calls[0][0].last_error).toBe('boom'); + }); + + it('records holds whose server is no longer configured', async () => { + const pending = hold({ server_type: 'emby' }); + WatchStatusHold.findAll.mockResolvedValue([pending]); + + const result = await pushBack.pushPendingHolds(); + + expect(result.failed).toBe(1); + expect(pending.update.mock.calls[0][0].last_error).toMatch(/no longer configured/); + }); + + it('pushes holds never tried and holds whose last push did not take, not ones pushed successfully', async () => { + const { Op } = require('sequelize'); + await pushBack.pushPendingHolds({ onlyDue: false }); + + expect(WatchStatusHold.findAll.mock.calls[0][0].where[Op.or]).toEqual([ + { last_attempt_at: null }, + { last_error: { [Op.ne]: null } }, + ]); + }); + + it('waits an hour between pushes of a hold whose last push did not take', async () => { + const { Op } = require('sequelize'); + const now = new Date('2026-10-03T12:00:00Z'); + await pushBack.pushPendingHolds({ now }); + + expect(WatchStatusHold.findAll.mock.calls[0][0].where[Op.or]).toEqual([ + { last_attempt_at: null }, + { last_error: { [Op.ne]: null }, last_attempt_at: { [Op.lt]: new Date('2026-10-03T11:00:00Z') } }, + ]); + }); + + it('pushes only the named holds on an explicit retry, pushed before or not', async () => { + const { Op } = require('sequelize'); + await pushBack.pushPendingHolds({ holdIds: [4] }); + + expect(WatchStatusHold.findAll.mock.calls[0][0].where).toMatchObject({ id: [4], state: 'pending' }); + expect(WatchStatusHold.findAll.mock.calls[0][0].where[Op.or]).toBeUndefined(); + }); + + it('schedules follow-up pushes after a reorganize', () => { + const schedule = jest.fn(() => ({ unref: jest.fn() })); + + pushBack.scheduleFollowUps({ delaysMs: [10, 20], schedule }); + + expect(schedule).toHaveBeenCalledTimes(2); + expect(schedule.mock.calls.map((call) => call[1])).toEqual([10, 20]); + }); +}); diff --git a/server/modules/mediaServers/__tests__/watchStatusSync.test.js b/server/modules/mediaServers/__tests__/watchStatusSync.test.js index a0526e638..267d7bbd5 100644 --- a/server/modules/mediaServers/__tests__/watchStatusSync.test.js +++ b/server/modules/mediaServers/__tests__/watchStatusSync.test.js @@ -18,6 +18,11 @@ describe('watchStatusSync', () => { })); jest.doMock('../../configModule', () => ({ getConfig: jest.fn(() => ({})) })); jest.doMock('../serverRegistry', () => ({ getEnabledAdapters: jest.fn() })); + jest.doMock('../watchStatusHolds', () => ({ + applyHolds: jest.fn(async (serverType, matches) => matches), + expireHolds: jest.fn().mockResolvedValue(0), + })); + jest.doMock('../watchStatusPushBack', () => ({ pushPendingHolds: jest.fn().mockResolvedValue({}) })); jest.doMock('../../../models', () => ({ Video: { findAll: jest.fn(), findOne: jest.fn() }, VideoWatchStatus: { @@ -40,6 +45,33 @@ describe('watchStatusSync', () => { configModule.getConfig.mockReturnValue({ jellyfinUserId: 'JF_USER' }); }); + test('writes only the matches the watch-state holds let through', async () => { + const watchStatusHolds = require('../watchStatusHolds'); + watchStatusHolds.applyHolds.mockResolvedValue([]); + serverRegistry.getEnabledAdapters.mockReturnValue([fakeAdapter('plex', resolvedFetch([{ + path: '/media/Chan/Video A [id1].mp4', serverUserId: '1', played: false, playCount: 0, + positionMs: 0, percentWatched: 0, lastWatchedAt: null, + }]))]); + Video.findAll.mockResolvedValue([{ id: 7, youtubeId: 'id1', filePath: '/data/Chan/Video A [id1].mp4' }]); + + await watchStatusSync.syncAll(); + + expect(watchStatusHolds.applyHolds).toHaveBeenCalledWith('plex', [expect.objectContaining({ video: expect.objectContaining({ id: 7 }) })]); + expect(VideoWatchStatus.bulkCreate).not.toHaveBeenCalled(); + }); + + test('expires old holds and pushes pending ones after reading the servers', async () => { + const watchStatusHolds = require('../watchStatusHolds'); + const watchStatusPushBack = require('../watchStatusPushBack'); + serverRegistry.getEnabledAdapters.mockReturnValue([fakeAdapter('plex', resolvedFetch([]))]); + Video.findAll.mockResolvedValue([]); + + await watchStatusSync.syncAll(); + + expect(watchStatusHolds.expireHolds).toHaveBeenCalled(); + expect(watchStatusPushBack.pushPendingHolds).toHaveBeenCalled(); + }); + test('skips when no media servers are configured', async () => { serverRegistry.getEnabledAdapters.mockReturnValue([]); const summary = await watchStatusSync.syncAll('manual'); diff --git a/server/modules/mediaServers/adapters/__tests__/baseAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/baseAdapter.test.js index a9bd94d4f..0c2e59224 100644 --- a/server/modules/mediaServers/adapters/__tests__/baseAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/baseAdapter.test.js @@ -3,6 +3,7 @@ const { extractBasename, pathSegments, trailingSegmentMatch, + bestItemMatchesByPath, isServerUnavailableError, describeHttpError, MediaServerUnavailableError, @@ -136,3 +137,17 @@ describe('baseAdapter helpers', () => { }); }); }); + +describe('bestItemMatchesByPath', () => { + test('picks the item sharing the most trailing folders with each file, with that count', () => { + const matches = bestItemMatchesByPath([ + { id: 'stale', path: 'Q:\\Media\\Old\\Show\\Season 2024\\E [id1].mp4' }, + { id: 'current', path: '/srv/__TV/Show/Season 2024/E [id1].mp4' }, + ], ['/data/__TV/Show/Season 2024/E [id1].mp4']); + expect(matches.get('/data/__TV/Show/Season 2024/E [id1].mp4')).toEqual({ id: 'current', score: 4 }); + }); + + test('maps a file no item matches to null', () => { + expect(bestItemMatchesByPath([{ id: 'a', path: '/x/a.mp4' }], ['/y/b.mp4']).get('/y/b.mp4')).toBeNull(); + }); +}); diff --git a/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js index 356d7caad..1dc586122 100644 --- a/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js @@ -449,4 +449,41 @@ describe('EmbyAdapter', () => { await expect(adapter.fetchWatchStates()).rejects.toBeInstanceOf(MediaServerUnavailableError); }); }); + + describe('push-back after a reorganize', () => { + test('reads one user\'s state of an item before a push', async () => { + axios.get.mockResolvedValueOnce({ data: { Path: '/m/x.mp4', UserData: { Played: false, PlayCount: 0, PlaybackPositionTicks: 15000000 } } }); + const state = await new EmbyAdapter(cfg).getWatchState('ITEM', 'U2'); + expect(axios.get).toHaveBeenCalledWith(`${new EmbyAdapter(cfg).url}/Users/U2/Items/ITEM`, expect.anything()); + expect(state).toMatchObject({ played: false, positionMs: 1500 }); + }); + + test('reports an unreadable item state as unknown', async () => { + axios.get.mockRejectedValueOnce(Object.assign(new Error('gone'), { response: { status: 404 } })); + await expect(new EmbyAdapter(cfg).getWatchState('ITEM', 'U2')).resolves.toBeNull(); + }); + + test('marks an item played for a user', async () => { + axios.post.mockResolvedValueOnce({}); + await new EmbyAdapter(cfg).setWatchState('ITEM', 'U2', { played: true, positionMs: null }); + expect(axios.post).toHaveBeenCalledWith(`${new EmbyAdapter(cfg).url}/Users/U2/PlayedItems/ITEM`, null, expect.anything()); + }); + + test('sets a resume position in ticks', async () => { + axios.post.mockResolvedValueOnce({}); + await new EmbyAdapter(cfg).setWatchState('ITEM', 'U2', { played: false, positionMs: 2000 }); + expect(axios.post).toHaveBeenCalledWith(`${new EmbyAdapter(cfg).url}/Users/U2/Items/ITEM/UserData`, + { PlaybackPositionTicks: 20000000, Played: false }, expect.anything()); + }); + + test('pages through the library to resolve moved files', async () => { + const fullPage = Array.from({ length: 1000 }, (_, i) => ({ Id: `X${i}`, Path: `/m/other/f${i} [x${i}].mp4` })); + axios.get + .mockResolvedValueOnce({ data: { Items: fullPage } }) + .mockResolvedValueOnce({ data: { Items: [{ Id: 'HIT', Path: '/m/__TV/Chan/Season 2024/E [id1].mp4' }] } }); + const matches = await new EmbyAdapter(cfg).resolveItemMatchesByPaths(['/data/__TV/Chan/Season 2024/E [id1].mp4']); + expect(axios.get).toHaveBeenCalledTimes(2); + expect(matches.get('/data/__TV/Chan/Season 2024/E [id1].mp4')).toEqual({ id: 'HIT', score: 4 }); + }); + }); }); diff --git a/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js index a065d1a21..c8b98dd85 100644 --- a/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js @@ -469,4 +469,55 @@ describe('JellyfinAdapter', () => { await expect(adapter.fetchWatchStates()).rejects.toBeInstanceOf(MediaServerUnavailableError); }); }); + + describe('push-back after a reorganize', () => { + const notFound = () => Object.assign(new Error('not found'), { response: { status: 404 } }); + + test('reads one user\'s state of an item before a push', async () => { + axios.get.mockResolvedValueOnce({ data: { Path: '/m/x.mp4', UserData: { Played: true, PlayCount: 2, PlaybackPositionTicks: 0, LastPlayedDate: '2026-10-01T10:00:00Z' } } }); + const state = await new JellyfinAdapter(cfg).getWatchState('ITEM', 'U2'); + expect(axios.get).toHaveBeenCalledWith('http://jf:8096/Users/U2/Items/ITEM', expect.anything()); + expect(state).toMatchObject({ played: true, playCount: 2, positionMs: 0, lastWatchedAt: new Date('2026-10-01T10:00:00Z') }); + }); + + test('reports an unreadable item state as unknown', async () => { + axios.get.mockRejectedValueOnce(notFound()); + await expect(new JellyfinAdapter(cfg).getWatchState('ITEM', 'U2')).resolves.toBeNull(); + }); + + test('marks an item played for a user through the 10.9+ endpoint', async () => { + axios.post.mockResolvedValueOnce({}); + await new JellyfinAdapter(cfg).setWatchState('ITEM', 'U2', { played: true, positionMs: null }); + expect(axios.post).toHaveBeenCalledWith('http://jf:8096/UserPlayedItems/ITEM', null, + expect.objectContaining({ params: { userId: 'U2' } })); + }); + + test('falls back to the legacy played endpoint on older servers', async () => { + axios.post.mockRejectedValueOnce(notFound()).mockResolvedValueOnce({}); + await new JellyfinAdapter(cfg).setWatchState('ITEM', 'U2', { played: true, positionMs: null }); + expect(axios.post).toHaveBeenLastCalledWith('http://jf:8096/Users/U2/PlayedItems/ITEM', null, expect.anything()); + }); + + test('sets a resume position in ticks', async () => { + axios.post.mockResolvedValueOnce({}); + await new JellyfinAdapter(cfg).setWatchState('ITEM', 'U2', { played: false, positionMs: 1500 }); + expect(axios.post).toHaveBeenCalledWith('http://jf:8096/UserItems/ITEM/UserData', + { PlaybackPositionTicks: 15000000, Played: false }, expect.objectContaining({ params: { userId: 'U2' } })); + }); + + test('does not fall back on other errors', async () => { + axios.post.mockRejectedValueOnce(Object.assign(new Error('denied'), { response: { status: 401 } })); + await expect(new JellyfinAdapter(cfg).setWatchState('ITEM', 'U2', { played: true })).rejects.toThrow('denied'); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + test('resolves a moved file to the item at its new path, not a stale one with the same name', async () => { + axios.get.mockResolvedValueOnce({ data: { Items: [ + { Id: 'OLD', Path: '/media/Old/Season 2024/S2024E01 - T [id1].mp4' }, + { Id: 'NEW', Path: '/media/__TV/Chan/Season 2024/S2024E01 - T [id1].mp4' }, + ] } }); + const matches = await new JellyfinAdapter(cfg).resolveItemMatchesByPaths(['/data/__TV/Chan/Season 2024/S2024E01 - T [id1].mp4']); + expect(matches.get('/data/__TV/Chan/Season 2024/S2024E01 - T [id1].mp4')).toEqual({ id: 'NEW', score: 4 }); + }); + }); }); diff --git a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js index 20a5d44b6..de01ec7a9 100644 --- a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js @@ -267,6 +267,21 @@ describe('PlexAdapter', () => { expect(resolved.get('/data/__New/Ch/v [aaa].mp4')).toBe('200'); }); + test('reports how well each item matches, for moved files', async () => { + mockSections(THREE_SECTIONS); + axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [] } } }); + axios.get.mockResolvedValueOnce({ + data: { MediaContainer: { Metadata: [{ ratingKey: '100', Media: [{ Part: [{ file: '/plex/__Old/Ch/v [aaa].mp4' }] }] }] } }, + }); + axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [] } } }); + axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [] } } }); + + const adapter = new PlexAdapter(cfg); + const matches = await adapter.resolveItemMatchesByPaths(['/data/__New/Ch/v [aaa].mp4', '/data/__Old/Ch/v [aaa].mp4']); + expect(matches.get('/data/__New/Ch/v [aaa].mp4')).toEqual({ id: '100', score: 2 }); + expect(matches.get('/data/__Old/Ch/v [aaa].mp4')).toEqual({ id: '100', score: 3 }); + }); + test('makes no requests for an empty batch', async () => { const adapter = new PlexAdapter(cfg); const resolved = await adapter.resolveItemIdsByFilepaths([]); @@ -1114,4 +1129,46 @@ describe('PlexAdapter', () => { expect(axios.get).toHaveBeenCalledTimes(2); }); }); + + describe('push-back after a reorganize', () => { + test('reads the owner\'s state of an item before a push', async () => { + axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [{ ratingKey: '42', viewCount: 1, viewOffset: 5000, lastViewedAt: 1700000000 }] } } }); + const state = await new PlexAdapter(cfg).getWatchState('42', '1'); + expect(axios.get).toHaveBeenCalledWith('http://plex:32400/library/metadata/42', expect.objectContaining({ + params: { 'X-Plex-Token': 'TOKEN' }, + })); + expect(state).toMatchObject({ played: true, playCount: 1, positionMs: 5000, lastWatchedAt: new Date(1700000000 * 1000) }); + }); + + test('cannot read another account\'s state', async () => { + await expect(new PlexAdapter(cfg).getWatchState('42', '5')).resolves.toBeNull(); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('reports an unreadable item state as unknown', async () => { + axios.get.mockRejectedValueOnce(Object.assign(new Error('gone'), { response: { status: 404 } })); + await expect(new PlexAdapter(cfg).getWatchState('42', '1')).resolves.toBeNull(); + }); + + test('scrobbles an item as the owner', async () => { + axios.get.mockResolvedValueOnce({}); + await new PlexAdapter(cfg).setWatchState('42', '1', { played: true, positionMs: null }); + expect(axios.get).toHaveBeenCalledWith('http://plex:32400/:/scrobble', expect.objectContaining({ + params: { identifier: 'com.plexapp.plugins.library', key: '42', 'X-Plex-Token': 'TOKEN' }, + })); + }); + + test('sets the owner\'s resume position', async () => { + axios.get.mockResolvedValueOnce({}); + await new PlexAdapter(cfg).setWatchState('42', '1', { played: false, positionMs: 61000 }); + expect(axios.get).toHaveBeenCalledWith('http://plex:32400/:/progress', expect.objectContaining({ + params: expect.objectContaining({ key: '42', time: 61000, state: 'stopped' }), + })); + }); + + test('refuses accounts other than the owner', async () => { + await expect(new PlexAdapter(cfg).setWatchState('42', '5', { played: true })).rejects.toThrow(/owner/); + expect(axios.get).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/modules/mediaServers/adapters/baseAdapter.js b/server/modules/mediaServers/adapters/baseAdapter.js index e09d626ab..8a1263b44 100644 --- a/server/modules/mediaServers/adapters/baseAdapter.js +++ b/server/modules/mediaServers/adapters/baseAdapter.js @@ -48,6 +48,64 @@ class BaseAdapter { * MediaServerUnavailableError when the server is unreachable. */ async fetchWatchStates(/* opts: { since } */) { throw new Error('not implemented'); } + + /** + * Items for files Youtarr moved: for each file, the item whose path shares + * the most trailing segments with it, with that count (the score), or null. + * The caller compares the scores of a video's new and old paths, because + * until the server rescans, the stale item at the old path still shares the + * file name (and, between two TV folders, the show and season folders too). + * Returns Map<filepath, {id, score}|null>. + */ + async resolveItemMatchesByPaths(/* filepaths */) { throw new Error('not implemented'); } + + /** + * One server user's current watch state of an item, read before a push so + * a state at least as watched (or watched since) is left alone. Resolves to + * { played, playCount, positionMs, percentWatched, lastWatchedAt }, or null + * when it cannot be read (the push then proceeds). + * @param {string} itemId + * @param {string} serverUserId + */ + async getWatchState(/* itemId, serverUserId */) { return null; } + + /** + * Write one server user's watch state for an item: played, or a resume + * position. Used to restore watch state after a reorganize moved the file. + * @param {string} itemId + * @param {string} serverUserId + * @param {{played: boolean, positionMs: number|null}} state + */ + async setWatchState(/* itemId, serverUserId, state */) { throw new Error('not implemented'); } +} + +/** + * Pick, for each file path, the item whose path shares the most trailing + * segments with it (at least the file name), with that count. + * + * @param {Array<{id: string, path: string}>} items + * @param {string[]} filepaths + * @returns {Map<string, {id: string, score: number}|null>} + */ +function bestItemMatchesByPath(items, filepaths) { + const byBasename = new Map(); + for (const item of items) { + if (!item.path) continue; + const base = extractBasename(item.path); + if (!byBasename.has(base)) byBasename.set(base, []); + byBasename.get(base).push({ id: item.id, segments: pathSegments(item.path) }); + } + const results = new Map(); + for (const filepath of filepaths) { + const target = pathSegments(filepath); + let best = null; + for (const candidate of byBasename.get(extractBasename(filepath)) || []) { + const score = trailingSegmentMatch(target, candidate.segments); + if (!best || score > best.score) best = { id: candidate.id, score }; + } + results.set(filepath, best); + } + return results; } /** @@ -152,6 +210,7 @@ module.exports = BaseAdapter; module.exports.extractBasename = extractBasename; module.exports.pathSegments = pathSegments; module.exports.trailingSegmentMatch = trailingSegmentMatch; +module.exports.bestItemMatchesByPath = bestItemMatchesByPath; module.exports.normalizeBaseUrl = normalizeBaseUrl; module.exports.REQUEST_TIMEOUT_MS = REQUEST_TIMEOUT_MS; module.exports.MediaServerUnavailableError = MediaServerUnavailableError; diff --git a/server/modules/mediaServers/adapters/embyAdapter.js b/server/modules/mediaServers/adapters/embyAdapter.js index 83dd81968..62f8a7b0e 100644 --- a/server/modules/mediaServers/adapters/embyAdapter.js +++ b/server/modules/mediaServers/adapters/embyAdapter.js @@ -2,6 +2,7 @@ const axios = require('axios'); const BaseAdapter = require('./baseAdapter'); const { extractBasename, + bestItemMatchesByPath, normalizeBaseUrl, REQUEST_TIMEOUT_MS, isServerUnavailableError, @@ -231,6 +232,58 @@ class EmbyAdapter extends BaseAdapter { } return this.createPlaylist(opts.name, itemIds, { public: !!opts.public, mediaType: opts.mediaType }); } + + // Every video item's path, paged, so moved files resolve to the item at + // their new path rather than a stale one with the same file name. + async resolveItemMatchesByPaths(filepaths) { + const items = []; + try { + for (let startIndex = 0; ; startIndex += WATCH_STATE_PAGE_SIZE) { + const params = { + userId: this.userId, + includeItemTypes: 'Video,Movie,Episode', + recursive: true, + fields: 'Path', + sortBy: 'SortName', + startIndex, + limit: WATCH_STATE_PAGE_SIZE, + }; + const res = await axios.get(`${this.url}/Items`, { headers: this._headers(), params, timeout: REQUEST_TIMEOUT_MS }); + const page = res.data?.Items || []; + for (const item of page) items.push({ id: item.Id, path: item.Path }); + if (page.length !== WATCH_STATE_PAGE_SIZE) break; + } + } catch (err) { + if (isServerUnavailableError(err)) throw new MediaServerUnavailableError(describeHttpError(err)); + throw err; + } + return bestItemMatchesByPath(items, filepaths); + } + + async getWatchState(itemId, serverUserId) { + const userId = serverUserId || this.userId; + try { + const res = await axios.get(`${this.url}/Users/${userId}/Items/${itemId}`, { headers: this._headers(), timeout: REQUEST_TIMEOUT_MS }); + return res.data ? this._itemWatchState(res.data, String(userId)) : null; + } catch (err) { + logger.debug({ ...describeHttpError(err), itemId }, 'emby: could not read an item\'s watch state before a push'); + return null; + } + } + + async setWatchState(itemId, serverUserId, { played, positionMs }) { + const userId = serverUserId || this.userId; + const options = { headers: this._headers(), timeout: REQUEST_TIMEOUT_MS }; + if (played) { + await axios.post(`${this.url}/Users/${userId}/PlayedItems/${itemId}`, null, options); + return; + } + await axios.post( + `${this.url}/Users/${userId}/Items/${itemId}/UserData`, + { PlaybackPositionTicks: Math.round((positionMs || 0) * TICKS_PER_MS), Played: false }, + options + ); + } } module.exports = EmbyAdapter; diff --git a/server/modules/mediaServers/adapters/jellyfinAdapter.js b/server/modules/mediaServers/adapters/jellyfinAdapter.js index f48fb6c4d..880a15d58 100644 --- a/server/modules/mediaServers/adapters/jellyfinAdapter.js +++ b/server/modules/mediaServers/adapters/jellyfinAdapter.js @@ -2,6 +2,7 @@ const axios = require('axios'); const BaseAdapter = require('./baseAdapter'); const { extractBasename, + bestItemMatchesByPath, normalizeBaseUrl, REQUEST_TIMEOUT_MS, isServerUnavailableError, @@ -15,6 +16,8 @@ const TICKS_PER_MS = 10000; const WATCH_STATE_FILTERS = [{ isPlayed: true }, { filters: 'IsResumable' }]; const WATCH_STATE_PAGE_SIZE = 1000; +// Statuses meaning an endpoint doesn't exist in this server version. +const MISSING_ENDPOINT_STATUSES = new Set([404, 405]); class JellyfinAdapter extends BaseAdapter { constructor(config) { @@ -223,6 +226,74 @@ class JellyfinAdapter extends BaseAdapter { } return this.createPlaylist(opts.name, itemIds, { public: !!opts.public, mediaType: opts.mediaType }); } + + // Every video item's path, paged, so moved files resolve to the item at + // their new path rather than a stale one with the same file name. + async resolveItemMatchesByPaths(filepaths) { + const items = []; + try { + for (let startIndex = 0; ; startIndex += WATCH_STATE_PAGE_SIZE) { + const params = { + userId: this.userId, + includeItemTypes: 'Video,Movie,Episode', + collapseBoxSetItems: false, + recursive: true, + fields: 'Path', + sortBy: 'SortName', + startIndex, + limit: WATCH_STATE_PAGE_SIZE, + }; + const res = await axios.get(`${this.url}/Items`, { headers: this._headers(), params, timeout: REQUEST_TIMEOUT_MS }); + const page = res.data?.Items || []; + for (const item of page) items.push({ id: item.Id, path: item.Path }); + if (page.length !== WATCH_STATE_PAGE_SIZE) break; + } + } catch (err) { + if (isServerUnavailableError(err)) throw new MediaServerUnavailableError(describeHttpError(err)); + throw err; + } + return bestItemMatchesByPath(items, filepaths); + } + + // Jellyfin 10.9+ takes the user as a query parameter; older versions only + // have the /Users/{id}/... routes. + async getWatchState(itemId, serverUserId) { + const userId = serverUserId || this.userId; + try { + const res = await axios.get(`${this.url}/Users/${userId}/Items/${itemId}`, { headers: this._headers(), timeout: REQUEST_TIMEOUT_MS }); + return res.data ? this._itemWatchState(res.data, String(userId)) : null; + } catch (err) { + logger.debug({ ...describeHttpError(err), itemId }, 'jellyfin: could not read an item\'s watch state before a push'); + return null; + } + } + + async setWatchState(itemId, serverUserId, { played, positionMs }) { + const userId = serverUserId || this.userId; + if (played) { + await this._postFirstAvailable( + [`/UserPlayedItems/${itemId}`, { userId }], + [`/Users/${userId}/PlayedItems/${itemId}`, {}], + null + ); + return; + } + await this._postFirstAvailable( + [`/UserItems/${itemId}/UserData`, { userId }], + [`/Users/${userId}/Items/${itemId}/UserData`, {}], + { PlaybackPositionTicks: Math.round((positionMs || 0) * TICKS_PER_MS), Played: false } + ); + } + + async _postFirstAvailable([path, params], [fallbackPath, fallbackParams], body) { + const options = (query) => ({ headers: this._headers(), params: query, timeout: REQUEST_TIMEOUT_MS }); + try { + await axios.post(`${this.url}${path}`, body, options(params)); + } catch (err) { + if (!MISSING_ENDPOINT_STATUSES.has(err.response?.status)) throw err; + await axios.post(`${this.url}${fallbackPath}`, body, options(fallbackParams)); + } + } } module.exports = JellyfinAdapter; diff --git a/server/modules/mediaServers/adapters/plexAdapter.js b/server/modules/mediaServers/adapters/plexAdapter.js index a6570c6b8..c2c474932 100644 --- a/server/modules/mediaServers/adapters/plexAdapter.js +++ b/server/modules/mediaServers/adapters/plexAdapter.js @@ -31,6 +31,9 @@ const EPISODE_NAME_PATTERN = /S\d+E\d+/i; // (full fidelity); history rows for account 1 are skipped as duplicates. const PLEX_OWNER_ACCOUNT_ID = '1'; +// Library items' identifier for the scrobble and progress endpoints. +const PLEX_LIBRARY_IDENTIFIER = 'com.plexapp.plugins.library'; + // Play-history pagination. The page cap bounds a single sync on servers with // enormous history; anything past it is picked up by later incremental syncs. const HISTORY_PAGE_SIZE = 1000; @@ -178,6 +181,11 @@ class PlexAdapter extends BaseAdapter { // libraries, so every section is scanned and the best-scoring candidate wins; // see trailingSegmentMatch in baseAdapter. async resolveItemIdsByFilepaths(filepaths) { + const matches = await this.resolveItemMatchesByPaths(filepaths); + return new Map([...matches].map(([filepath, match]) => [filepath, match ? match.id : null])); + } + + async resolveItemMatchesByPaths(filepaths) { const results = new Map(); const targets = [...new Set((filepaths || []).filter(Boolean))]; if (targets.length === 0) return results; @@ -233,12 +241,12 @@ class PlexAdapter extends BaseAdapter { for (const filepath of targets) { const targetSegments = pathSegments(filepath); - let best = null; // { ratingKey, score } + let best = null; // { id, score } for (const candidate of candidatesByBasename.get(extractBasename(filepath)) || []) { const score = trailingSegmentMatch(targetSegments, candidate.segments); - if (!best || score > best.score) best = { ratingKey: candidate.ratingKey, score }; + if (!best || score > best.score) best = { id: candidate.ratingKey, score }; } - results.set(filepath, best ? best.ratingKey : null); + results.set(filepath, best); } return results; } @@ -585,6 +593,40 @@ class PlexAdapter extends BaseAdapter { lastWatchedAt: item.lastViewedAt ? new Date(Number(item.lastViewedAt) * 1000) : null, }; } + + // The owner's state of one item (the admin token reads as the owner; other + // accounts can't be read this way, so they report unknown). + async getWatchState(itemId, serverUserId) { + if (String(serverUserId) !== PLEX_OWNER_ACCOUNT_ID) return null; + const auth = this.anonymousScope ? this._plParams() : { 'X-Plex-Token': this.token }; + try { + const res = await axios.get(`${this.url}/library/metadata/${itemId}`, { params: auth, timeout: REQUEST_TIMEOUT_MS }); + const item = (res.data?.MediaContainer?.Metadata || [])[0]; + return item ? this._itemWatchState(item) : null; + } catch (err) { + logger.debug({ ...describeHttpError(err), itemId }, 'plex: could not read an item\'s watch state before a push'); + return null; + } + } + + // Restores the owner's state only: the admin token writes as the owner, and + // other accounts' tokens are out of scope. /:/scrobble leaves no play + // history, which is fine for the owner (read from section listings). + async setWatchState(itemId, serverUserId, { played, positionMs }) { + if (String(serverUserId) !== PLEX_OWNER_ACCOUNT_ID) { + throw new Error('Youtarr can only restore the Plex server owner\'s watch state.'); + } + const auth = this.anonymousScope ? this._plParams() : { 'X-Plex-Token': this.token }; + const params = { identifier: PLEX_LIBRARY_IDENTIFIER, key: itemId, ...auth }; + if (played) { + await axios.get(`${this.url}/:/scrobble`, { params, timeout: REQUEST_TIMEOUT_MS }); + return; + } + await axios.get(`${this.url}/:/progress`, { + params: { ...params, time: Math.round(positionMs || 0), state: 'stopped' }, + timeout: REQUEST_TIMEOUT_MS, + }); + } } module.exports = PlexAdapter; diff --git a/server/modules/mediaServers/mediaServerSync.js b/server/modules/mediaServers/mediaServerSync.js index 3f9f49744..c3f3a392e 100644 --- a/server/modules/mediaServers/mediaServerSync.js +++ b/server/modules/mediaServers/mediaServerSync.js @@ -3,6 +3,7 @@ const configModule = require('../configModule'); const serverRegistry = require('./serverRegistry'); const { MediaServerUnavailableError, describeHttpError } = require('./adapters/baseAdapter'); const { Playlist, PlaylistVideo, PlaylistSyncState, Video } = require('../../models'); +const reorganizeLock = require('../reorganize/reorganizeLock'); // Backoff retry for resolving items after library scan. Tuned for typical Plex/Jellyfin // scan completion times — short initial delays, then longer as more time passes. @@ -30,6 +31,19 @@ class MediaServerSync { // Tradeoff: if the initial run rejects, joiners share that rejection and // any rerun they requested is dropped (a later call starts fresh). this._inFlight = new Map(); + // Playlists whose sync was put off while a reorganize moved files. + this._deferred = new Set(); + reorganizeLock.on('released', () => this._runDeferred()); + } + + _runDeferred() { + const ids = [...this._deferred]; + this._deferred.clear(); + for (const playlistId of ids) { + this.syncPlaylist(playlistId).catch((err) => { + logger.error({ err, playlistId }, 'Deferred media server playlist sync failed'); + }); + } } syncPlaylist(playlistId) { @@ -48,6 +62,11 @@ class MediaServerSync { return entry.promise; } + // A reorganize refuses to start while any playlist sync runs. + isAnySyncInFlight() { + return this._inFlight.size > 0; + } + async _runWithRerun(key, playlistId, entry) { try { await this._doSync(playlistId); @@ -61,6 +80,12 @@ class MediaServerSync { } async _doSync(playlistId) { + // Paths are mid-move; the sync runs once the reorganize ends. + if (reorganizeLock.isActive()) { + logger.info({ playlistId }, 'Downloads are being reorganized; deferring the media server playlist sync'); + this._deferred.add(playlistId); + return; + } const playlist = await Playlist.findByPk(playlistId); if (!playlist) return; diff --git a/server/modules/mediaServers/watchStateCompare.js b/server/modules/mediaServers/watchStateCompare.js new file mode 100644 index 000000000..9403750e9 --- /dev/null +++ b/server/modules/mediaServers/watchStateCompare.js @@ -0,0 +1,40 @@ +/** + * Comparisons between a media server's watch state of an item and a held + * snapshot of Youtarr's row. Pure, so push-back can decide before writing + * what the sync decides after reading (watchStatusHolds). + */ + +// Last-watched times compare with a little slack: servers round them. +const WATCHED_AT_TOLERANCE_MS = 1000; + +function timeOf(value) { + return value ? new Date(value).getTime() : null; +} + +/** + * The server reports at least the held state: played when the snapshot was, + * else played or a resume position no earlier than the snapshot's. + */ +function isAtLeast(entry, snapshot) { + if (snapshot.played) return Boolean(entry.played); + return Boolean(entry.played) || (entry.positionMs || 0) >= (snapshot.positionMs || 0); +} + +/** + * A watch made after the snapshot: finished when the snapshot wasn't, or a + * later last-watched time. Finishing resets the position to zero, which + * "played" covers. + */ +function isLaterWatch(entry, snapshot) { + if (entry.played && !snapshot.played) return true; + const entryTime = timeOf(entry.lastWatchedAt); + const snapshotTime = timeOf(snapshot.lastWatchedAt); + return entryTime !== null && snapshotTime !== null && entryTime > snapshotTime + WATCHED_AT_TOLERANCE_MS; +} + +module.exports = { + WATCHED_AT_TOLERANCE_MS, + timeOf, + isAtLeast, + isLaterWatch +}; diff --git a/server/modules/mediaServers/watchStatusHolds.js b/server/modules/mediaServers/watchStatusHolds.js new file mode 100644 index 000000000..a42a9a6d5 --- /dev/null +++ b/server/modules/mediaServers/watchStatusHolds.js @@ -0,0 +1,390 @@ +/** + * Watch-state holds. When a reorganize moves a video's files, the media + * servers see a new item: unwatched until Youtarr pushes the state back, and + * until they rescan they still list the old path, which the sync's + * current-copy rule rejects (resetting the row). A hold snapshots each + * (video, server, user) row with watch state before the move and, while it + * is active, the sync does not downgrade that row. + * + * A hold ends (restored) when the server reports state at least equal to the + * snapshot, or a newer state, for the item at the video's current path. One + * not restored within 14 days is marked failed; it keeps protecting the row + * until the user dismisses it. + * + * Plex accounts other than the owner come from play history, which a move + * never resets, so they get no hold. + */ + +const { Op } = require('sequelize'); +const WatchStatusHold = require('../../models/watchstatushold'); +const VideoWatchStatus = require('../../models/videowatchstatus'); +const Video = require('../../models/video'); +const MediaServerUser = require('../../models/mediaserveruser'); +const logger = require('../../logger'); +const { pathSegments, trailingSegmentMatch } = require('./adapters/baseAdapter'); +const { PLEX_OWNER_ACCOUNT_ID } = require('./adapters/plexAdapter'); +const { timeOf, isAtLeast, isLaterWatch } = require('./watchStateCompare'); + +const HOLD_STATE = Object.freeze({ + PENDING: 'pending', + RESTORED: 'restored', + FAILED: 'failed', + DISMISSED: 'dismissed', +}); +// Holds that still protect their row. +const ACTIVE_STATES = [HOLD_STATE.PENDING, HOLD_STATE.FAILED]; +const HOLD_TTL_MS = 14 * 24 * 60 * 60 * 1000; +// A server item is at the video's current path when its file and parent +// folder match (the sync's current-copy rule) and it matches the path the +// video came from less well: until the server rescans, the stale item at the +// old path shares the file name, and between two TV folders the show and +// season folders too. +const CURRENT_PATH_MIN_SEGMENTS = 2; +// A push stamps the server item with the push time as its last-watched time. +// A time this close to a successful push is that echo, not a new watch, so +// Youtarr's row keeps its historical play count and last-watched time. +const PUSH_ECHO_WINDOW_MS = 10 * 60 * 1000; +const EXPIRED_MESSAGE = 'The media server did not show this video as watched within 14 days of the move.'; +const NOT_TAKEN_MESSAGE = 'The media server shows less than the held state; the push is repeated.'; + +function holdKey(videoId, serverUserId) { + return `${videoId}:${serverUserId}`; +} + +/** + * Whether a stored watch-status row has state worth protecting. + * @param {{server_type: string, server_user_id: string, played: boolean, position_ms: number|null}} row + */ +function isHoldable(row) { + if (row.server_type === 'plex' && String(row.server_user_id) !== PLEX_OWNER_ACCOUNT_ID) return false; + return Boolean(row.played) || Number(row.position_ms) > 0; +} + +/** + * @param {Object} row - video_watch_status row + * @param {string|null} [fromPath] - The video's file path before the move + */ +function snapshotOf(row, fromPath = null) { + return { + played: Boolean(row.played), + playCount: row.play_count || 0, + positionMs: row.position_ms !== null && row.position_ms !== undefined ? Number(row.position_ms) : null, + percentWatched: row.percent_watched !== null && row.percent_watched !== undefined ? Number(row.percent_watched) : null, + lastWatchedAt: row.last_watched_at ? new Date(row.last_watched_at).toISOString() : null, + fromPath: fromPath || null, + }; +} + +// The stronger of two snapshots; the earlier hold's origin path is kept, +// since the stale item the servers may still list is the one at that path. +function mergeSnapshots(a, b) { + const later = (x, y) => { + if (!x) return y; + if (!y) return x; + return timeOf(x) >= timeOf(y) ? x : y; + }; + const max = (x, y) => (x === null || x === undefined ? y : y === null || y === undefined ? x : Math.max(x, y)); + return { + played: Boolean(a.played || b.played), + playCount: max(a.playCount, b.playCount) || 0, + positionMs: max(a.positionMs, b.positionMs), + percentWatched: max(a.percentWatched, b.percentWatched), + lastWatchedAt: later(a.lastWatchedAt, b.lastWatchedAt), + fromPath: a.fromPath || b.fromPath || null, + }; +} + +// When the hold's state was last written to the server, or null. An attempt +// that found the state already there wrote nothing and leaves no echo. +function pushedAt(hold) { + return hold.last_pushed_at ? timeOf(hold.last_pushed_at) : null; +} + +// The entry's last-watched time is the server echoing a push, not a watch. +function isPushEcho(entry, hold) { + const pushTime = pushedAt(hold); + const entryTime = timeOf(entry.lastWatchedAt); + return pushTime !== null && entryTime !== null && Math.abs(entryTime - pushTime) <= PUSH_ECHO_WINDOW_MS; +} + +// A real change made after the snapshot: finished, or watched later, the +// server's echo of Youtarr's own push excepted. +function isNewer(entry, snapshot, hold = null) { + if (entry.played && !snapshot.played) return true; + if (hold && isPushEcho(entry, hold)) return false; + return isLaterWatch(entry, snapshot); +} + +// The entry with the row's history: a restored state carries the server's +// count of one and the push time, which must not replace the real ones. +function withHistory(entry, snapshot) { + return { + ...entry, + playCount: Math.max(entry.playCount || 0, snapshot.playCount || 0), + lastWatchedAt: snapshot.lastWatchedAt || entry.lastWatchedAt || null, + }; +} + +function isAtCurrentPath(entry, video, fromPath) { + if (!entry.path || !video.filePath) return false; + const entrySegments = pathSegments(entry.path); + const current = trailingSegmentMatch(entrySegments, pathSegments(video.filePath)); + if (current < CURRENT_PATH_MIN_SEGMENTS) return false; + return !fromPath || current > trailingSegmentMatch(entrySegments, pathSegments(fromPath)); +} + +/** + * Snapshot the watch state of videos about to move. + * + * @param {Object} params + * @param {number|null} params.operationId + * @param {number[]} params.videoIds - Videos.id values + * @param {Date} [params.now] + * @returns {Promise<number>} Holds created or refreshed + */ +async function createHolds({ operationId, videoIds, now = new Date() }) { + if (videoIds.length === 0) return 0; + const rows = (await VideoWatchStatus.findAll({ + where: { video_id: videoIds, [Op.or]: [{ played: true }, { position_ms: { [Op.gt]: 0 } }] }, + raw: true, + })).filter(isHoldable); + if (rows.length === 0) return 0; + + const heldVideoIds = [...new Set(rows.map((row) => row.video_id))]; + const [existing, videos] = await Promise.all([ + WatchStatusHold.findAll({ where: { video_id: heldVideoIds } }), + Video.findAll({ where: { id: heldVideoIds }, attributes: ['id', 'filePath'], raw: true }), + ]); + const existingByKey = new Map(existing.map((hold) => [`${hold.server_type}:${holdKey(hold.video_id, hold.server_user_id)}`, hold])); + const pathBefore = new Map(videos.map((video) => [video.id, video.filePath])); + const expiresAt = new Date(now.getTime() + HOLD_TTL_MS); + for (const row of rows) { + const snapshot = snapshotOf(row, pathBefore.get(row.video_id)); + const hold = existingByKey.get(`${row.server_type}:${holdKey(row.video_id, row.server_user_id)}`); + if (hold) { + // A hold still active from an earlier move keeps the stronger state: + // the row it protected may be all that is left of it. Its push record + // starts over: the files move again, so an earlier push (to the + // earlier location) says nothing about the new one, and a hold pushed + // successfully once would otherwise never be pushed again. + const merged = ACTIVE_STATES.includes(hold.state) ? mergeSnapshots(JSON.parse(hold.snapshot), snapshot) : snapshot; + await hold.update({ + snapshot: JSON.stringify(merged), state: HOLD_STATE.PENDING, operation_id: operationId, + expires_at: expiresAt, attempts: 0, last_attempt_at: null, last_pushed_at: null, last_error: null, + }); + } else { + await WatchStatusHold.create({ + video_id: row.video_id, + server_type: row.server_type, + server_user_id: String(row.server_user_id), + operation_id: operationId, + snapshot: JSON.stringify(snapshot), + state: HOLD_STATE.PENDING, + expires_at: expiresAt, + }); + } + } + return rows.length; +} + +/** + * Filter a sync's matches for one server before they are written: drop + * downgrades of held rows, end holds the server now reports as restored, + * ask for another push where a pushed state did not take, and keep the row's + * history (play count, last watched) where the server only echoes a push, + * for restored holds too. + * + * @param {string} serverType + * @param {Array<{video: {id: number, filePath: string}, entry: Object}>} matches + * @returns {Promise<Array<Object>>} The matches to write + */ +async function applyHolds(serverType, matches) { + if (matches.length === 0) return matches; + const holds = await WatchStatusHold.findAll({ + where: { + server_type: serverType, + state: [...ACTIVE_STATES, HOLD_STATE.RESTORED], + video_id: [...new Set(matches.map(({ video }) => video.id))], + }, + }); + if (holds.length === 0) return matches; + + const holdsByKey = new Map(holds.map((hold) => [holdKey(hold.video_id, hold.server_user_id), hold])); + const restored = []; + const notTaken = []; + const kept = []; + for (const match of matches) { + const hold = holdsByKey.get(holdKey(match.video.id, String(match.entry.serverUserId))); + if (!hold) { + kept.push(match); + continue; + } + const snapshot = JSON.parse(hold.snapshot); + const { entry } = match; + const newer = isNewer(entry, snapshot, hold); + const written = newer ? entry : withHistory(entry, snapshot); + if (hold.state === HOLD_STATE.RESTORED) { + kept.push(written === entry ? match : { ...match, entry: written }); + continue; + } + const atCurrentPath = isAtCurrentPath(entry, match.video, snapshot.fromPath); + if (atCurrentPath && (isAtLeast(entry, snapshot) || newer)) { + restored.push(hold.id); + kept.push(written === entry ? match : { ...match, entry: written }); + } else if (newer || isAtLeast(entry, snapshot)) { + kept.push(match); + } else if (atCurrentPath && hold.attempts > 0 && !hold.last_error) { + // The item at the new path shows less than the state an attempt found + // or wrote there: it is pushed again. + notTaken.push(hold.id); + } + // Anything else would lower the protected state: Youtarr keeps its row. + } + if (restored.length > 0) { + await WatchStatusHold.update( + { state: HOLD_STATE.RESTORED, last_error: null }, + { where: { id: [...new Set(restored)] } } + ); + logger.info({ serverType, restored: restored.length }, 'Watch state restored on the media server after a reorganize'); + } + if (notTaken.length > 0) { + await WatchStatusHold.update({ last_error: NOT_TAKEN_MESSAGE }, { where: { id: [...new Set(notTaken)] } }); + } + return kept; +} + +/** + * Release the holds an operation took for videos it did not move: their + * files are where the servers already know them, so the servers' state is + * accurate again. + * @param {Object} params + * @param {number} params.operationId + * @param {number[]} params.videoIds - Videos.id values + * @returns {Promise<number>} + */ +async function releaseUnmovedHolds({ operationId, videoIds }) { + if (videoIds.length === 0) return 0; + const [count] = await WatchStatusHold.update( + { state: HOLD_STATE.DISMISSED }, + { where: { operation_id: operationId, video_id: videoIds, state: ACTIVE_STATES } } + ); + return count; +} + +/** + * Mark pending holds past their deadline as failed restores. + * @returns {Promise<number>} + */ +async function expireHolds(now = new Date()) { + const [count] = await WatchStatusHold.update( + { state: HOLD_STATE.FAILED, last_error: EXPIRED_MESSAGE }, + { where: { state: HOLD_STATE.PENDING, expires_at: { [Op.lt]: now } } } + ); + return count; +} + +/** + * @param {Object} [options] + * @param {string[]} [options.states] - Defaults to the active states + * @param {number} [options.limit] + */ +async function listHolds({ states = ACTIVE_STATES, limit = 500 } = {}) { + return WatchStatusHold.findAll({ where: { state: states }, order: [['updated_at', 'DESC']], limit }); +} + +/** + * Holds for the restore list: each with its video's title and the server + * user's name. + * @param {Object} [options] - listHolds options + */ +async function describeHolds(options = {}) { + const holds = await listHolds(options); + if (holds.length === 0) return []; + const [videos, users] = await Promise.all([ + Video.findAll({ + where: { id: [...new Set(holds.map((hold) => hold.video_id))] }, + attributes: ['id', 'youtubeId', 'youTubeVideoName', 'youTubeChannelName'], + raw: true, + }), + MediaServerUser.findAll({ attributes: ['server_type', 'server_user_id', 'server_user_name'], raw: true }), + ]); + const videoById = new Map(videos.map((video) => [video.id, video])); + const userName = new Map(users.map((user) => [`${user.server_type}:${user.server_user_id}`, user.server_user_name])); + return holds.map((hold) => { + const video = videoById.get(hold.video_id) || {}; + const snapshot = JSON.parse(hold.snapshot); + return { + id: hold.id, + state: hold.state, + serverType: hold.server_type, + serverUserId: hold.server_user_id, + serverUserName: userName.get(`${hold.server_type}:${hold.server_user_id}`) || null, + youtubeId: video.youtubeId || null, + title: video.youTubeVideoName || null, + channelName: video.youTubeChannelName || null, + played: Boolean(snapshot.played), + positionMs: snapshot.positionMs, + attempts: hold.attempts, + lastAttemptAt: hold.last_attempt_at, + lastError: hold.last_error, + expiresAt: hold.expires_at, + }; + }); +} + +async function countHolds() { + const rows = await WatchStatusHold.findAll({ + where: { state: ACTIVE_STATES }, + attributes: ['state'], + raw: true, + }); + return { + pending: rows.filter((row) => row.state === HOLD_STATE.PENDING).length, + failed: rows.filter((row) => row.state === HOLD_STATE.FAILED).length, + }; +} + +/** + * Stop protecting a row: the user accepts the server's state. + * @returns {Promise<boolean>} false when the hold does not exist + */ +async function dismissHold(id) { + const hold = await WatchStatusHold.findByPk(id); + if (!hold) return false; + await hold.update({ state: HOLD_STATE.DISMISSED }); + return true; +} + +/** + * Give a failed restore another 14 days and make it due for a push now. + * @returns {Promise<Object|null>} The hold, or null when it does not exist + */ +async function reopenHold(id, now = new Date()) { + const hold = await WatchStatusHold.findByPk(id); + if (!hold) return null; + await hold.update({ + state: HOLD_STATE.PENDING, last_error: null, last_attempt_at: null, + expires_at: new Date(now.getTime() + HOLD_TTL_MS), + }); + return hold; +} + +module.exports = { + HOLD_STATE, + ACTIVE_STATES, + HOLD_TTL_MS, + isHoldable, + snapshotOf, + mergeSnapshots, + isAtLeast, + isNewer, + createHolds, + applyHolds, + releaseUnmovedHolds, + expireHolds, + listHolds, + describeHolds, + countHolds, + dismissHold, + reopenHold +}; diff --git a/server/modules/mediaServers/watchStatusPushBack.js b/server/modules/mediaServers/watchStatusPushBack.js new file mode 100644 index 000000000..2dacc39e1 --- /dev/null +++ b/server/modules/mediaServers/watchStatusPushBack.js @@ -0,0 +1,183 @@ +/** + * Push held watch state back to the media servers after a reorganize. Each + * pending hold's video is looked up on its server by its current path, that + * user's state of the item is read, and the snapshot (played, or the resume + * position) is written unless the item already shows as much or more. The hold itself ends only when a later sync sees the state on the + * item at the new path (watchStatusHolds.applyHolds), so a push the server + * ignored is simply retried. + * + * Servers index moved files on their own schedule, so a push finds no item + * until the library scan reaches it: the reorganize retries a few times + * shortly after it finishes, and every watch-status sync retries holds whose + * last attempt is old enough. Until then the stale item at the old path is + * what a lookup finds (same file name; between TV folders the same show and + * season folders too), so an item is only taken when it matches the new path + * better than the old one. An explicit retry pushes to whatever matches. + */ + +const { Op } = require('sequelize'); +const Video = require('../../models/video'); +const WatchStatusHold = require('../../models/watchstatushold'); +const configModule = require('../configModule'); +const serverRegistry = require('./serverRegistry'); +const logger = require('../../logger'); +const { describeHttpError } = require('./adapters/baseAdapter'); +const { HOLD_STATE } = require('./watchStatusHolds'); +const { isAtLeast, isLaterWatch } = require('./watchStateCompare'); + +// A sync retries a hold whose last push did not take at most this often. +const PUSH_RETRY_INTERVAL_MS = 60 * 60 * 1000; +// After a reorganize: pushes while the servers scan the moved files. +const FOLLOW_UP_DELAYS_MS = [60 * 1000, 5 * 60 * 1000, 15 * 60 * 1000]; +const NOT_INDEXED_MESSAGE = 'The media server has not indexed the moved file yet.'; +const NOT_CONFIGURED_MESSAGE = 'This media server is no longer configured.'; + +function errorMessage(err) { + if (err && err.isAxiosError) { + const described = describeHttpError(err); + return described.status ? `HTTP ${described.status}` : (described.message || 'Request failed'); + } + return (err && err.message) || 'Unknown error'; +} + +/** + * @param {Object} hold + * @param {string|null} error + * @param {Date} now + * @param {Object} [options] + * @param {boolean} [options.wrote] - The state was written to the server (not + * found already there): the sync reads the server's time stamp of it as an echo + */ +async function recordAttempt(hold, error, now, { wrote = false } = {}) { + await hold.update({ + attempts: hold.attempts + 1, + last_attempt_at: now, + last_error: error, + ...(wrote ? { last_pushed_at: now } : {}), + }); +} + +// The item at the video's new path, or null while the only match is the +// stale item at the old path: the same item answers for both paths and fits +// the old one at least as well. Two different items mean the new file is +// indexed, whether or not the old one is still listed. +function indexedItemId(matches, newPath, fromPath, { explicit }) { + const current = newPath ? matches.get(newPath) : null; + if (!current || !current.id) return null; + const previous = fromPath ? matches.get(fromPath) : null; + if (!explicit && previous && previous.id === current.id && previous.score >= current.score) return null; + return current.id; +} + +/** + * Push pending holds' state to their servers. + * + * A hold is pushed once; after a successful push it is pushed again only + * when a sync found the item at the new path without the state + * (watchStatusHolds.applyHolds records that as its last error). Pushing the + * snapshot blindly again could undo a watch made in between. + * + * @param {Object} [options] + * @param {number[]} [options.holdIds] - Only these holds (an explicit retry); pushed before or not + * @param {boolean} [options.onlyDue=true] - Skip holds attempted within the retry interval + * @param {Date} [options.now] + * @returns {Promise<{pushed: number, notIndexed: number, failed: number}>} + */ +async function pushPendingHolds({ holdIds = null, onlyDue = true, now = new Date() } = {}) { + const where = { state: HOLD_STATE.PENDING }; + if (holdIds) { + where.id = holdIds; + } else { + const retry = { last_error: { [Op.ne]: null } }; + if (onlyDue) retry.last_attempt_at = { [Op.lt]: new Date(now.getTime() - PUSH_RETRY_INTERVAL_MS) }; + where[Op.or] = [{ last_attempt_at: null }, retry]; + } + const holds = await WatchStatusHold.findAll({ where }); + const result = { pushed: 0, notIndexed: 0, failed: 0 }; + if (holds.length === 0) return result; + + const adapters = new Map(serverRegistry.getEnabledAdapters(configModule.getConfig()) + .map((adapter) => [adapter.serverType, adapter])); + const videos = await Video.findAll({ + where: { id: [...new Set(holds.map((hold) => hold.video_id))] }, + attributes: ['id', 'filePath'], + raw: true, + }); + const pathOf = new Map(videos.map((video) => [video.id, video.filePath])); + const fromPathOf = (hold) => JSON.parse(hold.snapshot).fromPath || null; + const explicit = Boolean(holdIds); + + const byServer = new Map(); + for (const hold of holds) { + if (!byServer.has(hold.server_type)) byServer.set(hold.server_type, []); + byServer.get(hold.server_type).push(hold); + } + + for (const [serverType, serverHolds] of byServer) { + const adapter = adapters.get(serverType); + if (!adapter) { + for (const hold of serverHolds) await recordAttempt(hold, NOT_CONFIGURED_MESSAGE, now); + result.failed += serverHolds.length; + continue; + } + let matches; + try { + const paths = [...new Set(serverHolds.flatMap((hold) => [pathOf.get(hold.video_id), fromPathOf(hold)]).filter(Boolean))]; + matches = await adapter.resolveItemMatchesByPaths(paths); + } catch (err) { + logger.warn({ err: err && err.isAxiosError ? describeHttpError(err) : err, serverType }, 'Could not look up moved files on the media server'); + for (const hold of serverHolds) await recordAttempt(hold, errorMessage(err), now); + result.failed += serverHolds.length; + continue; + } + for (const hold of serverHolds) { + const itemId = indexedItemId(matches, pathOf.get(hold.video_id), fromPathOf(hold), { explicit }); + if (!itemId) { + await recordAttempt(hold, NOT_INDEXED_MESSAGE, now); + result.notIndexed += 1; + continue; + } + try { + const snapshot = JSON.parse(hold.snapshot); + // Never write over a state at least as watched, or watched since the + // snapshot (the item was indexed and someone has watched it already). + const current = await adapter.getWatchState(itemId, hold.server_user_id); + const wrote = !current || !(isAtLeast(current, snapshot) || isLaterWatch(current, snapshot)); + if (wrote) await adapter.setWatchState(itemId, hold.server_user_id, snapshot); + await recordAttempt(hold, null, now, { wrote }); + result.pushed += 1; + } catch (err) { + await recordAttempt(hold, errorMessage(err), now); + result.failed += 1; + } + } + } + logger.info({ ...result }, 'Pushed held watch state to the media servers'); + return result; +} + +/** + * Push again a few times after a reorganize while the servers scan the + * moved files. Timers are unref'd so they never hold the process open. + * + * @param {Object} [options] + * @param {number[]} [options.delaysMs] + * @param {(fn: Function, ms: number) => Object} [options.schedule] + */ +function scheduleFollowUps({ delaysMs = FOLLOW_UP_DELAYS_MS, schedule = setTimeout } = {}) { + for (const delay of delaysMs) { + const timer = schedule(() => { + pushPendingHolds({ onlyDue: false }).catch((err) => { + logger.error({ err }, 'Watch state push after a reorganize failed'); + }); + }, delay); + if (timer && typeof timer.unref === 'function') timer.unref(); + } +} + +module.exports = { + PUSH_RETRY_INTERVAL_MS, + FOLLOW_UP_DELAYS_MS, + pushPendingHolds, + scheduleFollowUps +}; diff --git a/server/modules/mediaServers/watchStatusSync.js b/server/modules/mediaServers/watchStatusSync.js index 9cc165614..6407dce4e 100644 --- a/server/modules/mediaServers/watchStatusSync.js +++ b/server/modules/mediaServers/watchStatusSync.js @@ -11,6 +11,8 @@ const { WatchStateFetchError, } = require('./adapters/baseAdapter'); const { Video, VideoWatchStatus, MediaServerUser, WatchStatusSyncCursor } = require('../../models'); +const watchStatusHolds = require('./watchStatusHolds'); +const watchStatusPushBack = require('./watchStatusPushBack'); // Rows per bulk upsert statement; keeps a 10k-video library from producing one // giant INSERT. @@ -145,7 +147,8 @@ class WatchStatusSync { const { entries, users, historyCursor, completeUserIds } = await adapter.fetchWatchStates(opts); const listed = this._matchVideos(videos, entries, { requireCurrentCopy: !!completeUserIds }); const cleared = await this._clearedMatches(serverType, completeUserIds, videos, listed); - const matches = listed.concat(cleared); + // Rows a reorganize protects are not downgraded while it settles. + const matches = await watchStatusHolds.applyHolds(serverType, listed.concat(cleared)); const { rowsWritten, changedVideoIds } = await this._persist(serverType, matches); // Advance the durable cursor only after rows persisted, and only // when the adapter reports a safely-scanned-through time (null means @@ -183,6 +186,7 @@ class WatchStatusSync { } } summary.totals = { changed: changedIds.size }; + await this._settleHolds(); return summary; } catch (err) { const logErr = err && err.isAxiosError ? describeHttpError(err) : err; @@ -322,6 +326,17 @@ class WatchStatusSync { })); } + // After the servers were read: retry pushing held state the servers still + // lack, and mark holds unrestored for too long as failed. Never throws. + async _settleHolds() { + try { + await watchStatusHolds.expireHolds(); + await watchStatusPushBack.pushPendingHolds(); + } catch (err) { + logger.warn({ err }, 'Could not settle watch-state holds after the sync'); + } + } + // Returns the ids of videos with at least one new or changed row. Every row // is still written, so last_synced_at stays current. async _persist(serverType, matches) { diff --git a/server/modules/reorganize/__tests__/changeContext.test.js b/server/modules/reorganize/__tests__/changeContext.test.js new file mode 100644 index 000000000..4047c5a91 --- /dev/null +++ b/server/modules/reorganize/__tests__/changeContext.test.js @@ -0,0 +1,144 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn() })); +jest.mock('../../../models/channel', () => ({ findOne: jest.fn() })); +jest.mock('../../subfolderModule', () => ({ getAll: jest.fn() })); +jest.mock('../../tvShows/libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); +jest.mock('../../tvShows/layoutGuards', () => ({ + guardError: (message, status) => Object.assign(new Error(message), { status }), + assertNoMp3Users: jest.fn(), + usersOfFolder: jest.fn().mockResolvedValue({ channels: [], playlists: [] }), + usersOfGlobalDefault: jest.fn().mockResolvedValue({ channels: [], playlists: [] }), +})); +jest.mock('../../tvShows/channelLayout', () => ({ + MESSAGES: { mp3: 'TV shows are video-only.' }, + resolveLayoutTarget: jest.fn(), +})); + +const TV_FOLDERS = new Set(['tv shows']); + +describe('reorganize changeContext', () => { + let changeContext; + let configModule; + let Channel; + let subfolderModule; + let layoutGuards; + let channelLayout; + + const channel = (overrides = {}) => ({ + channel_id: 'UC1', title: 'Chan', sub_folder: 'Kids', audio_format: null, ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + configModule = require('../../configModule'); + Channel = require('../../../models/channel'); + subfolderModule = require('../../subfolderModule'); + layoutGuards = require('../../tvShows/layoutGuards'); + channelLayout = require('../../tvShows/channelLayout'); + require('../../tvShows/libraryLayouts').getLayoutResolver.mockResolvedValue( + (folder) => (TV_FOLDERS.has(String(folder).toLowerCase()) ? 'tv' : 'videos') + ); + configModule.getDefaultSubfolder.mockReturnValue('GlobalDefault'); + subfolderModule.getAll.mockResolvedValue(['__Kids', '__TV Shows', '__GlobalDefault']); + changeContext = require('../changeContext'); + }); + + describe('channel changes', () => { + it('resolves a move from a Videos folder to a TV folder', async () => { + Channel.findOne.mockResolvedValue(channel()); + + const context = await changeContext.resolveChange({ type: 'channel', channelId: 'UC1', subFolder: 'TV Shows' }); + + expect(context).toMatchObject({ type: 'channel', fromFolder: 'Kids', toFolder: 'TV Shows', label: 'Chan' }); + expect(context.stored).toEqual({ type: 'channel', channelId: 'UC1', subFolder: 'TV Shows', previousSubFolder: 'Kids' }); + expect(context.folderAfter({ channel_id: 'UC1', sub_folder: 'Kids' })).toBe('TV Shows'); + expect(context.folderAfter({ channel_id: 'UC2', sub_folder: '##USE_GLOBAL_DEFAULT##' })).toBe('GlobalDefault'); + }); + + it('resolves the layout toggle through the folder it would pick', async () => { + Channel.findOne.mockResolvedValue(channel()); + channelLayout.resolveLayoutTarget.mockResolvedValue('TV Shows'); + + const context = await changeContext.resolveChange({ type: 'channelLayout', channelId: 'UC1', layout: 'tv' }); + + expect(channelLayout.resolveLayoutTarget).toHaveBeenCalledWith(expect.objectContaining({ layout: 'tv', folder: undefined })); + expect(context.toFolder).toBe('TV Shows'); + }); + + it('refuses a move between two Videos folders', async () => { + Channel.findOne.mockResolvedValue(channel()); + + await expect(changeContext.resolveChange({ type: 'channel', channelId: 'UC1', subFolder: 'Other' })) + .rejects.toMatchObject({ status: 400 }); + }); + + it('refuses a change that keeps the channel in its folder', async () => { + Channel.findOne.mockResolvedValue(channel({ sub_folder: 'TV Shows' })); + + await expect(changeContext.resolveChange({ type: 'channel', channelId: 'UC1', subFolder: 'tv shows' })) + .rejects.toMatchObject({ status: 400 }); + }); + + it('refuses an MP3 channel moving to a TV folder', async () => { + Channel.findOne.mockResolvedValue(channel({ audio_format: 'mp3_only' })); + + await expect(changeContext.resolveChange({ type: 'channel', channelId: 'UC1', subFolder: 'TV Shows' })) + .rejects.toMatchObject({ status: 400, message: 'TV shows are video-only.' }); + }); + + it('returns 404 for an unknown channel', async () => { + Channel.findOne.mockResolvedValue(null); + + await expect(changeContext.resolveChange({ type: 'channel', channelId: 'UCX', subFolder: 'TV Shows' })) + .rejects.toMatchObject({ status: 404 }); + }); + + it('refuses an invalid folder name', async () => { + Channel.findOne.mockResolvedValue(channel()); + + await expect(changeContext.resolveChange({ type: 'channel', channelId: 'UC1', subFolder: '../etc' })) + .rejects.toMatchObject({ status: 400 }); + }); + }); + + describe('folder layout changes', () => { + it('resolves a folder switching to TV', async () => { + const context = await changeContext.resolveChange({ type: 'folderLayout', folder: 'Kids', layout: 'tv' }); + + expect(context.stored).toEqual({ type: 'folderLayout', folder: 'Kids', layout: 'tv', previousLayout: 'videos' }); + expect(context.layoutAfter('kids')).toBe('tv'); + expect(context.layoutAfter('Other')).toBe('videos'); + expect(layoutGuards.assertNoMp3Users).toHaveBeenCalled(); + }); + + it('returns 404 for an unknown subfolder', async () => { + await expect(changeContext.resolveChange({ type: 'folderLayout', folder: 'Nope', layout: 'tv' })) + .rejects.toMatchObject({ status: 404 }); + }); + + it('refuses a layout the folder already has', async () => { + await expect(changeContext.resolveChange({ type: 'folderLayout', folder: 'TV Shows', layout: 'tv' })) + .rejects.toMatchObject({ status: 400 }); + }); + }); + + describe('default subfolder changes', () => { + it('resolves a default moving to a TV folder', async () => { + const context = await changeContext.resolveChange({ type: 'defaultSubfolder', value: 'TV Shows' }); + + expect(context.stored).toEqual({ type: 'defaultSubfolder', value: 'TV Shows', previousValue: 'GlobalDefault' }); + expect(context.folderAfter({ sub_folder: '##USE_GLOBAL_DEFAULT##' })).toBe('TV Shows'); + expect(context.folderAfter({ sub_folder: 'Kids' })).toBe('Kids'); + }); + + it('refuses a default with the same layout', async () => { + await expect(changeContext.resolveChange({ type: 'defaultSubfolder', value: 'Kids' })) + .rejects.toMatchObject({ status: 400 }); + }); + }); + + it('refuses an unknown change type', async () => { + await expect(changeContext.resolveChange({ type: 'rename' })).rejects.toMatchObject({ status: 400 }); + }); +}); diff --git a/server/modules/reorganize/__tests__/changeScope.test.js b/server/modules/reorganize/__tests__/changeScope.test.js new file mode 100644 index 000000000..21de5abbd --- /dev/null +++ b/server/modules/reorganize/__tests__/changeScope.test.js @@ -0,0 +1,124 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ directoryPath: '/data' })); +jest.mock('../../../models/video', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); +jest.mock('../../tvShows/channelFolders', () => ({ resolveChannelDirectory: jest.fn() })); + +const video = (id, youtubeId, filePath, overrides = {}) => ({ + id, youtubeId, filePath, audioFilePath: null, channel_id: 'UC1', youTubeVideoName: `Video ${id}`, + youTubeChannelName: 'Chan', removed: false, ...overrides, +}); + +const CHANNEL = { channel_id: 'UC1', title: 'Chan', folder_name: 'Chan', sub_folder: 'Kids' }; + +describe('reorganize changeScope', () => { + let changeScope; + let Video; + let Channel; + let VideoClassification; + let channelFolders; + + const context = (overrides = {}) => ({ + type: 'channel', + channel: CHANNEL, + layoutBefore: (folder) => (folder === 'TV' ? 'tv' : 'videos'), + folderBefore: (row) => row.sub_folder || '', + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + Video = require('../../../models/video'); + Channel = require('../../../models/channel'); + VideoClassification = require('../../../models/videoclassification'); + channelFolders = require('../../tvShows/channelFolders'); + Channel.findAll.mockResolvedValue([CHANNEL]); + VideoClassification.findAll.mockResolvedValue([]); + channelFolders.resolveChannelDirectory.mockResolvedValue({ layout: 'videos', dir: '/data/__Kids/Chan' }); + changeScope = require('../changeScope'); + }); + + describe('a channel\'s videos', () => { + it('selects videos by channel id and by path, owned by the channel', async () => { + Video.findAll.mockResolvedValue([ + video(1, 'aaaaaaaaaaa', '/data/__Kids/Chan/A [aaaaaaaaaaa].mp4'), + video(2, 'bbbbbbbbbbb', '/data/__Kids/Chan/B [bbbbbbbbbbb].mp4', { channel_id: 'UCVEVO' }), + ]); + + const { subjects } = await changeScope.selectSubjects(context()); + + expect(subjects.map((subject) => [subject.video.id, subject.ownerChannelId, subject.libraryFolder, subject.currentLayout])) + .toEqual([[1, 'UC1', 'Kids', 'videos'], [2, 'UC1', 'Kids', 'videos']]); + }); + + it('drops prefix matches that only matched through LIKE wildcards', async () => { + Video.findAll.mockResolvedValue([ + video(3, 'ccccccccccc', '/data/__Kids/ChanXother/C [ccccccccccc].mp4', { channel_id: 'UCOTHER' }), + ]); + + const { subjects } = await changeScope.selectSubjects(context()); + + expect(subjects).toEqual([]); + }); + + it('leaves out an episode that belongs to another channel\'s show', async () => { + Video.findAll.mockResolvedValue([video(4, 'ddddddddddd', '/data/__TV/Other/Season 2024/S2024E01 [ddddddddddd].mp4')]); + VideoClassification.findAll.mockImplementation(async ({ where }) => (where.youtube_id + ? [{ youtube_id: 'ddddddddddd', channel_id: 'UCOTHER' }] + : [])); + + const { subjects } = await changeScope.selectSubjects(context()); + + expect(subjects).toEqual([]); + }); + + it('skips files outside the downloads folder', async () => { + Video.findAll.mockResolvedValue([video(5, 'eeeeeeeeeee', '/elsewhere/E [eeeeeeeeeee].mp4')]); + + const { subjects } = await changeScope.selectSubjects(context()); + + expect(subjects).toEqual([]); + }); + }); + + describe('a library folder\'s videos', () => { + const folderContext = context({ type: 'folderLayout', folder: 'Kids', channel: undefined }); + + it('selects every video in the folder and finds owners by channel folder', async () => { + Video.findAll.mockResolvedValue([ + video(1, 'aaaaaaaaaaa', '/data/__Kids/Chan/A [aaaaaaaaaaa].mp4', { channel_id: 'UCVEVO' }), + video(2, 'bbbbbbbbbbb', '/data/__Kids/Untracked/B [bbbbbbbbbbb].mp4', { channel_id: 'UCUNTRACKED' }), + video(3, 'ccccccccccc', '/data/__Other/Chan/C [ccccccccccc].mp4'), + ]); + + const { subjects } = await changeScope.selectSubjects(folderContext); + + expect(subjects.map((subject) => [subject.video.id, subject.ownerChannelId])).toEqual([[1, 'UC1'], [2, 'UCUNTRACKED']]); + expect(subjects[1].ownerChannel).toBeNull(); + }); + + it('takes an episode\'s owner from its classification in a TV folder', async () => { + const tvContext = context({ type: 'folderLayout', folder: 'TV', channel: undefined }); + Video.findAll.mockResolvedValue([video(4, 'ddddddddddd', '/data/__TV/Show/Season 2024/S2024E01 [ddddddddddd].mp4', { channel_id: 'UCVEVO' })]); + VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'ddddddddddd', channel_id: 'UC1' }]); + + const { subjects } = await changeScope.selectSubjects(tvContext); + + expect(subjects[0].ownerChannelId).toBe('UC1'); + expect(subjects[0].currentLayout).toBe('tv'); + }); + }); + + it('selects the videos of every channel that follows the default subfolder', async () => { + const onDefault = { ...CHANNEL, sub_folder: '##USE_GLOBAL_DEFAULT##' }; + Channel.findAll.mockResolvedValue([onDefault, { ...CHANNEL, channel_id: 'UC2', sub_folder: 'Kids' }]); + Video.findAll.mockResolvedValue([video(1, 'aaaaaaaaaaa', '/data/__Kids/Chan/A [aaaaaaaaaaa].mp4')]); + + const { subjects } = await changeScope.selectSubjects(context({ type: 'defaultSubfolder', channel: undefined })); + + expect(channelFolders.resolveChannelDirectory).toHaveBeenCalledTimes(1); + expect(subjects.map((subject) => subject.video.id)).toEqual([1]); + }); +}); diff --git a/server/modules/reorganize/__tests__/destinationPlanner.test.js b/server/modules/reorganize/__tests__/destinationPlanner.test.js new file mode 100644 index 000000000..52344260c --- /dev/null +++ b/server/modules/reorganize/__tests__/destinationPlanner.test.js @@ -0,0 +1,276 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ getConfig: jest.fn(() => ({})), directoryPath: null })); +jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); +jest.mock('../../videoInfoStore', () => ({ readInfoOrFallback: jest.fn() })); +jest.mock('../movieNameRenderer', () => ({ renderMovieNames: jest.fn() })); +jest.mock('../showPlanner', () => { + const path = require('path'); + return { + plannedShowDirectory: (show) => path.join(require('../../configModule').directoryPath, + show.libraryFolder ? `__${show.libraryFolder}` : '', show.folderName), + }; +}); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ID = 'abcdefghijk'; +// 2024-03-15 12:00:00 UTC +const TIMESTAMP = 1710504000; + +describe('reorganize destinationPlanner', () => { + let planner; + let configModule; + let VideoClassification; + let videoInfoStore; + let movieNameRenderer; + let root; + + const touch = (relativePath, content = 'x') => { + const full = path.join(root, relativePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + return full; + }; + + const show = { ownerChannelId: 'UC1', showId: null, name: 'Chan', libraryFolder: 'TV', folderName: 'Chan' }; + const owner = { channel_id: 'UC1', folder_name: 'Chan', uploader: 'Chan', skip_video_folder: null }; + const tvContext = { type: 'channel', folderBefore: () => 'Kids' }; + + const subjectFor = (filePath, overrides = {}) => ({ + video: { + id: 1, youtubeId: ID, filePath, audioFilePath: null, youTubeVideoName: 'Big Build', youTubeChannelName: 'Chan', + last_downloaded_at: null, ...overrides.video, + }, + ownerChannelId: 'UC1', + ownerChannel: owner, + libraryFolder: 'Kids', + currentLayout: 'videos', + ...overrides.subject, + }); + + const planTo = (subject, target, shows = new Map([['UC1', show]])) => planner.planDestinations({ + subjects: [subject], context: tvContext, targets: new Map([[subject.video.id, target]]), shows, + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'reorganize-plan-')); + configModule = require('../../configModule'); + configModule.directoryPath = root; + VideoClassification = require('../../../models/videoclassification'); + VideoClassification.findAll.mockResolvedValue([]); + videoInfoStore = require('../../videoInfoStore'); + videoInfoStore.readInfoOrFallback.mockResolvedValue({ id: ID, title: 'Big Build', timestamp: TIMESTAMP }); + movieNameRenderer = require('../movieNameRenderer'); + movieNameRenderer.renderMovieNames.mockResolvedValue(new Map()); + planner = require('../destinationPlanner'); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + describe('Videos to TV', () => { + it('renames the video\'s files to the episode stem in its season folder', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build - ${ID}/Chan - Big Build [${ID}].mp4`); + touch(`__Kids/Chan/Chan - Big Build - ${ID}/Chan - Big Build [${ID}].jpg`); + touch(`__Kids/Chan/Chan - Big Build - ${ID}/Chan - Big Build [${ID}].en.srt`); + touch(`__Kids/Chan/Chan - Big Build - ${ID}/Chan - Big Build [${ID}].nfo`); + + const { items, problems } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + + expect(problems).toEqual([]); + const seasonDir = path.join(root, '__TV', 'Chan', 'Season 2024'); + const stem = `S2024E03151200 - Big Build [${ID}]`; + expect(items[0].files.map((file) => file.to).sort()).toEqual([ + path.join(seasonDir, `${stem}.en.srt`), path.join(seasonDir, `${stem}.jpg`), path.join(seasonDir, `${stem}.mp4`), + ]); + expect(items[0].nfoSources).toEqual([videoPath.replace(/\.mp4$/, '.nfo')]); + expect(items[0].newVideoPath).toBe(path.join(seasonDir, `${stem}.mp4`)); + expect(items[0].classification).toMatchObject({ season: 2024, episode: 3151200, source: 'date', fileStem: stem }); + expect(items[0].flags).toContain('movie-tags'); + }); + + it('keeps the code of a file named by the Plex TV Series preset', async () => { + const videoPath = touch(`Chan/S2024E03151230 Big Build [${ID}].mp4`); + + const { items } = await planTo(subjectFor(videoPath, { subject: { libraryFolder: '' } }), { libraryFolder: 'TV', layout: 'tv' }); + + expect(items[0].classification).toMatchObject({ episode: 3151230, source: 'adopted' }); + expect(items[0].flags).toContain('adopted'); + }); + + it('reuses a number the video already holds in the show', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + VideoClassification.findAll.mockResolvedValue([{ + youtube_id: ID, show_id: 5, status: 'assigned', season: 2024, episode: 3151201, source: 'date', + timestamp_source: 'timestamp', episode_title: 'Old title', file_stem: `S2024E03151201 - Old title [${ID}]`, + }]); + + const { items } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }, + new Map([['UC1', { ...show, showId: 5 }]])); + + expect(items[0].classification).toMatchObject({ episode: 3151201, fileStem: `S2024E03151201 - Old title [${ID}]` }); + }); + + it('numbers by the download time when there is no upload date', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + videoInfoStore.readInfoOrFallback.mockResolvedValue({ id: ID, title: 'Big Build' }); + + const { items } = await planTo( + subjectFor(videoPath, { video: { last_downloaded_at: '2025-01-02T03:04:00Z' } }), + { libraryFolder: 'TV', layout: 'tv' } + ); + + expect(items[0].classification).toMatchObject({ season: 2025, episode: 1020304, timestampSource: null }); + expect(items[0].flags).toContain('download-time'); + }); + + it('reports a video with no date at all', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + videoInfoStore.readInfoOrFallback.mockResolvedValue({ id: ID, title: 'Big Build' }); + + const { items, problems } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + + expect(items).toEqual([]); + expect(problems).toEqual([expect.objectContaining({ youtubeId: ID, problem: 'no-date' })]); + }); + }); + + describe('TV to Videos', () => { + const tvSubject = (videoPath) => subjectFor(videoPath, { subject: { libraryFolder: 'TV', currentLayout: 'tv' } }); + + beforeEach(() => { + movieNameRenderer.renderMovieNames.mockResolvedValue(new Map([[ID, { + channelFolder: 'Uploader', videoFolder: `Chan - Big Build - ${ID}`, stem: `Chan - Big Build [${ID}]`, + }]])); + }); + + it('moves the episode to a per-video folder in the channel\'s folder', async () => { + const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + + const { items } = await planTo(tvSubject(videoPath), { libraryFolder: 'Kids', layout: 'videos' }); + + expect(items[0].newVideoPath).toBe(path.join(root, '__Kids', 'Chan', `Chan - Big Build - ${ID}`, `Chan - Big Build [${ID}].mp4`)); + expect(items[0].classification).toBeNull(); + }); + + it('moves the episode flat into the channel folder when the channel saves flat', async () => { + const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + const subject = tvSubject(videoPath); + subject.ownerChannel = { ...owner, skip_video_folder: true }; + + const { items } = await planTo(subject, { libraryFolder: 'Kids', layout: 'videos' }); + + expect(items[0].newVideoPath).toBe(path.join(root, '__Kids', 'Chan', `Chan - Big Build [${ID}].mp4`)); + }); + + it('uses the rendered channel folder for an untracked channel', async () => { + const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + const subject = tvSubject(videoPath); + subject.ownerChannel = null; + + const { items } = await planTo(subject, { libraryFolder: '', layout: 'videos' }); + + expect(items[0].newVideoPath.startsWith(path.join(root, 'Uploader'))).toBe(true); + }); + + it('reports a video yt-dlp could not name', async () => { + movieNameRenderer.renderMovieNames.mockResolvedValue(new Map()); + const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + + const { problems } = await planTo(tvSubject(videoPath), { libraryFolder: 'Kids', layout: 'videos' }); + + expect(problems).toEqual([expect.objectContaining({ problem: 'no-name' })]); + }); + + it('uses the rendered channel folder, never the raw uploader name, when the channel has no folder name', async () => { + const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + const subject = tvSubject(videoPath); + subject.ownerChannel = { ...owner, folder_name: null, uploader: '24/7 News' }; + + const { items } = await planTo(subject, { libraryFolder: 'Kids', layout: 'videos' }); + + expect(items[0].newVideoPath.startsWith(path.join(root, '__Kids', 'Uploader'))).toBe(true); + }); + + it('reports a destination that would leave the downloads folder', async () => { + movieNameRenderer.renderMovieNames.mockResolvedValue(new Map([[ID, { + channelFolder: '..', videoFolder: `Chan - Big Build - ${ID}`, stem: `Chan - Big Build [${ID}]`, + }]])); + const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + const subject = tvSubject(videoPath); + subject.ownerChannel = null; + + const { items, problems } = await planTo(subject, { libraryFolder: '', layout: 'videos' }); + + expect(items).toEqual([]); + expect(problems).toEqual([expect.objectContaining({ problem: 'unsafe-name' })]); + }); + }); + + it('reads a folder once however many videos it holds', async () => { + const second = 'bcdefghijkl'; + const first = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + touch(`__Kids/Chan/Chan - Big Build [${ID}].jpg`); + const other = touch(`__Kids/Chan/Chan - Other [${second}].mp4`); + const subjects = [ + subjectFor(first), + subjectFor(other, { video: { id: 2, youtubeId: second, youTubeVideoName: 'Other' } }), + ]; + videoInfoStore.readInfoOrFallback.mockImplementation(async (video) => ({ id: video.youtubeId, title: video.youTubeVideoName, timestamp: TIMESTAMP })); + const readdir = jest.spyOn(fs.promises, 'readdir'); + + const { items } = await planner.planDestinations({ + subjects, context: tvContext, targets: new Map([[1, { libraryFolder: 'TV', layout: 'tv' }], [2, { libraryFolder: 'TV', layout: 'tv' }]]), shows: new Map([['UC1', show]]), + }); + + expect(readdir).toHaveBeenCalledTimes(1); + expect(items.map((item) => item.files.map((file) => path.basename(file.from)).sort())).toEqual([ + [`Chan - Big Build [${ID}].jpg`, `Chan - Big Build [${ID}].mp4`], + [`Chan - Other [${second}].mp4`], + ]); + }); + + it('reports a video whose file is missing', async () => { + const { items, problems } = await planTo(subjectFor(path.join(root, '__Kids/Chan/Gone [abcdefghijk].mp4')), + { libraryFolder: 'TV', layout: 'tv' }); + + expect(items).toEqual([]); + expect(problems).toEqual([expect.objectContaining({ problem: 'missing' })]); + }); + + it('reports a destination held by another file but still plans the move', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`, 'other'); + + const { items, problems } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + + expect(items).toHaveLength(1); + expect(problems).toEqual([expect.objectContaining({ problem: 'collision' })]); + }); + + it('leaves out leftovers of interrupted downloads and moves', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4.part`); + touch(`__Kids/Chan/Chan - Big Build [${ID}].jpg.reorganize.part`); + + const { items } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + + expect(items[0].files).toHaveLength(1); + }); + + it('counts a video already where it belongs as unchanged', async () => { + const stem = `S2024E03151200 - Big Build [${ID}]`; + const videoPath = touch(`__TV/Chan/Season 2024/${stem}.mp4`); + + const result = await planTo(subjectFor(videoPath, { subject: { libraryFolder: 'TV', currentLayout: 'tv' } }), + { libraryFolder: 'TV', layout: 'tv' }); + + expect(result.items).toEqual([]); + expect(result.unchanged).toBe(1); + }); +}); diff --git a/server/modules/reorganize/__tests__/followUp.test.js b/server/modules/reorganize/__tests__/followUp.test.js new file mode 100644 index 000000000..c62133d80 --- /dev/null +++ b/server/modules/reorganize/__tests__/followUp.test.js @@ -0,0 +1,112 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ directoryPath: '/data', getConfig: jest.fn(() => ({})) })); +jest.mock('../../plexModule', () => ({ refreshLibrariesForSubfolders: jest.fn() })); +jest.mock('../../m3uGenerator', () => ({ generateChannelM3U: jest.fn(), generatePlaylistM3U: jest.fn() })); +jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/tvshow', () => ({ findByPk: jest.fn() })); +jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/playlistvideo', () => ({ findAll: jest.fn() })); +jest.mock('../../sidecarWriter', () => ({ writeShowMetadata: jest.fn(), writeFolderArt: jest.fn() })); +jest.mock('../../mediaServers/mediaServerSync', () => ({ syncPlaylist: jest.fn() })); +jest.mock('../../mediaServers/serverRegistry', () => ({ getEnabledAdapters: jest.fn(() => []) })); +jest.mock('../../mediaServers/watchStatusPushBack', () => ({ scheduleFollowUps: jest.fn() })); +jest.mock('../../filesystem/directoryManager', () => ({ cleanupEmptyChannelDirectory: jest.fn() })); +jest.mock('../../filesystem/showFolderCleanup', () => { + const actual = jest.requireActual('../../filesystem/showFolderCleanup'); + return { ...actual, cleanupOrphanShowFolder: jest.fn() }; +}); +jest.mock('../../tvShows/channelFolders', () => ({ showDirectory: (show) => `/data/__TV/${show.folder_name}` })); + +const toTv = { + youtubeId: 'abcdefghijk', + channelId: 'UC1', + plan: { + oldVideoPath: '/data/__Kids/Chan/Chan - T - abcdefghijk/Chan - T [abcdefghijk].mp4', + newVideoPath: '/data/__TV/Chan/Season 2024/S2024E01 - T [abcdefghijk].mp4', + layout: 'tv', fromLayout: 'videos', libraryFolder: 'TV', fromLibraryFolder: 'Kids', + }, + classification: { ownerChannelId: 'UC1' }, +}; +const toVideos = { + youtubeId: 'bbbbbbbbbbb', + channelId: 'UC2', + plan: { + oldVideoPath: '/data/__TV/Other/Season 2024/S2024E02 - U [bbbbbbbbbbb].mp4', + newVideoPath: '/data/Other/Other - U [bbbbbbbbbbb].mp4', + layout: 'videos', fromLayout: 'tv', libraryFolder: '', fromLibraryFolder: 'TV', + }, + classification: null, +}; + +describe('reorganize followUp', () => { + let followUp; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + require('../../../models/tvshow').findByPk.mockResolvedValue({ id: 5, folder_name: 'Chan' }); + require('../../../models/channel').findAll.mockResolvedValue([{ channel_id: 'UC2' }]); + require('../../../models/playlistvideo').findAll.mockResolvedValue([{ playlist_id: 'PL1' }]); + require('../../../models/playlist').findAll.mockResolvedValue([{ id: 4 }]); + followUp = require('../followUp'); + }); + + it('finds the channel or show folder a path sits in', () => { + expect(followUp.rootFolderOf('/data/__TV/Chan/Season 2024/a.mp4')).toBe('/data/__TV/Chan'); + expect(followUp.rootFolderOf('/data/Chan/a.mp4')).toBe('/data/Chan'); + expect(followUp.rootFolderOf('/elsewhere/a.mp4')).toBeNull(); + }); + + describe('finishFiles', () => { + it('writes the metadata of shows that received episodes', async () => { + await followUp.finishFiles({ items: [toTv], shows: [{ ownerChannelId: 'UC1', showId: 5, plot: 'About' }] }); + + expect(require('../../sidecarWriter').writeShowMetadata).toHaveBeenCalledWith({ + show: { id: 5, folder_name: 'Chan' }, showDir: '/data/__TV/Chan', plot: 'About', + }); + }); + + it('writes channel art where videos arrived and removes the folders they left', async () => { + await followUp.finishFiles({ items: [toTv, toVideos], shows: [] }); + + expect(require('../../sidecarWriter').writeFolderArt).toHaveBeenCalledWith({ channelId: 'UC2', folderPath: '/data/Other' }); + expect(require('../../filesystem/directoryManager').cleanupEmptyChannelDirectory) + .toHaveBeenCalledWith('/data/__Kids/Chan', '/data', { includeIgnorableFiles: true }); + expect(require('../../filesystem/showFolderCleanup').cleanupOrphanShowFolder).toHaveBeenCalledWith('/data/__TV/Other'); + }); + + it('regenerates the .m3u of channels now saving to a Videos folder', async () => { + await followUp.finishFiles({ items: [toVideos], shows: [] }); + + expect(require('../../m3uGenerator').generateChannelM3U).toHaveBeenCalledWith('UC2'); + }); + + it('keeps going when one step fails', async () => { + require('../../filesystem/directoryManager').cleanupEmptyChannelDirectory.mockRejectedValue(new Error('busy')); + + await expect(followUp.finishFiles({ items: [toTv, toVideos], shows: [] })).resolves.toBeUndefined(); + expect(require('../../m3uGenerator').generateChannelM3U).toHaveBeenCalled(); + }); + }); + + describe('finishServers', () => { + it('re-syncs playlists with moved videos, refreshes both libraries and schedules push-back', async () => { + const adapter = { serverType: 'jellyfin', triggerLibraryScan: jest.fn() }; + require('../../mediaServers/serverRegistry').getEnabledAdapters.mockReturnValue([{ serverType: 'plex' }, adapter]); + + await followUp.finishServers({ items: [toTv] }); + + expect(require('../../m3uGenerator').generatePlaylistM3U).toHaveBeenCalledWith(4); + expect(require('../../mediaServers/mediaServerSync').syncPlaylist).toHaveBeenCalledWith(4); + expect(require('../../plexModule').refreshLibrariesForSubfolders).toHaveBeenCalledWith(['Kids', 'TV']); + expect(adapter.triggerLibraryScan).toHaveBeenCalled(); + expect(require('../../mediaServers/watchStatusPushBack').scheduleFollowUps).toHaveBeenCalled(); + }); + + it('schedules push-back after a resumed run too, whose holds predate the restart', async () => { + await followUp.finishServers({ items: [toTv] }); + + expect(require('../../mediaServers/watchStatusPushBack').scheduleFollowUps).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/modules/reorganize/__tests__/index.test.js b/server/modules/reorganize/__tests__/index.test.js new file mode 100644 index 000000000..7e98f6883 --- /dev/null +++ b/server/modules/reorganize/__tests__/index.test.js @@ -0,0 +1,53 @@ +jest.mock('../operationRunner', () => ({ + initialize: jest.fn(), preview: jest.fn(), start: jest.fn(), retry: jest.fn(), recover: jest.fn(), + EXCLUSIVE_TASKS: { videoRescanFrequency: 'The filesystem rescan' }, +})); +jest.mock('../operationStore', () => ({ getOperationView: jest.fn(), unmovedForChannel: jest.fn() })); + +describe('reorganize module', () => { + let reorganize; + let lock; + let operationStore; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + reorganize = require('..'); + lock = require('../reorganizeLock'); + operationStore = require('../operationStore'); + }); + + it('blocks only the tasks that touch downloads while a reorganize runs', () => { + const scheduledTaskManager = { setExclusiveBlocker: jest.fn() }; + reorganize.initialize({ scheduledTaskManager }); + const blockerFor = scheduledTaskManager.setExclusiveBlocker.mock.calls[0][0]; + + expect(blockerFor('videoRescanFrequency')).toBeNull(); + const token = lock.acquire({ label: 'Chan' }); + expect(blockerFor('videoRescanFrequency')).toMatchObject({ reason: 'reorganizing' }); + expect(blockerFor('channelDownloadFrequency')).toBeNull(); + lock.release(token); + }); + + it('reports a reorganize that is still planning as starting', async () => { + const token = lock.acquire({ label: 'Chan' }); + + await expect(reorganize.getActive()).resolves.toEqual({ id: null, label: 'Chan', status: 'starting' }); + lock.release(token); + }); + + it('reports no active reorganize when the lock is free', async () => { + await expect(reorganize.getActive()).resolves.toBeNull(); + }); + + it('describes a channel\'s reorganize state', async () => { + operationStore.unmovedForChannel.mockResolvedValue({ operationId: 3, failed: 1, status: 'partial' }); + const token = lock.acquire({ label: 'Chan' }); + lock.setScope(token, { channelIds: ['UC1'] }); + + await expect(reorganize.channelState('UC1')).resolves.toEqual({ + running: true, unmoved: { operationId: 3, failed: 1, status: 'partial' }, + }); + lock.release(token); + }); +}); diff --git a/server/modules/reorganize/__tests__/itemExecutor.test.js b/server/modules/reorganize/__tests__/itemExecutor.test.js new file mode 100644 index 000000000..cea210fd4 --- /dev/null +++ b/server/modules/reorganize/__tests__/itemExecutor.test.js @@ -0,0 +1,296 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ directoryPath: null })); +jest.mock('../../../models/video', () => ({ findByPk: jest.fn(), update: jest.fn() })); +jest.mock('../../../models/videoclassification', () => ({ findByPk: jest.fn(), create: jest.fn() })); +jest.mock('../../videoInfoStore', () => ({ + readInfoOrFallback: jest.fn().mockResolvedValue({ id: 'abcdefghijk', title: 'Big Build' }), + rewriteActualPaths: jest.fn().mockResolvedValue(true), +})); +jest.mock('../../sidecarWriter', () => ({ writeVideoSidecars: jest.fn().mockResolvedValue([]) })); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ID = 'abcdefghijk'; +const STEM = `S2024E03151200 - Big Build [${ID}]`; + +describe('reorganize itemExecutor', () => { + let executor; + let configModule; + let Video; + let VideoClassification; + let videoInfoStore; + let sidecarWriter; + let root; + let sourceDir; + let seasonDir; + + const at = (...segments) => path.join(root, ...segments); + const touch = (filePath, content = 'x') => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + return filePath; + }; + + const classification = { + ownerChannelId: 'UC1', showTitle: 'Chan', season: 2024, episode: 3151200, source: 'date', + timestampSource: 'timestamp', episodeTitle: 'Big Build', fileStem: STEM, + }; + + function toTvRecord() { + const oldVideo = path.join(sourceDir, `Chan - Big Build [${ID}].mp4`); + const oldThumb = path.join(sourceDir, `Chan - Big Build [${ID}].jpg`); + const plan = { + files: [ + { from: oldVideo, to: path.join(seasonDir, `${STEM}.mp4`) }, + { from: oldThumb, to: path.join(seasonDir, `${STEM}.jpg`) }, + ], + nfoSources: [path.join(sourceDir, `Chan - Big Build [${ID}].nfo`)], + sourceDirs: [sourceDir], + oldVideoPath: oldVideo, + newVideoPath: path.join(seasonDir, `${STEM}.mp4`), + oldAudioPath: null, + newAudioPath: null, + layout: 'tv', + fromLayout: 'videos', + }; + return { youtube_id: ID, video_id: 1, files: JSON.stringify(plan), classification: JSON.stringify(classification), plan }; + } + + const videoRow = (filePath) => ({ + id: 1, youtubeId: ID, filePath, audioFilePath: null, fileSize: 1, audioFileSize: null, + video_resolution: null, last_downloaded_at: null, removed: false, + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'reorganize-item-')); + sourceDir = at('__Kids', 'Chan', `Chan - Big Build - ${ID}`); + seasonDir = at('__TV', 'Chan', 'Season 2024'); + configModule = require('../../configModule'); + configModule.directoryPath = root; + Video = require('../../../models/video'); + Video.update.mockResolvedValue([1]); + VideoClassification = require('../../../models/videoclassification'); + VideoClassification.findByPk.mockResolvedValue(null); + videoInfoStore = require('../../videoInfoStore'); + sidecarWriter = require('../../sidecarWriter'); + executor = require('../itemExecutor'); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('moves a movie-style video into its season folder as an episode', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + touch(record.plan.files[1].from, 'thumb'); + touch(record.plan.nfoSources[0], '<movie/>'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await executor.executeItem(record, { showIdFor: () => 5 }); + + expect(fs.readFileSync(record.plan.newVideoPath, 'utf8')).toBe('video'); + expect(fs.existsSync(path.join(seasonDir, `${STEM}.jpg`))).toBe(true); + expect(fs.existsSync(record.plan.nfoSources[0])).toBe(false); + expect(fs.existsSync(sourceDir)).toBe(false); + expect(VideoClassification.create).toHaveBeenCalledWith(expect.objectContaining({ + youtube_id: ID, show_id: 5, season: 2024, episode: 3151200, file_stem: STEM, status: 'assigned', + })); + expect(sidecarWriter.writeVideoSidecars).toHaveBeenCalledWith(expect.objectContaining({ + videoPath: record.plan.newVideoPath, + episode: { showTitle: 'Chan', season: 2024, episode: 3151200, episodeTitle: 'Big Build' }, + })); + expect(Video.update).toHaveBeenCalledWith({ filePath: record.plan.newVideoPath, audioFilePath: null }, expect.anything()); + expect(videoInfoStore.rewriteActualPaths).toHaveBeenCalledWith(ID, expect.any(Map)); + }); + + it('finishes a move a restart interrupted after the files moved', async () => { + const record = toTvRecord(); + touch(record.plan.newVideoPath, 'video'); + touch(path.join(seasonDir, `${STEM}.jpg`), 'thumb'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await executor.executeItem(record, { showIdFor: () => 5 }); + + expect(Video.update).toHaveBeenCalledWith({ filePath: record.plan.newVideoPath, audioFilePath: null }, expect.anything()); + }); + + it('skips the row update when a restart came after it', async () => { + const record = toTvRecord(); + touch(record.plan.newVideoPath, 'video'); + touch(path.join(seasonDir, `${STEM}.jpg`), 'thumb'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.newVideoPath)); + + await executor.executeItem(record, { showIdFor: () => 5 }); + + expect(Video.update).not.toHaveBeenCalled(); + }); + + it('moves the earlier files back when a later one can\'t move', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + touch(record.plan.files[1].from, 'thumb'); + touch(path.join(seasonDir, `${STEM}.jpg`), 'someone else'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toMatchObject({ code: 'EEXIST' }); + + expect(fs.readFileSync(record.plan.oldVideoPath, 'utf8')).toBe('video'); + expect(fs.existsSync(record.plan.newVideoPath)).toBe(false); + expect(Video.update).not.toHaveBeenCalled(); + }); + + it('refuses a video whose files changed since the preview', async () => { + const record = toTvRecord(); + Video.findByPk.mockResolvedValue(videoRow(at('elsewhere.mp4'))); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toThrow(/changed since the preview/); + }); + + it('refuses a video that was marked missing', async () => { + const record = toTvRecord(); + Video.findByPk.mockResolvedValue({ ...videoRow(record.plan.oldVideoPath), removed: true }); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toThrow(/marked missing/); + }); + + it('reports a row another writer changed while the files moved', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + touch(record.plan.files[1].from, 'thumb'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + Video.update.mockResolvedValue([0]); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toThrow(/rescan corrects/); + }); + + it('marks a failure after the files moved as such, leaving the files at their destination', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + touch(record.plan.files[1].from, 'thumb'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + sidecarWriter.writeVideoSidecars.mockRejectedValueOnce(new Error('EACCES: nfo')); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })) + .rejects.toMatchObject({ filesMoved: true, message: expect.stringMatching(/files were moved.*EACCES: nfo/) }); + + expect(fs.readFileSync(record.plan.newVideoPath, 'utf8')).toBe('video'); + expect(fs.existsSync(record.plan.oldVideoPath)).toBe(false); + }); + + it('refuses a destination outside the downloads folder before moving anything', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + const outside = path.join(path.dirname(root), 'escaped.mp4'); + record.plan.files[0].to = outside; + record.files = JSON.stringify(record.plan); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toThrow(/outside the downloads folder/); + + expect(fs.existsSync(outside)).toBe(false); + expect(fs.existsSync(record.plan.oldVideoPath)).toBe(true); + }); + + it('returns the media home when a retry\'s later move fails after an earlier attempt moved it', async () => { + const record = toTvRecord(); + // The first attempt moved the video and stopped; the thumbnail's destination is now taken. + touch(record.plan.newVideoPath, 'video'); + touch(record.plan.files[1].from, 'thumb'); + touch(path.join(seasonDir, `${STEM}.jpg`), 'someone else'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toMatchObject({ code: 'EEXIST', filesMoved: false }); + + expect(fs.readFileSync(record.plan.oldVideoPath, 'utf8')).toBe('video'); + expect(fs.existsSync(record.plan.newVideoPath)).toBe(false); + expect(fs.existsSync(record.plan.files[1].from)).toBe(true); + }); + + it('reports files it could not bring home as moved', async () => { + const record = toTvRecord(); + touch(record.plan.newVideoPath, 'video'); + const thumbFrom = at('__Kids', 'Chan', 'elsewhere', `Chan - Big Build [${ID}].jpg`); + record.plan.files[1].from = touch(thumbFrom, 'thumb'); + record.plan.sourceDirs = [sourceDir, path.dirname(thumbFrom)]; + record.files = JSON.stringify(record.plan); + touch(path.join(seasonDir, `${STEM}.jpg`), 'someone else'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + // The video's source folder is now a file, so nothing can be moved back into it. + fs.rmSync(sourceDir, { recursive: true, force: true }); + touch(sourceDir, 'not a folder'); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toMatchObject({ code: 'EEXIST', filesMoved: true }); + + expect(fs.existsSync(record.plan.newVideoPath)).toBe(true); + }); + + it('skips a thumbnail that vanished since the preview', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await executor.executeItem(record, { showIdFor: () => 5 }); + + expect(fs.readFileSync(record.plan.newVideoPath, 'utf8')).toBe('video'); + expect(Video.update).toHaveBeenCalled(); + }); + + it('still fails when the media file itself vanished', async () => { + const record = toTvRecord(); + touch(record.plan.files[1].from, 'thumb'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toMatchObject({ code: 'ENOENT' }); + expect(fs.existsSync(record.plan.files[1].from)).toBe(true); + }); + + it('reports a move that failed before any file left its source as unmoved', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + touch(record.plan.files[1].from, 'thumb'); + touch(path.join(seasonDir, `${STEM}.jpg`), 'someone else'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await expect(executor.executeItem(record, { showIdFor: () => 5 })).rejects.toMatchObject({ filesMoved: false }); + }); + + it('leaves the files question open for a failure before any move was tried', async () => { + const record = toTvRecord(); + touch(record.plan.oldVideoPath, 'video'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + + await expect(executor.executeItem(record, { showIdFor: () => null })).rejects.not.toHaveProperty('filesMoved'); + }); + + it('moves an episode back to a movie-style folder and removes the emptied season and show folders', async () => { + const oldVideo = touch(path.join(seasonDir, `${STEM}.mp4`), 'video'); + touch(path.join(seasonDir, `${STEM}.nfo`), '<episodedetails/>'); + touch(at('__TV', 'Chan', 'tvshow.nfo'), '<tvshow/>'); + const newDir = at('__Kids', 'Chan', `Chan - Big Build - ${ID}`); + const plan = { + files: [{ from: oldVideo, to: path.join(newDir, `Chan - Big Build [${ID}].mp4`) }], + nfoSources: [path.join(seasonDir, `${STEM}.nfo`)], + sourceDirs: [seasonDir], + oldVideoPath: oldVideo, + newVideoPath: path.join(newDir, `Chan - Big Build [${ID}].mp4`), + oldAudioPath: null, + newAudioPath: null, + layout: 'videos', + fromLayout: 'tv', + }; + Video.findByPk.mockResolvedValue(videoRow(oldVideo)); + + await executor.executeItem({ youtube_id: ID, video_id: 1, files: JSON.stringify(plan), classification: null }, { showIdFor: () => null }); + + expect(fs.existsSync(plan.newVideoPath)).toBe(true); + expect(fs.existsSync(seasonDir)).toBe(false); + expect(fs.existsSync(at('__TV', 'Chan'))).toBe(false); + expect(VideoClassification.create).not.toHaveBeenCalled(); + expect(sidecarWriter.writeVideoSidecars).toHaveBeenCalledWith(expect.objectContaining({ episode: null })); + }); +}); diff --git a/server/modules/reorganize/__tests__/movieNameRenderer.test.js b/server/modules/reorganize/__tests__/movieNameRenderer.test.js new file mode 100644 index 000000000..b3fc4c09e --- /dev/null +++ b/server/modules/reorganize/__tests__/movieNameRenderer.test.js @@ -0,0 +1,106 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ getConfig: jest.fn() })); +jest.mock('../../ytDlpRunner', () => ({ run: jest.fn() })); +jest.mock('../../download/ytdlpCommandBuilder', () => ({ buildCustomArgs: jest.fn() })); + +const fs = require('fs'); + +const ID_A = 'aaaaaaaaaaa'; +const ID_B = 'bbbbbbbbbbb'; + +describe('movieNameRenderer', () => { + let renderer; + let configModule; + let ytDlpRunner; + let commandBuilder; + + beforeEach(() => { + jest.resetModules(); + configModule = require('../../configModule'); + ytDlpRunner = require('../../ytDlpRunner'); + commandBuilder = require('../../download/ytdlpCommandBuilder'); + configModule.getConfig.mockReturnValue({ videoFilenamePrefix: '%(uploader).80B - %(title).64B' }); + commandBuilder.buildCustomArgs.mockReturnValue([]); + renderer = require('../movieNameRenderer'); + }); + + const output = (...pairs) => `${pairs.map(([id, line]) => `${id}\n${line}`).join('\n')}\n`; + + describe('nameAffectingArgs', () => { + it('keeps only the custom args that change file names, with their values', () => { + const tokens = ['--retries', '5', '--restrict-filenames', '--trim-filenames', '60', '--replace-in-metadata', 'title', 'a', 'b', '-f', 'best']; + + expect(renderer.nameAffectingArgs(tokens)).toEqual([ + '--restrict-filenames', '--trim-filenames', '60', '--replace-in-metadata', 'title', 'a', 'b', + ]); + }); + + it('keeps a --flag=value token on its own', () => { + expect(renderer.nameAffectingArgs(['--trim-filenames=60', '--no-mtime'])).toEqual(['--trim-filenames=60']); + }); + }); + + it('renders the channel folder, video folder and stem from the global template', () => { + expect(renderer.renderTemplate('%(uploader).80B - %(title).64B')).toBe( + '%(uploader,channel,uploader_id).80B/%(uploader).80B - %(title).64B - %(id)s/%(uploader).80B - %(title).64B [%(id)s]' + ); + }); + + it('pairs each id with its rendered path', async () => { + ytDlpRunner.run.mockResolvedValue(output( + [ID_A, `Chan/Chan - One - ${ID_A}/Chan - One [${ID_A}]`], + [ID_B, `Chan/Chan - Two - ${ID_B}/Chan - Two [${ID_B}]`], + )); + + const names = await renderer.renderMovieNames([ + { youtubeId: ID_A, info: { id: ID_A, title: 'One' } }, + { youtubeId: ID_B, info: { id: ID_B, title: 'Two' } }, + ]); + + expect(names.get(ID_A)).toEqual({ channelFolder: 'Chan', videoFolder: `Chan - One - ${ID_A}`, stem: `Chan - One [${ID_A}]` }); + expect(names.get(ID_B).stem).toBe(`Chan - Two [${ID_B}]`); + }); + + it('passes the info dicts without their format lists, plus the download naming options', async () => { + let written = null; + ytDlpRunner.run.mockImplementation(async (args) => { + written = JSON.parse(fs.readFileSync(args[args.indexOf('--load-info-json') + 1], 'utf8')); + return output([ID_A, `Chan/Chan - One - ${ID_A}/Chan - One [${ID_A}]`]); + }); + commandBuilder.buildCustomArgs.mockReturnValue(['--restrict-filenames', '--retries', '3']); + + await renderer.renderMovieNames([{ youtubeId: ID_A, info: { id: ID_A, title: 'One', formats: [{}], thumbnails: [{}] } }]); + + const args = ytDlpRunner.run.mock.calls[0][0]; + expect(written).toEqual([{ id: ID_A, title: 'One' }]); + expect(args).toEqual(expect.arrayContaining(['--ignore-no-formats-error', '--windows-filenames', '--restrict-filenames'])); + expect(args).not.toContain('--retries'); + }); + + it('isolates an info dict that fails yt-dlp and renders the rest', async () => { + ytDlpRunner.run.mockImplementation(async (args) => { + const batch = JSON.parse(fs.readFileSync(args[args.indexOf('--load-info-json') + 1], 'utf8')); + if (batch.some((info) => info.id === ID_B)) throw new Error('bad info'); + return output([ID_A, `Chan/Chan - One - ${ID_A}/Chan - One [${ID_A}]`]); + }); + + const names = await renderer.renderMovieNames([ + { youtubeId: ID_A, info: { id: ID_A } }, + { youtubeId: ID_B, info: { id: ID_B } }, + ]); + + expect(names.has(ID_A)).toBe(true); + expect(names.has(ID_B)).toBe(false); + }); + + it('ignores a rendered path that does not end with the video id', () => { + const names = renderer.parseOutput(`${ID_A}\nChan/Folder/Other name\n`, new Set([ID_A])); + + expect(names.size).toBe(0); + }); + + it('does not run yt-dlp for an empty list', async () => { + await expect(renderer.renderMovieNames([])).resolves.toEqual(new Map()); + expect(ytDlpRunner.run).not.toHaveBeenCalled(); + }); +}); diff --git a/server/modules/reorganize/__tests__/operationRunner.test.js b/server/modules/reorganize/__tests__/operationRunner.test.js new file mode 100644 index 000000000..48ce1d21c --- /dev/null +++ b/server/modules/reorganize/__tests__/operationRunner.test.js @@ -0,0 +1,394 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ getConfig: jest.fn(() => ({})) })); +jest.mock('../../messageEmitter', () => ({ emitMessage: jest.fn() })); +jest.mock('../../mediaServers/watchStatusHolds', () => ({ + createHolds: jest.fn().mockResolvedValue(1), + releaseUnmovedHolds: jest.fn().mockResolvedValue(0), +})); +jest.mock('../../tvShows/libraryLayouts', () => ({ getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos') })); +jest.mock('../operationStore', () => ({ + createOperation: jest.fn(), + settingsOf: jest.fn((operation) => JSON.parse(operation.settings_change)), + markSettingsApplied: jest.fn(), + itemsWithStatus: jest.fn(), + markItem: jest.fn(), + refreshCounts: jest.fn(), + finishOperation: jest.fn(), + reopenOperation: jest.fn(), + findUnfinished: jest.fn(), + findOperation: jest.fn(), + resetFailedItems: jest.fn(), + hasNewerOperationFor: jest.fn().mockResolvedValue(false), +})); +jest.mock('../planner', () => ({ buildPlan: jest.fn(), summarizePlan: jest.fn(), applyRefusal: jest.fn(() => null) })); +jest.mock('../settingsApplier', () => ({ applySettings: jest.fn(), rollbackSettings: jest.fn() })); +jest.mock('../itemExecutor', () => ({ executeItem: jest.fn() })); +jest.mock('../followUp', () => ({ finishFiles: jest.fn(), finishServers: jest.fn().mockResolvedValue(undefined) })); + +const CHANGE = { type: 'channel', channelId: 'UC1', subFolder: 'TV' }; + +const flush = async (until) => { + for (let i = 0; i < 50; i++) { + if (until()) return; + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +describe('reorganize operationRunner', () => { + let runner; + let lock; + let store; + let planner; + let applier; + let executor; + let followUp; + let deps; + let operation; + + const item = (id) => ({ id, youtube_id: `vid${id}`, video_id: id, channel_id: 'UC1', files: '{}', classification: null }); + const plan = (items = [{ videoId: 1, youtubeId: 'vid1', channelId: 'UC1' }]) => ({ + revision: 'rev', + items, + shows: [{ ownerChannelId: 'UC1', showId: null }], + context: { type: 'channel', label: 'Chan', stored: CHANGE, channel: { channel_id: 'UC1' }, layoutBefore: () => 'videos' }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + lock = require('../reorganizeLock'); + store = require('../operationStore'); + planner = require('../planner'); + applier = require('../settingsApplier'); + executor = require('../itemExecutor'); + followUp = require('../followUp'); + operation = { + id: 3, status: 'running', total_items: 2, done_items: 0, failed_items: 0, settings_applied: false, + settings_change: JSON.stringify({ change: CHANGE, label: 'Chan', shows: [{ ownerChannelId: 'UC1', showId: null }] }), + update: jest.fn(async (values) => Object.assign(operation, values)), + }; + store.createOperation.mockResolvedValue(operation); + store.itemsWithStatus.mockResolvedValue([item(1), item(2)]); + store.refreshCounts.mockResolvedValue({ done: 2, failed: 0, pending: 0 }); + applier.applySettings.mockImplementation(async ({ shows }) => shows.map((show) => ({ ...show, showId: 9 }))); + planner.buildPlan.mockResolvedValue(plan()); + deps = { + jobModule: { getInProgressJobId: jest.fn(() => null), isArchiveRepairRunning: jest.fn(() => false) }, + scheduledTaskManager: { isTaskRunningById: jest.fn(() => false) }, + videosModule: { isBackfillRunning: jest.fn(() => false) }, + mediaServerSync: { isAnySyncInFlight: jest.fn(() => false) }, + watchStatusSync: { getStatus: jest.fn(() => ({ running: false, lastRun: { completedAt: new Date().toISOString() } })), syncAll: jest.fn() }, + }; + runner = require('../operationRunner'); + runner.initialize(deps); + }); + + describe('blocker', () => { + it('is free when nothing touches downloads', () => { + expect(runner.blocker()).toBeNull(); + }); + + it('waits for a running download', () => { + deps.jobModule.getInProgressJobId.mockReturnValue('job'); + expect(runner.blocker()).toMatchObject({ reason: 'download-running' }); + }); + + it('waits for a task that touches downloads', () => { + deps.scheduledTaskManager.isTaskRunningById.mockImplementation((id) => id === 'autoRemovalFrequency'); + expect(runner.blocker()).toMatchObject({ reason: 'task-running', message: expect.stringContaining('Automatic video cleanup') }); + }); + + it('waits for a startup rescan and a playlist sync', () => { + deps.videosModule.isBackfillRunning.mockReturnValue(true); + expect(runner.blocker()).toMatchObject({ reason: 'task-running' }); + deps.videosModule.isBackfillRunning.mockReturnValue(false); + deps.mediaServerSync.isAnySyncInFlight.mockReturnValue(true); + expect(runner.blocker()).toMatchObject({ reason: 'task-running' }); + }); + }); + + it('previews with the current blocker', async () => { + deps.jobModule.getInProgressJobId.mockReturnValue('job'); + planner.summarizePlan.mockResolvedValue({ needed: true }); + + await runner.preview(CHANGE); + + expect(planner.summarizePlan).toHaveBeenCalledWith(expect.anything(), { blocked: expect.objectContaining({ reason: 'download-running' }) }); + }); + + describe('start', () => { + it('requires the preview\'s revision', async () => { + await expect(runner.start(CHANGE)).rejects.toMatchObject({ status: 400 }); + }); + + it('refuses while a download runs', async () => { + deps.jobModule.getInProgressJobId.mockReturnValue('job'); + + await expect(runner.start(CHANGE, 'rev')).rejects.toMatchObject({ status: 409 }); + expect(lock.isActive()).toBe(false); + }); + + it('refuses a stale preview and releases the lock', async () => { + await expect(runner.start(CHANGE, 'old')).rejects.toMatchObject({ status: 409, code: 'STALE_PREVIEW' }); + expect(lock.isActive()).toBe(false); + }); + + it('applies the change directly when nothing has to move', async () => { + planner.buildPlan.mockResolvedValue(plan([])); + + await expect(runner.start(CHANGE, 'rev')).resolves.toEqual({ operationId: null, applied: true }); + expect(applier.applySettings).toHaveBeenCalled(); + expect(store.createOperation).not.toHaveBeenCalled(); + expect(lock.isActive()).toBe(false); + }); + + it('refuses a change none of whose videos could be planned', async () => { + planner.buildPlan.mockResolvedValue({ ...plan([]), problems: [{ problem: 'no-name' }] }); + planner.applyRefusal.mockReturnValue({ reason: 'problems', message: 'None can move' }); + + await expect(runner.start(CHANGE, 'rev')).rejects.toMatchObject({ status: 409, code: 'problems' }); + expect(applier.applySettings).not.toHaveBeenCalled(); + expect(lock.isActive()).toBe(false); + }); + + it('moves every video in the background, holding the lock until it ends', async () => { + await expect(runner.start(CHANGE, 'rev')).resolves.toEqual({ operationId: 3, applied: false }); + expect(lock.getActive()).toMatchObject({ operationId: 3, label: 'Chan' }); + expect(lock.coversChannel('UC1')).toBe(true); + + await flush(() => !lock.isActive()); + + expect(applier.applySettings).toHaveBeenCalledTimes(1); + expect(store.markSettingsApplied).toHaveBeenCalledWith(operation, [{ ownerChannelId: 'UC1', showId: 9 }]); + expect(executor.executeItem).toHaveBeenCalledTimes(2); + expect(executor.executeItem.mock.calls[0][1].showIdFor('UC1')).toBe(9); + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'completed', null); + expect(followUp.finishFiles).toHaveBeenCalled(); + expect(followUp.finishServers).toHaveBeenCalledWith(expect.objectContaining({ + items: expect.arrayContaining([expect.objectContaining({ youtubeId: 'vid1' })]), + })); + expect(lock.isActive()).toBe(false); + }); + + it('records a partial run when some videos fail', async () => { + executor.executeItem.mockRejectedValueOnce(new Error('EEXIST')); + store.refreshCounts.mockResolvedValue({ done: 1, failed: 1, pending: 0 }); + + await runner.start(CHANGE, 'rev'); + await flush(() => !lock.isActive()); + + expect(store.markItem).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 'failed', 'EEXIST', { filesMoved: undefined }); + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'partial', null); + expect(require('../../mediaServers/watchStatusHolds').releaseUnmovedHolds) + .toHaveBeenCalledWith({ operationId: 3, videoIds: [1] }); + expect(applier.rollbackSettings).not.toHaveBeenCalled(); + }); + + it('undoes the settings change when no video could move', async () => { + executor.executeItem.mockRejectedValue(new Error('EACCES')); + store.itemsWithStatus.mockImplementation(async (id, statuses) => (statuses.includes('pending') ? [item(1), item(2)] : [])); + store.refreshCounts.mockResolvedValue({ done: 0, failed: 2, pending: 0, moved: 0 }); + + await runner.start(CHANGE, 'rev'); + await flush(() => !lock.isActive()); + + expect(applier.rollbackSettings).toHaveBeenCalled(); + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'failed', expect.stringContaining('undone')); + expect(followUp.finishServers).not.toHaveBeenCalled(); + }); + + it('keeps the settings and the holds of videos whose files moved but could not be finished', async () => { + executor.executeItem.mockRejectedValue(Object.assign(new Error('nfo failed'), { filesMoved: true })); + store.refreshCounts.mockResolvedValue({ done: 0, failed: 2, pending: 0, moved: 2 }); + + await runner.start(CHANGE, 'rev'); + await flush(() => !lock.isActive()); + + expect(store.markItem).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 'failed', 'nfo failed', { filesMoved: true }); + expect(applier.rollbackSettings).not.toHaveBeenCalled(); + expect(require('../../mediaServers/watchStatusHolds').releaseUnmovedHolds) + .toHaveBeenCalledWith({ operationId: 3, videoIds: [] }); + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'partial', null); + }); + + it('releases the holds of every video when the run stops before moving', async () => { + applier.applySettings.mockRejectedValue(new Error('show folder')); + + await runner.start(CHANGE, 'rev'); + await flush(() => !lock.isActive()); + + expect(require('../../mediaServers/watchStatusHolds').releaseUnmovedHolds) + .toHaveBeenCalledWith({ operationId: 3, videoIds: [1, 2] }); + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'failed', 'show folder'); + }); + + it('still completes when the follow-up work fails', async () => { + followUp.finishFiles.mockRejectedValue(new Error('db gone')); + + await runner.start(CHANGE, 'rev'); + await flush(() => !lock.isActive()); + + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'completed', null); + }); + + it('syncs watch state first when the last sync is old', async () => { + deps.watchStatusSync.getStatus.mockReturnValue({ running: false, lastRun: null }); + deps.watchStatusSync.syncAll.mockResolvedValue({}); + + await runner.start(CHANGE, 'rev'); + await flush(() => !lock.isActive()); + + expect(deps.watchStatusSync.syncAll).toHaveBeenCalledWith('reorganize'); + }); + + it('skips that sync when the last one is recent', async () => { + await runner.start(CHANGE, 'rev'); + await flush(() => !lock.isActive()); + + expect(deps.watchStatusSync.syncAll).not.toHaveBeenCalled(); + }); + }); + + describe('retry', () => { + beforeEach(() => { + operation.status = 'partial'; + operation.settings_applied = true; + store.findOperation.mockResolvedValue(operation); + store.itemsWithStatus.mockResolvedValue([item(2)]); + }); + + it('returns 404 for an unknown operation', async () => { + store.findOperation.mockResolvedValue(null); + await expect(runner.retry(99)).rejects.toMatchObject({ status: 404 }); + }); + + it('refuses while a reorganize runs', async () => { + const token = lock.acquire({ label: 'other' }); + try { + await expect(runner.retry(3)).rejects.toMatchObject({ status: 409, code: 'reorganizing' }); + } finally { + lock.release(token); + } + }); + + it('retries an operation a crash left running once nothing else runs', async () => { + operation.status = 'running'; + await expect(runner.retry(3)).resolves.toEqual({ operationId: 3 }); + await flush(() => !lock.isActive()); + expect(executor.executeItem).toHaveBeenCalled(); + }); + + it('keeps the holds and settings of a video that moved on an earlier attempt when the retry fails before moving', async () => { + store.itemsWithStatus.mockImplementation(async (id, statuses) => ( + statuses.includes('pending') ? [{ ...item(2), files_moved: true }] : [] + )); + executor.executeItem.mockRejectedValue(new Error('The video\'s show could not be found.')); + store.refreshCounts.mockResolvedValue({ done: 0, failed: 1, pending: 0, moved: 1 }); + + await runner.retry(3); + await flush(() => !lock.isActive()); + + expect(require('../../mediaServers/watchStatusHolds').releaseUnmovedHolds) + .toHaveBeenCalledWith({ operationId: 3, videoIds: [] }); + expect(applier.rollbackSettings).not.toHaveBeenCalled(); + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'partial', null); + }); + + it('releases the holds of a video a retry brought back home', async () => { + store.itemsWithStatus.mockImplementation(async (id, statuses) => ( + statuses.includes('pending') ? [{ ...item(2), files_moved: true }] : [] + )); + executor.executeItem.mockRejectedValue(Object.assign(new Error('EEXIST'), { filesMoved: false })); + store.refreshCounts.mockResolvedValue({ done: 0, failed: 1, pending: 0, moved: 0 }); + + await runner.retry(3); + await flush(() => !lock.isActive()); + + expect(require('../../mediaServers/watchStatusHolds').releaseUnmovedHolds) + .toHaveBeenCalledWith({ operationId: 3, videoIds: [2] }); + expect(store.markItem).toHaveBeenCalledWith(expect.objectContaining({ id: 2 }), 'failed', 'EEXIST', { filesMoved: false }); + }); + + it('retries the videos a stopped run never reached', async () => { + store.itemsWithStatus.mockImplementation(async (id, statuses) => (statuses.includes('pending') ? [item(2)] : [])); + await expect(runner.retry(3)).resolves.toEqual({ operationId: 3 }); + await flush(() => !lock.isActive()); + expect(executor.executeItem).toHaveBeenCalledTimes(1); + }); + + it('refuses when a newer reorganize changed the same channels', async () => { + store.hasNewerOperationFor.mockResolvedValue(true); + await expect(runner.retry(3)).rejects.toMatchObject({ status: 409 }); + expect(lock.isActive()).toBe(false); + }); + + it('runs the failed videos again without reapplying the settings', async () => { + await expect(runner.retry(3)).resolves.toEqual({ operationId: 3 }); + await flush(() => !lock.isActive()); + + expect(store.resetFailedItems).toHaveBeenCalledWith(3); + expect(store.reopenOperation).toHaveBeenCalledWith(operation); + expect(applier.applySettings).not.toHaveBeenCalled(); + expect(executor.executeItem).toHaveBeenCalled(); + }); + }); + + it('resumes an interrupted operation at startup without a fresh sync', async () => { + deps.watchStatusSync.getStatus.mockReturnValue({ running: false, lastRun: null }); + operation.settings_applied = true; + store.findUnfinished.mockResolvedValue([operation]); + + await runner.recover(); + + expect(executor.executeItem).toHaveBeenCalledTimes(2); + expect(deps.watchStatusSync.syncAll).not.toHaveBeenCalled(); + expect(applier.applySettings).not.toHaveBeenCalled(); + expect(lock.isActive()).toBe(false); + }); + + it('runs the follow-up for the videos moved before the restart as well', async () => { + operation.settings_applied = true; + store.findUnfinished.mockResolvedValue([operation]); + store.itemsWithStatus.mockImplementation(async (id, statuses) => ( + statuses.includes('pending') ? [item(2)] : [item(1), item(2)] + )); + + await runner.recover(); + + expect(executor.executeItem).toHaveBeenCalledTimes(1); + const finished = followUp.finishFiles.mock.calls[0][0].items.map((entry) => entry.youtubeId); + expect(finished).toEqual(['vid1', 'vid2']); + expect(followUp.finishServers).toHaveBeenCalledWith(expect.objectContaining({ + items: expect.arrayContaining([expect.objectContaining({ youtubeId: 'vid1' })]), + })); + }); + + it('leaves an interrupted operation for later when startup work is still running at the deadline', async () => { + jest.useFakeTimers(); + try { + deps.jobModule.getInProgressJobId.mockReturnValue('job'); + store.findUnfinished.mockResolvedValue([operation]); + + const recovery = runner.recover(); + await jest.advanceTimersByTimeAsync(31 * 60 * 1000); + await recovery; + + expect(executor.executeItem).not.toHaveBeenCalled(); + expect(store.finishOperation).toHaveBeenCalledWith(operation, 'failed', expect.stringContaining('Retry')); + expect(lock.isActive()).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it('refuses to move when a download started while the plan was computed', async () => { + planner.buildPlan.mockImplementation(async () => { + deps.jobModule.getInProgressJobId.mockReturnValue('job'); + return plan(); + }); + + await expect(runner.start(CHANGE, 'rev')).rejects.toMatchObject({ status: 409, code: 'download-running' }); + expect(store.createOperation).not.toHaveBeenCalled(); + expect(lock.isActive()).toBe(false); + }); +}); diff --git a/server/modules/reorganize/__tests__/operationStore.test.js b/server/modules/reorganize/__tests__/operationStore.test.js new file mode 100644 index 000000000..a37f411f5 --- /dev/null +++ b/server/modules/reorganize/__tests__/operationStore.test.js @@ -0,0 +1,166 @@ +jest.mock('../../../db', () => ({ + sequelize: { + transaction: jest.fn(async (work) => work('tx')), + fn: jest.fn((name, col) => `${name}(${col})`), + col: jest.fn((name) => name), + }, +})); +jest.mock('../../../models/tvreorganizeoperation', () => ({ create: jest.fn(), findByPk: jest.fn(), findAll: jest.fn() })); +jest.mock('../../../models/tvreorganizeitem', () => ({ + bulkCreate: jest.fn(), findAll: jest.fn(), findOne: jest.fn(), count: jest.fn(), update: jest.fn(), +})); + +const plan = { + context: { type: 'channel', scope: 'UC1', label: 'Chan', stored: { type: 'channel', channelId: 'UC1', subFolder: 'TV' } }, + shows: [{ ownerChannelId: 'UC1', action: 'create' }], + revision: 'rev', + items: [{ + videoId: 1, youtubeId: 'abcdefghijk', channelId: 'UC1', title: 'Big Build', + files: [{ from: '/a', to: '/b', size: 1, mtimeMs: 2 }], nfoSources: [], sourceDirs: ['/'], destDir: '/', + oldVideoPath: '/a', newVideoPath: '/b', oldAudioPath: null, newAudioPath: null, + layout: 'tv', libraryFolder: 'TV', fromLayout: 'videos', fromLibraryFolder: 'Kids', + classification: { season: 2024 }, + }], +}; + +describe('reorganize operationStore', () => { + let store; + let TvReorganizeOperation; + let TvReorganizeItem; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + TvReorganizeOperation = require('../../../models/tvreorganizeoperation'); + TvReorganizeItem = require('../../../models/tvreorganizeitem'); + TvReorganizeOperation.create.mockImplementation(async (values) => ({ id: 3, ...values })); + store = require('../operationStore'); + }); + + it('records the operation and its items in one transaction', async () => { + const operation = await store.createOperation(plan); + + expect(operation).toMatchObject({ id: 3, change_type: 'channel', status: 'running', total_items: 1, revision: 'rev' }); + expect(JSON.parse(operation.settings_change)).toEqual({ change: plan.context.stored, label: 'Chan', shows: plan.shows }); + const [rows, options] = TvReorganizeItem.bulkCreate.mock.calls[0]; + expect(options).toEqual({ transaction: 'tx' }); + expect(rows[0]).toMatchObject({ operation_id: 3, youtube_id: 'abcdefghijk', video_id: 1, status: 'pending' }); + expect(JSON.parse(rows[0].files).files).toEqual([{ from: '/a', to: '/b' }]); + expect(JSON.parse(rows[0].classification)).toEqual({ season: 2024 }); + }); + + it('stores the pinned shows when the settings are applied', async () => { + const operation = { settings_change: JSON.stringify({ change: {}, label: 'Chan', shows: [] }), update: jest.fn() }; + + await store.markSettingsApplied(operation, [{ ownerChannelId: 'UC1', showId: 9 }]); + + expect(operation.update).toHaveBeenCalledWith({ + settings_change: JSON.stringify({ change: {}, label: 'Chan', shows: [{ ownerChannelId: 'UC1', showId: 9 }] }), + settings_applied: true, + }); + }); + + it('recounts item statuses into the operation, with the items whose files moved', async () => { + TvReorganizeItem.findAll.mockResolvedValue([{ status: 'done', count: '4' }, { status: 'failed', count: 1 }]); + TvReorganizeItem.count.mockResolvedValue(5); + const operation = { id: 3, update: jest.fn() }; + + await expect(store.refreshCounts(operation)).resolves.toEqual({ done: 4, failed: 1, pending: 0, moved: 5 }); + expect(TvReorganizeItem.count).toHaveBeenCalledWith({ where: { operation_id: 3, files_moved: true } }); + expect(operation.update).toHaveBeenCalledWith({ done_items: 4, failed_items: 1 }); + }); + + it('records whether a failed item\'s files had moved, and a done item\'s always did', async () => { + const item = { update: jest.fn() }; + + await store.markItem(item, 'failed', 'nfo failed', { filesMoved: true }); + await store.markItem(item, 'failed', 'EEXIST'); + await store.markItem(item, 'done'); + + expect(item.update.mock.calls.map(([values]) => values.files_moved)).toEqual([true, false, true]); + }); + + it('keeps a stored "files moved" when a later attempt failed before reaching the files', async () => { + const item = { files_moved: true, update: jest.fn() }; + + await store.markItem(item, 'failed', 'show could not be found'); + + expect(item.update.mock.calls[0][0].files_moved).toBe(true); + }); + + it('clears a stored "files moved" when a later attempt brought every file home', async () => { + const item = { files_moved: true, update: jest.fn() }; + + await store.markItem(item, 'failed', 'EEXIST', { filesMoved: false }); + + expect(item.update.mock.calls[0][0].files_moved).toBe(false); + }); + + it('describes an operation with its failed videos', async () => { + TvReorganizeOperation.findByPk.mockResolvedValue({ + id: 3, change_type: 'channel', status: 'partial', total_items: 2, done_items: 1, failed_items: 1, error: null, + started_at: null, finished_at: null, settings_change: JSON.stringify({ change: { type: 'channel' }, label: 'Chan', shows: [] }), + }); + TvReorganizeItem.findAll.mockResolvedValue([{ id: 8, youtube_id: 'abcdefghijk', title: 'T', channel_id: 'UC1', error: 'EEXIST' }]); + + await expect(store.getOperationView(3)).resolves.toMatchObject({ + id: 3, label: 'Chan', status: 'partial', total: 2, done: 1, failed: 1, + failedItems: [{ id: 8, youtubeId: 'abcdefghijk', error: 'EEXIST' }], + }); + }); + + it('returns null for an unknown operation', async () => { + TvReorganizeOperation.findByPk.mockResolvedValue(null); + + await expect(store.getOperationView(99)).resolves.toBeNull(); + }); + + it('reports the videos of a channel the newest operation left unmoved', async () => { + TvReorganizeItem.findOne.mockResolvedValue({ operation_id: 3 }); + TvReorganizeOperation.findByPk.mockResolvedValue({ id: 3, status: 'partial' }); + TvReorganizeItem.count.mockResolvedValue(2); + + await expect(store.unmovedForChannel('UC1')).resolves.toEqual({ operationId: 3, failed: 2, status: 'partial' }); + expect(TvReorganizeItem.count).toHaveBeenCalledWith({ + where: { operation_id: 3, channel_id: 'UC1', status: ['failed', 'pending'] }, + }); + }); + + it('lists the videos a finished operation never reached among the ones to retry', async () => { + TvReorganizeOperation.findByPk.mockResolvedValue({ + id: 3, change_type: 'channel', status: 'partial', total_items: 2, done_items: 1, failed_items: 0, error: 'interrupted', + started_at: null, finished_at: null, settings_change: JSON.stringify({ change: { type: 'channel' }, label: 'Chan', shows: [] }), + }); + TvReorganizeItem.findAll.mockResolvedValue([{ id: 8, youtube_id: 'abcdefghijk', title: 'T', channel_id: 'UC1', error: null }]); + + await expect(store.getOperationView(3)).resolves.toMatchObject({ failedItems: [{ id: 8 }] }); + expect(TvReorganizeItem.findAll.mock.calls[0][0].where).toEqual({ operation_id: 3, status: ['failed', 'pending'] }); + }); + + it('lists only the failed videos of an operation that is still running', async () => { + TvReorganizeOperation.findByPk.mockResolvedValue({ + id: 3, change_type: 'channel', status: 'running', total_items: 2, done_items: 0, failed_items: 1, error: null, + started_at: null, finished_at: null, settings_change: JSON.stringify({ change: { type: 'channel' }, label: 'Chan', shows: [] }), + }); + TvReorganizeItem.findAll.mockResolvedValue([]); + + await store.getOperationView(3); + + expect(TvReorganizeItem.findAll.mock.calls[0][0].where).toEqual({ operation_id: 3, status: ['failed'] }); + }); + + it('truncates long errors', async () => { + const item = { update: jest.fn() }; + + await store.markItem(item, 'failed', 'x'.repeat(2000)); + + expect(item.update.mock.calls[0][0].error).toHaveLength(1000); + }); + + it('finds a newer operation that covers the same channels', async () => { + TvReorganizeItem.findOne.mockResolvedValue({ id: 20 }); + + await expect(store.hasNewerOperationFor(3, ['UC1'])).resolves.toBe(true); + await expect(store.hasNewerOperationFor(3, [])).resolves.toBe(false); + }); +}); diff --git a/server/modules/reorganize/__tests__/planner.test.js b/server/modules/reorganize/__tests__/planner.test.js new file mode 100644 index 000000000..5425ba4e0 --- /dev/null +++ b/server/modules/reorganize/__tests__/planner.test.js @@ -0,0 +1,115 @@ +jest.mock('../../configModule', () => ({ directoryPath: '/data' })); +jest.mock('../../../models/videowatchstatus', () => ({ findAll: jest.fn() })); +jest.mock('../../mediaServers/watchStatusHolds', () => ({ + isHoldable: (row) => !(row.server_type === 'plex' && row.server_user_id !== '1'), +})); +jest.mock('../changeContext', () => ({ resolveChange: jest.fn() })); +jest.mock('../changeScope', () => ({ selectSubjects: jest.fn() })); +jest.mock('../showPlanner', () => ({ planShows: jest.fn() })); +jest.mock('../destinationPlanner', () => ({ planDestinations: jest.fn() })); + +const item = (overrides = {}) => ({ + videoId: 1, + youtubeId: 'abcdefghijk', + channelId: 'UC1', + title: 'Big Build', + layout: 'tv', + fromLayout: 'videos', + oldVideoPath: '/data/__Kids/Chan/A [abcdefghijk].mp4', + newVideoPath: '/data/__TV/Chan/Season 2024/S2024E03151200 - A [abcdefghijk].mp4', + files: [{ from: '/a', to: '/b', size: 1, mtimeMs: 1 }], + classification: { season: 2024, episode: 3151200, fileStem: 'S2024E03151200 - A [abcdefghijk]' }, + flags: ['adopted'], + ...overrides, +}); + +describe('reorganize planner', () => { + let planner; + let VideoWatchStatus; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + const context = { type: 'channel', label: 'Chan', stored: { type: 'channel', channelId: 'UC1', subFolder: 'TV' } }; + require('../changeContext').resolveChange.mockResolvedValue(context); + require('../changeScope').selectSubjects.mockResolvedValue({ subjects: [] }); + require('../showPlanner').planShows.mockResolvedValue({ + targets: new Map(), + shows: new Map([ + ['UC1', { ownerChannelId: 'UC1', action: 'create', name: 'Chan', libraryFolder: 'TV', folderName: 'Chan' }], + ['UC9', { ownerChannelId: 'UC9', action: 'create', name: 'Unused', libraryFolder: 'TV', folderName: 'Unused' }], + ]), + }); + require('../destinationPlanner').planDestinations.mockResolvedValue({ + items: [item()], + problems: [{ videoId: 2, youtubeId: 'bbbbbbbbbbb', title: 'B', problem: 'collision', detail: '/data/__TV/x.mp4' }], + unchanged: 3, + }); + VideoWatchStatus = require('../../../models/videowatchstatus'); + VideoWatchStatus.findAll.mockResolvedValue([]); + planner = require('../planner'); + }); + + it('keeps only the shows that receive a moved video and computes a revision', async () => { + const plan = await planner.buildPlan({ type: 'channel' }); + + expect(plan.shows.map((show) => show.ownerChannelId)).toEqual(['UC1']); + expect(plan.revision).toMatch(/^[0-9a-f]{64}$/); + }); + + it('summarizes the plan for the preview with paths relative to the downloads folder', async () => { + const plan = await planner.buildPlan({ type: 'channel' }); + + const preview = await planner.summarizePlan(plan, { blocked: { reason: 'download-running', message: 'Wait' } }); + + expect(preview).toMatchObject({ + needed: true, + change: { type: 'channel', label: 'Chan' }, + totals: { videos: 1, toTv: 1, toVideos: 0, unchanged: 3, collisions: 1, adopted: 1 }, + items: [{ from: '__Kids/Chan/A [abcdefghijk].mp4', to: '__TV/Chan/Season 2024/S2024E03151200 - A [abcdefghijk].mp4', episode: 'S2024E03151200' }], + problems: [{ problem: 'collision', detail: '__TV/x.mp4' }], + blocked: { reason: 'download-running' }, + }); + }); + + it('counts videos whose watch state the servers will lose, leaving out other Plex accounts', async () => { + VideoWatchStatus.findAll.mockResolvedValue([ + { video_id: 1, server_type: 'jellyfin', server_user_id: 'u1', played: true }, + { video_id: 1, server_type: 'jellyfin', server_user_id: 'u2', played: true }, + { video_id: 1, server_type: 'plex', server_user_id: '5', played: true }, + ]); + const plan = await planner.buildPlan({ type: 'channel' }); + + const preview = await planner.summarizePlan(plan); + + expect(preview.watchState).toEqual([{ serverType: 'jellyfin', videos: 1, users: 2 }]); + }); + + it('reports that nothing needs to move', async () => { + require('../destinationPlanner').planDestinations.mockResolvedValue({ items: [], problems: [], unchanged: 0 }); + const plan = await planner.buildPlan({ type: 'channel' }); + + await expect(planner.summarizePlan(plan)).resolves.toMatchObject({ needed: false, shows: [], blocked: null }); + }); + + it('refuses to apply a change none of whose videos could be planned', async () => { + require('../destinationPlanner').planDestinations.mockResolvedValue({ + items: [], unchanged: 0, + problems: [{ videoId: 2, youtubeId: 'bbbbbbbbbbb', title: 'B', problem: 'no-name', detail: null }], + }); + const plan = await planner.buildPlan({ type: 'channel' }); + + expect(planner.applyRefusal(plan)).toMatchObject({ reason: 'problems' }); + await expect(planner.summarizePlan(plan)).resolves.toMatchObject({ needed: false, blocked: { reason: 'problems' } }); + }); + + it('still applies directly when the only problems are files that are gone', async () => { + require('../destinationPlanner').planDestinations.mockResolvedValue({ + items: [], unchanged: 1, + problems: [{ videoId: 2, youtubeId: 'bbbbbbbbbbb', title: 'B', problem: 'missing', detail: null }], + }); + const plan = await planner.buildPlan({ type: 'channel' }); + + expect(planner.applyRefusal(plan)).toBeNull(); + }); +}); diff --git a/server/modules/reorganize/__tests__/reorganizeLock.test.js b/server/modules/reorganize/__tests__/reorganizeLock.test.js new file mode 100644 index 000000000..804ca86c9 --- /dev/null +++ b/server/modules/reorganize/__tests__/reorganizeLock.test.js @@ -0,0 +1,71 @@ +const { ReorganizeLock, REORGANIZE_BLOCK_REASON } = require('../reorganizeLock'); + +describe('reorganizeLock', () => { + let lock; + + beforeEach(() => { + lock = new ReorganizeLock(); + }); + + it('is free until acquired', () => { + expect(lock.isActive()).toBe(false); + expect(lock.runBlocker()).toBeNull(); + }); + + it('refuses a second reorganize with a 409', () => { + lock.acquire({ label: 'Chan' }); + + expect(() => lock.acquire({ label: 'Other' })).toThrow(expect.objectContaining({ status: 409 })); + }); + + it('reports the scope it covers', () => { + const token = lock.acquire({ label: 'Chan' }); + lock.setScope(token, { operationId: 4, channelIds: ['UC1'], videoIds: [7], youtubeIds: ['abc'] }); + + expect(lock.getActive()).toMatchObject({ operationId: 4, label: 'Chan' }); + expect(lock.coversChannel('UC1')).toBe(true); + expect(lock.coversChannel('UC2')).toBe(false); + expect(lock.coversAnyVideo({ ids: ['7'] })).toBe(true); + expect(lock.coversAnyVideo({ youtubeIds: ['abc'] })).toBe(true); + expect(lock.coversAnyVideo({ ids: [8], youtubeIds: ['xyz'] })).toBe(false); + }); + + it('blocks scheduled tasks while held', () => { + lock.acquire({ label: 'Chan' }); + + expect(lock.runBlocker()).toEqual({ reason: REORGANIZE_BLOCK_REASON, message: expect.stringContaining('Chan') }); + }); + + it('throws 409 errors for covered channels and videos', () => { + const token = lock.acquire({ label: 'Chan' }); + lock.setScope(token, { channelIds: ['UC1'], youtubeIds: ['abc'] }); + + expect(() => lock.assertChannelFree('UC1')).toThrow(expect.objectContaining({ status: 409, code: 'REORGANIZE_RUNNING' })); + expect(() => lock.assertChannelFree('UC2')).not.toThrow(); + expect(() => lock.assertVideosFree({ youtubeIds: ['abc'] })).toThrow(expect.objectContaining({ status: 409 })); + expect(() => lock.assertInactive()).toThrow(expect.objectContaining({ status: 409 })); + }); + + it('emits released and frees the lock on release', () => { + const released = jest.fn(); + lock.on('released', released); + const token = lock.acquire({ label: 'Chan' }); + + lock.release(token); + + expect(lock.isActive()).toBe(false); + expect(released).toHaveBeenCalledTimes(1); + }); + + it('ignores a release with a stale token', () => { + const released = jest.fn(); + lock.on('released', released); + lock.release({}); + const token = lock.acquire({ label: 'Chan' }); + lock.release({}); + + expect(lock.isActive()).toBe(true); + expect(released).not.toHaveBeenCalled(); + lock.release(token); + }); +}); diff --git a/server/modules/reorganize/__tests__/revision.test.js b/server/modules/reorganize/__tests__/revision.test.js new file mode 100644 index 000000000..5552d1d04 --- /dev/null +++ b/server/modules/reorganize/__tests__/revision.test.js @@ -0,0 +1,40 @@ +const { planRevision } = require('../revision'); + +const plan = (overrides = {}) => ({ + change: { type: 'channel', channelId: 'UC1', subFolder: 'TV' }, + shows: [{ ownerChannelId: 'UC1', action: 'create', libraryFolder: 'TV', folderName: 'Chan' }], + items: [{ + youtubeId: 'abcdefghijk', + files: [{ from: '/a.mp4', to: '/b.mp4', size: 10, mtimeMs: 1000 }], + classification: { season: 2024, episode: 3151200, fileStem: 'S2024E03151200 - T [abcdefghijk]' }, + }], + ...overrides, +}); + +describe('reorganize revision', () => { + it('is stable for the same plan', () => { + expect(planRevision(plan())).toBe(planRevision(plan())); + }); + + it('changes when a source file changes', () => { + const changed = plan(); + changed.items[0].files[0].size = 11; + + expect(planRevision(changed)).not.toBe(planRevision(plan())); + }); + + it('changes when a planned show folder changes', () => { + const changed = plan({ shows: [{ ownerChannelId: 'UC1', action: 'create', libraryFolder: 'TV', folderName: 'Chan (UC1)' }] }); + + expect(planRevision(changed)).not.toBe(planRevision(plan())); + }); + + it('does not depend on the order shows were planned in', () => { + const shows = [ + { ownerChannelId: 'UC2', action: 'create', libraryFolder: 'TV', folderName: 'B' }, + { ownerChannelId: 'UC1', action: 'create', libraryFolder: 'TV', folderName: 'A' }, + ]; + + expect(planRevision(plan({ shows }))).toBe(planRevision(plan({ shows: [...shows].reverse() }))); + }); +}); diff --git a/server/modules/reorganize/__tests__/settingsApplier.test.js b/server/modules/reorganize/__tests__/settingsApplier.test.js new file mode 100644 index 000000000..9a58e6058 --- /dev/null +++ b/server/modules/reorganize/__tests__/settingsApplier.test.js @@ -0,0 +1,146 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../../configModule', () => ({ getConfig: jest.fn(() => ({ x: 1 })), getDefaultSubfolder: jest.fn(() => 'GlobalDefault'), updateConfig: jest.fn() })); +jest.mock('../../subfolderModule', () => ({ register: jest.fn() })); +jest.mock('../../m3uGenerator', () => ({ deleteChannelM3U: jest.fn() })); +jest.mock('../../../models/channel', () => ({ findOne: jest.fn(), update: jest.fn() })); +jest.mock('../../../models/tvshow', () => ({ findByPk: jest.fn() })); +jest.mock('../../tvShows/showStore', () => ({ findChannelShow: jest.fn(), createChannelShowAt: jest.fn(), moveShowTo: jest.fn() })); +jest.mock('../../tvShows/libraryLayouts', () => ({ setLayout: jest.fn() })); +jest.mock('../../tvShows/layoutGuards', () => ({ + usersOfFolder: jest.fn().mockResolvedValue({ channels: [{ channel_id: 'UC7' }] }), + usersOfGlobalDefault: jest.fn().mockResolvedValue({ channels: [{ channel_id: 'UC8' }] }), +})); +jest.mock('../../tvShows/channelLayout', () => ({ applyChannelFolderChange: jest.fn() })); +jest.mock('../../tvShows/libraryFolders', () => ({ syncPlexIgnore: jest.fn() })); +jest.mock('../showPlanner', () => ({ SHOW_ACTION: { KEEP: 'keep', CREATE: 'create', MOVE: 'move' } })); +jest.mock('../changeContext', () => ({ libraryFolderOf: (value, def) => (value === '##USE_GLOBAL_DEFAULT##' ? def : value || '') })); + +const layoutOf = (folder) => (folder === 'TV' ? 'tv' : 'videos'); + +describe('reorganize settingsApplier', () => { + let applier; + let showStore; + let Channel; + let TvShow; + let m3uGenerator; + let configModule; + let libraryLayouts; + let channelLayout; + let libraryFolders; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + showStore = require('../../tvShows/showStore'); + Channel = require('../../../models/channel'); + TvShow = require('../../../models/tvshow'); + m3uGenerator = require('../../m3uGenerator'); + configModule = require('../../configModule'); + libraryLayouts = require('../../tvShows/libraryLayouts'); + channelLayout = require('../../tvShows/channelLayout'); + libraryFolders = require('../../tvShows/libraryFolders'); + showStore.findChannelShow.mockResolvedValue(null); + showStore.createChannelShowAt.mockImplementation(async (values) => ({ id: 11, ...values })); + applier = require('../settingsApplier'); + }); + + const channelChange = { type: 'channel', channelId: 'UC1', subFolder: 'TV', previousSubFolder: 'Kids' }; + const plannedShow = { + ownerChannelId: 'UC1', showId: null, action: 'create', name: 'Chan', libraryFolder: 'TV', folderName: 'Chan', + previousVideosFolder: 'Kids', + }; + + it('creates a planned show and moves the channel to its new folder', async () => { + const channel = { update: jest.fn() }; + Channel.findOne.mockResolvedValue(channel); + + const shows = await applier.applySettings({ change: channelChange, shows: [plannedShow], layoutBefore: layoutOf }); + + expect(shows).toEqual([expect.objectContaining({ showId: 11 })]); + expect(showStore.createChannelShowAt).toHaveBeenCalledWith(expect.objectContaining({ folderName: 'Chan', previousVideosFolder: 'Kids' })); + expect(m3uGenerator.deleteChannelM3U).toHaveBeenCalledWith('UC1'); + expect(m3uGenerator.deleteChannelM3U.mock.invocationCallOrder[0]).toBeLessThan(channel.update.mock.invocationCallOrder[0]); + expect(channel.update).toHaveBeenCalledWith({ sub_folder: 'TV' }); + expect(channelLayout.applyChannelFolderChange).toHaveBeenCalledWith(expect.objectContaining({ + change: { oldLayout: 'videos', newLayout: 'tv', newFolder: 'TV' }, + })); + }); + + it('reuses a show already pinned when applied again after a restart', async () => { + Channel.findOne.mockResolvedValue({ update: jest.fn() }); + showStore.findChannelShow.mockResolvedValue({ id: 11, library_folder: 'tv', folder_name: 'chan', update: jest.fn() }); + + const shows = await applier.applySettings({ change: channelChange, shows: [plannedShow], layoutBefore: layoutOf }); + + expect(showStore.createChannelShowAt).not.toHaveBeenCalled(); + expect(showStore.moveShowTo).not.toHaveBeenCalled(); + expect(shows[0].showId).toBe(11); + }); + + it('moves a show that moves with its channel', async () => { + Channel.findOne.mockResolvedValue({ update: jest.fn() }); + const row = { id: 9, library_folder: 'Old TV', folder_name: 'Chan', update: jest.fn() }; + TvShow.findByPk.mockResolvedValue(row); + showStore.moveShowTo.mockResolvedValue(row); + + await applier.applySettings({ + change: channelChange, + shows: [{ ...plannedShow, showId: 9, action: 'move', previousVideosFolder: null }], + layoutBefore: layoutOf, + }); + + expect(showStore.moveShowTo).toHaveBeenCalledWith(row, expect.objectContaining({ libraryFolder: 'TV', folderName: 'Chan' })); + }); + + it('keeps the channel .m3u when the channel moves to a Videos folder', async () => { + Channel.findOne.mockResolvedValue({ update: jest.fn() }); + + await applier.applySettings({ + change: { ...channelChange, subFolder: 'Kids', previousSubFolder: 'TV' }, shows: [], layoutBefore: layoutOf, + }); + + expect(m3uGenerator.deleteChannelM3U).not.toHaveBeenCalled(); + }); + + it('switches a folder to TV, dropping its channels\' .m3u files first', async () => { + await applier.applySettings({ change: { type: 'folderLayout', folder: '', layout: 'tv', previousLayout: 'videos' }, shows: [], layoutBefore: layoutOf }); + + expect(m3uGenerator.deleteChannelM3U).toHaveBeenCalledWith('UC7'); + expect(libraryLayouts.setLayout).toHaveBeenCalledWith('', 'tv'); + expect(libraryFolders.syncPlexIgnore).toHaveBeenCalledWith('tv'); + }); + + it('changes the default subfolder', async () => { + await applier.applySettings({ change: { type: 'defaultSubfolder', value: 'TV', previousValue: 'GlobalDefault' }, shows: [], layoutBefore: layoutOf }); + + expect(m3uGenerator.deleteChannelM3U).toHaveBeenCalledWith('UC8'); + expect(configModule.updateConfig).toHaveBeenCalledWith({ x: 1, defaultSubfolder: 'TV' }); + }); + + describe('rollbackSettings', () => { + it('puts the channel and a moved show back', async () => { + const row = { id: 9 }; + TvShow.findByPk.mockResolvedValue(row); + + await applier.rollbackSettings({ + change: channelChange, + shows: [{ showId: 9, action: 'move', previousLocation: { libraryFolder: 'Old TV', folderName: 'Chan' } }], + }); + + expect(showStore.moveShowTo).toHaveBeenCalledWith(row, { libraryFolder: 'Old TV', folderName: 'Chan' }); + expect(Channel.update).toHaveBeenCalledWith({ sub_folder: 'Kids' }, { where: { channel_id: 'UC1' } }); + }); + + it('restores a folder\'s layout', async () => { + await applier.rollbackSettings({ change: { type: 'folderLayout', folder: 'Kids', layout: 'tv', previousLayout: 'videos' }, shows: [] }); + + expect(libraryLayouts.setLayout).toHaveBeenCalledWith('Kids', 'videos'); + }); + + it('restores the default subfolder', async () => { + await applier.rollbackSettings({ change: { type: 'defaultSubfolder', value: 'TV', previousValue: 'GlobalDefault' }, shows: [] }); + + expect(configModule.updateConfig).toHaveBeenCalledWith({ x: 1, defaultSubfolder: 'GlobalDefault' }); + }); + }); +}); diff --git a/server/modules/reorganize/__tests__/showPlanner.test.js b/server/modules/reorganize/__tests__/showPlanner.test.js new file mode 100644 index 000000000..50e7a03f4 --- /dev/null +++ b/server/modules/reorganize/__tests__/showPlanner.test.js @@ -0,0 +1,121 @@ +jest.mock('../../tvShows/showStore', () => ({ findChannelShow: jest.fn(), planChannelShowFolder: jest.fn() })); +jest.mock('../../tvShows/channelFolders', () => { + const path = require('path'); + return { + showDirectory: (show) => path.join('/data', show.library_folder ? `__${show.library_folder}` : '', show.folder_name), + }; +}); +jest.mock('../changeScope', () => { + const path = require('path'); + return { libraryRootOf: (folder) => (folder ? path.join('/data', `__${folder}`) : '/data') }; +}); + +const OWNER = { channel_id: 'UC1', title: 'Chan', folder_name: 'Chan', sub_folder: 'Kids', description: 'About' }; + +const subject = (overrides = {}) => ({ + video: { id: 1, youtubeId: 'aaaaaaaaaaa', filePath: '/data/__Kids/Chan/A [aaaaaaaaaaa].mp4', youTubeChannelName: 'Chan' }, + ownerChannelId: 'UC1', + ownerChannel: OWNER, + libraryFolder: 'Kids', + currentLayout: 'videos', + ...overrides, +}); + +const channelToTv = { + type: 'channel', + fromFolder: 'Kids', + toFolder: 'TV', + stored: { type: 'channel', channelId: 'UC1', subFolder: 'TV', previousSubFolder: 'Kids' }, + layoutBefore: (folder) => (folder === 'TV' ? 'tv' : 'videos'), + layoutAfter: (folder) => (folder === 'TV' ? 'tv' : 'videos'), + folderAfter: () => 'TV', +}; + +describe('reorganize showPlanner', () => { + let showPlanner; + let showStore; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + showStore = require('../../tvShows/showStore'); + showStore.findChannelShow.mockResolvedValue(null); + showStore.planChannelShowFolder.mockImplementation(async ({ folderName }) => folderName); + showPlanner = require('../showPlanner'); + }); + + it('plans a new show for a channel moving to TV, remembering its Videos folder', async () => { + const { targets, shows } = await showPlanner.planShows([subject()], channelToTv); + + expect(targets.get(1)).toEqual({ libraryFolder: 'TV', layout: 'tv' }); + expect(shows.get('UC1')).toMatchObject({ + action: 'create', libraryFolder: 'TV', folderName: 'Chan', name: 'Chan', plot: 'About', previousVideosFolder: 'Kids', + }); + }); + + it('keeps a show already at the destination', async () => { + showStore.findChannelShow.mockResolvedValue({ id: 9, name: 'Chan', library_folder: 'tv', folder_name: 'Chan', external_key: 'UC1' }); + + const { shows } = await showPlanner.planShows([subject()], channelToTv); + + expect(shows.get('UC1')).toMatchObject({ action: 'keep', showId: 9 }); + expect(showStore.planChannelShowFolder).not.toHaveBeenCalled(); + }); + + it('moves a show along with its channel to another TV folder', async () => { + showStore.findChannelShow.mockResolvedValue({ id: 9, name: 'Chan', library_folder: 'Old TV', folder_name: 'Chan', external_key: 'UC1' }); + const context = { ...channelToTv, fromFolder: 'Old TV', layoutBefore: () => 'tv', layoutAfter: () => 'tv' }; + + const { shows } = await showPlanner.planShows([subject({ libraryFolder: 'Old TV', currentLayout: 'tv' })], context); + + expect(shows.get('UC1')).toMatchObject({ + action: 'move', showId: 9, libraryFolder: 'TV', previousLocation: { libraryFolder: 'Old TV', folderName: 'Chan' }, + previousVideosFolder: null, + }); + expect(showStore.planChannelShowFolder).toHaveBeenCalledWith(expect.objectContaining({ excludeShowId: 9, libraryFolder: 'TV' })); + }); + + it('plans no show for videos going to a Videos folder', async () => { + const context = { ...channelToTv, toFolder: 'Kids', folderAfter: () => 'Kids' }; + + const { targets, shows } = await showPlanner.planShows([subject({ libraryFolder: 'TV', currentLayout: 'tv' })], context); + + expect(targets.get(1)).toEqual({ libraryFolder: 'Kids', layout: 'videos' }); + expect(shows.size).toBe(0); + }); + + describe('a folder switching to TV', () => { + const folderToTv = { + type: 'folderLayout', + folder: 'Kids', + layoutBefore: () => 'videos', + layoutAfter: (folder) => (folder === 'Kids' || folder === 'TV' ? 'tv' : 'videos'), + }; + + it('keeps its videos in the folder and names a new show after their channel folder', async () => { + const untracked = subject({ ownerChannelId: 'UCX', ownerChannel: null, video: { + id: 2, youtubeId: 'bbbbbbbbbbb', filePath: '/data/__Kids/Some Uploader/B [bbbbbbbbbbb].mp4', youTubeChannelName: 'Uploader', + } }); + + const { targets, shows } = await showPlanner.planShows([untracked], folderToTv); + + expect(targets.get(2)).toEqual({ libraryFolder: 'Kids', layout: 'tv' }); + expect(shows.get('UCX')).toMatchObject({ action: 'create', folderName: 'Some Uploader', name: 'Uploader', libraryFolder: 'Kids' }); + }); + + it('leaves an owner\'s show where it is when it already lives in a TV folder', async () => { + showStore.findChannelShow.mockResolvedValue({ id: 9, name: 'Chan', library_folder: 'TV', folder_name: 'Chan', external_key: 'UC1' }); + + const { shows } = await showPlanner.planShows([subject()], folderToTv); + + expect(shows.get('UC1')).toMatchObject({ action: 'keep', libraryFolder: 'TV' }); + }); + }); + + it('gives the absolute folders of a planned show', () => { + const planned = { libraryFolder: 'TV', folderName: 'Chan', previousLocation: { libraryFolder: '', folderName: 'Chan' } }; + + expect(showPlanner.plannedShowDirectory(planned)).toBe('/data/__TV/Chan'); + expect(showPlanner.previousShowDirectory(planned)).toBe('/data/Chan'); + }); +}); diff --git a/server/modules/reorganize/changeContext.js b/server/modules/reorganize/changeContext.js new file mode 100644 index 000000000..b19463594 --- /dev/null +++ b/server/modules/reorganize/changeContext.js @@ -0,0 +1,188 @@ +/** + * A settings change that moves downloaded files, resolved into the world + * before and after it: which folder each channel downloads to and which + * layout each library folder has. The planner works out every move from it, + * and the operation stores its normalized form so a restart can finish it. + */ + +const configModule = require('../configModule'); +const Channel = require('../../models/channel'); +const subfolderModule = require('../subfolderModule'); +const { validateSubFolderName } = require('../filesystem/subfolderValidation'); +const { resolveEffectiveSubfolder } = require('../filesystem/pathBuilder'); +const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey, isMp3Format } = require('../tvShows/constants'); +const { getLayoutResolver } = require('../tvShows/libraryLayouts'); +const layoutGuards = require('../tvShows/layoutGuards'); +const channelLayout = require('../tvShows/channelLayout'); +const { + CHANGE_CHANNEL, + CHANGE_CHANNEL_LAYOUT, + CHANGE_FOLDER_LAYOUT, + CHANGE_DEFAULT_SUBFOLDER, +} = require('./constants'); + +const LAYOUTS = new Set([LAYOUT_VIDEOS, LAYOUT_TV]); +const MAX_VALUE_LENGTH = 255; + +function badRequest(message) { + return layoutGuards.guardError(message, 400); +} + +function folderLabel(folder) { + return folder ? `__${folder}` : 'the main folder'; +} + +function optionalString(value, name) { + if (value === undefined || value === null) return null; + if (typeof value !== 'string' || value.length > MAX_VALUE_LENGTH) { + throw badRequest(`${name} must be a string`); + } + return value.trim(); +} + +function libraryFolderOf(subFolderValue, defaultFolder) { + return resolveEffectiveSubfolder(subFolderValue, defaultFolder || null) || ''; +} + +async function findChannel(channelId) { + if (typeof channelId !== 'string' || !channelId) throw badRequest('channelId is required'); + const channel = await Channel.findOne({ where: { channel_id: channelId } }); + if (!channel) throw layoutGuards.guardError('Channel not found', 404); + return channel; +} + +async function resolveChannelChange(channel, subFolderValue, base) { + const validation = validateSubFolderName(subFolderValue); + if (!validation.valid) throw badRequest(validation.error); + const subFolder = subFolderValue ? subFolderValue : null; + const fromFolder = libraryFolderOf(channel.sub_folder, base.defaultBefore); + const toFolder = libraryFolderOf(subFolder, base.defaultBefore); + if (folderKey(fromFolder) === folderKey(toFolder)) { + throw badRequest('The channel already downloads to that folder, so nothing needs to move.'); + } + const fromLayout = base.layoutBefore(fromFolder); + const toLayout = base.layoutBefore(toFolder); + if (fromLayout !== LAYOUT_TV && toLayout !== LAYOUT_TV) { + throw badRequest('Moving a channel between two Videos folders doesn\'t need a reorganize.'); + } + if (toLayout === LAYOUT_TV && isMp3Format(channel.audio_format)) { + throw badRequest(channelLayout.MESSAGES.mp3); + } + return { + ...base, + type: CHANGE_CHANNEL, + stored: { type: CHANGE_CHANNEL, channelId: channel.channel_id, subFolder, previousSubFolder: channel.sub_folder }, + scope: channel.channel_id, + label: channel.title || channel.uploader || channel.channel_id, + channel, + fromFolder, + toFolder, + layoutAfter: base.layoutBefore, + defaultAfter: base.defaultBefore, + folderAfter: (row) => (row.channel_id === channel.channel_id + ? toFolder + : libraryFolderOf(row.sub_folder, base.defaultBefore)), + }; +} + +async function resolveFolderLayoutChange(raw, base) { + const folder = optionalString(raw.folder, 'folder') || ''; + const layout = raw.layout; + if (!LAYOUTS.has(layout)) throw badRequest('layout must be "videos" or "tv"'); + if (folder) { + const known = (await subfolderModule.getAll()) + .some((display) => folderKey(display.replace(/^__/, '')) === folderKey(folder)); + if (!known) throw layoutGuards.guardError('Subfolder not found', 404); + } + if (base.layoutBefore(folder) === layout) { + throw badRequest(`${folderLabel(folder)} already has that layout.`); + } + if (layout === LAYOUT_TV) { + layoutGuards.assertNoMp3Users(await layoutGuards.usersOfFolder(folder), 'this folder'); + } + const key = folderKey(folder); + return { + ...base, + type: CHANGE_FOLDER_LAYOUT, + stored: { type: CHANGE_FOLDER_LAYOUT, folder, layout, previousLayout: base.layoutBefore(folder) }, + scope: folder, + label: folderLabel(folder), + folder, + layoutAfter: (libraryFolder) => (folderKey(libraryFolder) === key ? layout : base.layoutBefore(libraryFolder)), + defaultAfter: base.defaultBefore, + folderAfter: (row) => libraryFolderOf(row.sub_folder, base.defaultBefore), + }; +} + +async function resolveDefaultSubfolderChange(raw, base) { + const value = optionalString(raw.value, 'value') || ''; + const validation = validateSubFolderName(value); + if (!validation.valid) throw badRequest(validation.error); + if (folderKey(value) === folderKey(base.defaultBefore)) { + throw badRequest('That is already the default subfolder.'); + } + const toLayout = base.layoutBefore(value); + if (toLayout === base.layoutBefore(base.defaultBefore)) { + throw badRequest('Both folders have the same layout, so changing the default doesn\'t need a reorganize.'); + } + if (toLayout === LAYOUT_TV) { + layoutGuards.assertNoMp3Users(await layoutGuards.usersOfGlobalDefault(), 'the default subfolder'); + } + return { + ...base, + type: CHANGE_DEFAULT_SUBFOLDER, + stored: { type: CHANGE_DEFAULT_SUBFOLDER, value, previousValue: base.defaultBefore }, + scope: value, + label: 'the default subfolder', + fromFolder: base.defaultBefore, + toFolder: value, + layoutAfter: base.layoutBefore, + defaultAfter: value, + folderAfter: (row) => libraryFolderOf(row.sub_folder, value), + }; +} + +/** + * Resolve a change requested by the API. + * + * @param {Object} raw + * { type: 'channelLayout', channelId, layout, folder? } + * | { type: 'channel', channelId, subFolder } + * | { type: 'folderLayout', folder, layout } + * | { type: 'defaultSubfolder', value } + * @returns {Promise<Object>} The resolved change. Errors carry .status (400/404/409). + */ +async function resolveChange(raw) { + if (!raw || typeof raw !== 'object') throw badRequest('change is required'); + const defaultBefore = String(configModule.getDefaultSubfolder() || '').trim(); + const base = { + layoutBefore: await getLayoutResolver(), + defaultBefore, + folderBefore: (row) => libraryFolderOf(row.sub_folder, defaultBefore), + }; + + switch (raw.type) { + case CHANGE_CHANNEL_LAYOUT: { + const channel = await findChannel(raw.channelId); + const folder = optionalString(raw.folder, 'folder'); + const subFolder = await channelLayout.resolveLayoutTarget({ channel, layout: raw.layout, folder: folder === null ? undefined : folder }); + return resolveChannelChange(channel, subFolder, base); + } + case CHANGE_CHANNEL: { + const channel = await findChannel(raw.channelId); + return resolveChannelChange(channel, optionalString(raw.subFolder, 'subFolder'), base); + } + case CHANGE_FOLDER_LAYOUT: + return resolveFolderLayoutChange(raw, base); + case CHANGE_DEFAULT_SUBFOLDER: + return resolveDefaultSubfolderChange(raw, base); + default: + throw badRequest('Unknown change type'); + } +} + +module.exports = { + resolveChange, + folderLabel, + libraryFolderOf +}; diff --git a/server/modules/reorganize/changeScope.js b/server/modules/reorganize/changeScope.js new file mode 100644 index 000000000..17331207a --- /dev/null +++ b/server/modules/reorganize/changeScope.js @@ -0,0 +1,184 @@ +/** + * Which downloaded videos a change moves, and the channel that owns each. + * + * - A channel's videos: rows with its channel id, its classified episodes + * (a VEVO/Topic upload keeps the uploader's id on its row), and rows whose + * files sit in its channel or show folder. + * - A library folder's videos: every row whose file is in that folder, + * tracked or not. + * - The default subfolder's videos: those of every channel that follows it. + * + * Only rows with a file inside the downloads folder are moved; rows marked + * missing are left alone. + */ + +const path = require('path'); +const { Op } = require('sequelize'); +const Video = require('../../models/video'); +const Channel = require('../../models/channel'); +const VideoClassification = require('../../models/videoclassification'); +const configModule = require('../configModule'); +const { GUARDED_COLUMNS } = require('../videoRowGuard'); +const { resolveLibraryFolder } = require('../filesystem/showFolderCleanup'); +const { resolveChannelFolderName, buildSubfolderSegment } = require('../filesystem/pathBuilder'); +const { GLOBAL_DEFAULT_SENTINEL } = require('../filesystem/constants'); +const { LAYOUT_TV, folderKey } = require('../tvShows/constants'); +const { resolveChannelDirectory } = require('../tvShows/channelFolders'); +const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER } = require('./constants'); + +const VIDEO_ATTRIBUTES = [ + 'id', 'youtubeId', 'channel_id', 'youTubeVideoName', 'youTubeChannelName', 'originalDate', 'removed', + ...GUARDED_COLUMNS, +]; +const CHANNEL_ATTRIBUTES = [ + 'channel_id', 'title', 'uploader', 'folder_name', 'sub_folder', 'enabled', 'skip_video_folder', 'audio_format', + 'description', 'm3u_enabled', +]; + +function mediaPathOf(video) { + return video.filePath || video.audioFilePath || null; +} + +function withPrefix(dir) { + const prefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`; + return { [Op.or]: [{ filePath: { [Op.startsWith]: prefix } }, { audioFilePath: { [Op.startsWith]: prefix } }] }; +} + +function isUnder(filePath, dir) { + const relative = path.relative(dir, filePath); + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +async function loadChannels(where = {}) { + const rows = await Channel.findAll({ where, attributes: CHANNEL_ATTRIBUTES }); + return new Map(rows.map((row) => [row.channel_id, row])); +} + +async function classificationsOf(youtubeIds) { + if (youtubeIds.length === 0) return new Map(); + const rows = await VideoClassification.findAll({ + where: { youtube_id: youtubeIds }, + attributes: ['youtube_id', 'channel_id', 'show_id', 'status', 'season', 'episode', 'source', 'file_stem', 'episode_title'], + }); + return new Map(rows.map((row) => [row.youtube_id, row])); +} + +function toSubject(video, ownerChannelId, channels, context) { + const mediaPath = mediaPathOf(video); + const located = mediaPath ? resolveLibraryFolder(mediaPath, configModule.directoryPath) : null; + if (!located) return null; + return { + video, + ownerChannelId, + ownerChannel: channels.get(ownerChannelId) || null, + libraryFolder: located.libraryFolder, + currentLayout: context.layoutBefore(located.libraryFolder), + }; +} + +async function videosOfChannel(channel, context, channels) { + const { dir } = await resolveChannelDirectory(channel, { layoutOf: context.layoutBefore }); + const episodeIds = (await VideoClassification.findAll({ where: { channel_id: channel.channel_id }, attributes: ['youtube_id'] })) + .map((row) => row.youtube_id); + const episodeIdSet = new Set(episodeIds); + const matches = [{ channel_id: channel.channel_id }]; + if (episodeIds.length > 0) matches.push({ youtubeId: episodeIds }); + if (dir) matches.push(withPrefix(dir)); + // LIKE treats _ and % in a folder name as wildcards; the prefix query only + // narrows the rows, the path check decides. + const videos = (await Video.findAll({ + where: { removed: false, [Op.or]: matches }, + attributes: VIDEO_ATTRIBUTES, + raw: true, + })).filter((video) => video.channel_id === channel.channel_id + || episodeIdSet.has(video.youtubeId) + || (dir && [video.filePath, video.audioFilePath].some((filePath) => filePath && isUnder(filePath, dir)))); + const classifications = await classificationsOf(videos.map((video) => video.youtubeId)); + + const subjects = []; + for (const video of videos) { + const subject = toSubject(video, channel.channel_id, channels, context); + if (!subject) continue; + // An episode in another channel's show (its owner by classification) + // moves with that channel, not this one. + const classification = classifications.get(video.youtubeId); + if (subject.currentLayout === LAYOUT_TV && classification && classification.channel_id !== channel.channel_id) continue; + subjects.push(subject); + } + return subjects; +} + +function libraryRootOf(libraryFolder) { + const base = configModule.directoryPath; + return libraryFolder ? path.join(base, buildSubfolderSegment(libraryFolder)) : base; +} + +// Owner of a video found in a library folder: its show's channel in a TV +// folder; in a videos folder the tracked channel whose folder holds it (a +// VEVO/Topic upload lives in its owner's folder); else the uploader. +function ownerInFolder(video, { libraryFolder, layout, classifications, channelByFolder }) { + const classification = classifications.get(video.youtubeId); + if (layout === LAYOUT_TV && classification) return classification.channel_id; + if (layout !== LAYOUT_TV) { + const relative = path.relative(libraryRootOf(libraryFolder), mediaPathOf(video)); + const channelFolder = relative.split(path.sep)[0]; + const owner = channelByFolder.get(channelFolder.toLowerCase()); + if (owner) return owner; + } + return video.channel_id; +} + +async function videosOfFolder(libraryFolder, context, channels) { + const root = libraryRootOf(libraryFolder); + const videos = (await Video.findAll({ where: { removed: false, ...withPrefix(root) }, attributes: VIDEO_ATTRIBUTES, raw: true })) + .filter((video) => { + const located = resolveLibraryFolder(mediaPathOf(video), configModule.directoryPath); + return located && folderKey(located.libraryFolder) === folderKey(libraryFolder); + }); + const classifications = await classificationsOf(videos.map((video) => video.youtubeId)); + const channelByFolder = new Map(); + for (const channel of channels.values()) { + const name = resolveChannelFolderName(channel); + if (name && folderKey(context.folderBefore(channel)) === folderKey(libraryFolder)) { + channelByFolder.set(name.toLowerCase(), channel.channel_id); + } + } + const layout = context.layoutBefore(libraryFolder); + return videos + .map((video) => toSubject(video, ownerInFolder(video, { libraryFolder, layout, classifications, channelByFolder }), channels, context)) + .filter(Boolean); +} + +/** + * @param {Object} context - changeContext.resolveChange's result + * @returns {Promise<{subjects: Array<Object>, channels: Map<string, Object>}>} + * Each subject: { video, ownerChannelId, ownerChannel, libraryFolder, currentLayout } + */ +async function selectSubjects(context) { + const channels = await loadChannels(); + let subjects = []; + if (context.type === CHANGE_CHANNEL) { + subjects = await videosOfChannel(channels.get(context.channel.channel_id) || context.channel, context, channels); + } else if (context.type === CHANGE_FOLDER_LAYOUT) { + subjects = await videosOfFolder(context.folder, context, channels); + } else if (context.type === CHANGE_DEFAULT_SUBFOLDER) { + for (const channel of channels.values()) { + if (channel.sub_folder !== GLOBAL_DEFAULT_SENTINEL) continue; + subjects.push(...await videosOfChannel(channel, context, channels)); + } + } + + const seen = new Set(); + const unique = subjects.filter((subject) => { + if (seen.has(subject.video.id)) return false; + seen.add(subject.video.id); + return true; + }); + return { subjects: unique, channels }; +} + +module.exports = { + selectSubjects, + mediaPathOf, + libraryRootOf +}; diff --git a/server/modules/reorganize/constants.js b/server/modules/reorganize/constants.js new file mode 100644 index 000000000..7c1f54e6c --- /dev/null +++ b/server/modules/reorganize/constants.js @@ -0,0 +1,61 @@ +/** + * Reorganize constants, dependency-free. + */ + +// Kinds of settings change a reorganize applies. +const CHANGE_CHANNEL = 'channel'; +const CHANGE_FOLDER_LAYOUT = 'folderLayout'; +const CHANGE_DEFAULT_SUBFOLDER = 'defaultSubfolder'; +// Accepted on the API and normalized to CHANGE_CHANNEL with the folder the +// Channel Settings layout toggle picks. +const CHANGE_CHANNEL_LAYOUT = 'channelLayout'; + +const OPERATION_STATUS = Object.freeze({ + RUNNING: 'running', + COMPLETED: 'completed', + PARTIAL: 'partial', + FAILED: 'failed', +}); + +const ITEM_STATUS = Object.freeze({ + PENDING: 'pending', + DONE: 'done', + FAILED: 'failed', +}); + +// Why a video can't be moved, reported by the preview. +const PROBLEM = Object.freeze({ + MISSING: 'missing', + COLLISION: 'collision', + NO_NAME: 'no-name', + NO_DATE: 'no-date', + UNSAFE_NAME: 'unsafe-name', +}); + +// Notes about a planned move, reported by the preview. +const FLAG = Object.freeze({ + OVERRIDE_PLACED: 'override-placed', + ADOPTED: 'adopted', + UPLOAD_DATE_ONLY: 'upload-date-only', + DOWNLOAD_TIME: 'download-time', + MOVIE_TAGS: 'movie-tags', +}); + +// The preview lists at most this many moves and problems; totals are complete. +const PREVIEW_ITEM_LIMIT = 200; + +// WebSocket message type for operation progress. +const PROGRESS_MESSAGE_TYPE = 'tvReorganizeProgress'; + +module.exports = { + CHANGE_CHANNEL, + CHANGE_FOLDER_LAYOUT, + CHANGE_DEFAULT_SUBFOLDER, + CHANGE_CHANNEL_LAYOUT, + OPERATION_STATUS, + ITEM_STATUS, + PROBLEM, + FLAG, + PREVIEW_ITEM_LIMIT, + PROGRESS_MESSAGE_TYPE +}; diff --git a/server/modules/reorganize/destinationPlanner.js b/server/modules/reorganize/destinationPlanner.js new file mode 100644 index 000000000..ce9e1793d --- /dev/null +++ b/server/modules/reorganize/destinationPlanner.js @@ -0,0 +1,374 @@ +/** + * Every file move of a reorganize. For each video: its destination folder and + * file stem (an episode stem in a TV show, or the movie-style names a fresh + * download would get), its episode assignment, and each of its files' source + * and destination. Nothing is written; the result feeds the preview and, + * recomputed under the lock, the operation. + * + * Numbering matches the post-processor's: a video keeps a number it already + * holds in the show; otherwise files named by the Plex TV Series preset keep + * their code when it is free, and the rest are numbered by upload time. + */ + +const fs = require('fs'); +const path = require('path'); +const configModule = require('../configModule'); +const VideoClassification = require('../../models/videoclassification'); +const videoInfoStore = require('../videoInfoStore'); +const downloadSettingsResolver = require('../download/downloadSettingsResolver'); +const { isFileForVideo, buildChannelPath } = require('../filesystem/pathBuilder'); +const { NO_CLOBBER_STAGING_SUFFIX } = require('../filesystem/fileOperations'); +const { YOUTUBE_ID_BRACKET_PATTERN } = require('../filesystem/constants'); +const { LAYOUT_TV, folderKey } = require('../tvShows/constants'); +const { STATUS_ASSIGNED } = require('../tvShows/episodeAllocator'); +const { releaseTime, parseDateEpisodeCode, assignDateEpisodes, SOURCE_UPLOAD_DATE } = require('../tvShows/dateNumbering'); +const { buildEpisodeStem, episodeFileName, seasonFolderName } = require('../tvShows/episodeNaming'); +const { renderMovieNames } = require('./movieNameRenderer'); +const { plannedShowDirectory } = require('./showPlanner'); +const { PROBLEM, FLAG, CHANGE_FOLDER_LAYOUT } = require('./constants'); + +// Partial downloads and copies left by an interrupted move are not the video's files. +const LEFTOVER_SUFFIXES = ['.part', NO_CLOBBER_STAGING_SUFFIX]; +const MOVIE_TAG_EXTENSIONS = new Set(['.mp4', '.m4v', '.mov']); + +// The files of each directory a plan reads, bucketed by the video id in +// their name, read once per plan: a flat channel folder holds every file of +// thousands of videos, and listing it once per video made planning +// quadratic. +class DirectoryIndex { + constructor() { + this.byDir = new Map(); + } + + async namesFor(dir, youtubeId) { + let byId = this.byDir.get(dir); + if (!byId) { + byId = new Map(); + for (const name of await fs.promises.readdir(dir)) { + const match = YOUTUBE_ID_BRACKET_PATTERN.exec(name); + if (!match) continue; + if (!byId.has(match[1])) byId.set(match[1], []); + byId.get(match[1]).push(name); + } + this.byDir.set(dir, byId); + } + return (byId.get(youtubeId) || []).filter((name) => isFileForVideo(name, youtubeId) + && !LEFTOVER_SUFFIXES.some((suffix) => name.endsWith(suffix))); + } +} + +async function statFile(filePath) { + try { + const stat = await fs.promises.stat(filePath); + return { size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) }; + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +} + +async function existsOtherThan(target, source) { + if (target === source) return false; + return (await statFile(target)) !== null; +} + +/** + * Plan the files of one video given its destination folder and stem. + * @param {Object} video - videos row + * @param {string} destDir + * @param {string} stem + * @param {DirectoryIndex} [index] - Shared across the videos of one plan + * @returns {Promise<Object>} { files, nfoSources, newVideoPath, newAudioPath, unchanged, missing, collisions } + */ +async function planFiles(video, destDir, stem, index = new DirectoryIndex()) { + const sourceDirs = [...new Set([video.filePath, video.audioFilePath].filter(Boolean).map((p) => path.dirname(p)))]; + const files = []; + const nfoSources = []; + for (const dir of sourceDirs) { + let names; + try { + names = await index.namesFor(dir, video.youtubeId); + } catch (err) { + if (err.code === 'ENOENT') return { missing: true }; + throw err; + } + for (const name of names) { + const from = path.join(dir, name); + // The NFO is written fresh for the new layout. + if (name.endsWith('.nfo')) { + nfoSources.push(from); + continue; + } + if (files.some((file) => file.from === from)) continue; + const stat = await statFile(from); + if (!stat) continue; + files.push({ from, to: path.join(destDir, episodeFileName(name, video.youtubeId, stem)), ...stat }); + } + } + const mediaPath = video.filePath || video.audioFilePath; + if (!files.some((file) => file.from === mediaPath)) return { missing: true }; + + const destinationOf = (source) => (source ? (files.find((file) => file.from === source) || {}).to || null : null); + const collisions = []; + for (const file of files) { + if (await existsOtherThan(file.to, file.from)) collisions.push(file.to); + } + return { + files, + nfoSources, + sourceDirs, + newVideoPath: destinationOf(video.filePath), + newAudioPath: destinationOf(video.audioFilePath), + unchanged: files.every((file) => file.to === file.from), + collisions, + }; +} + +async function storedClassifications(subjects) { + const ids = subjects.map((subject) => subject.video.youtubeId); + if (ids.length === 0) return new Map(); + const rows = await VideoClassification.findAll({ where: { youtube_id: ids } }); + return new Map(rows.map((row) => [row.youtube_id, row])); +} + +async function takenNumbers(showId, excludedIds) { + const taken = new Map(); + if (!showId) return taken; + const rows = await VideoClassification.findAll({ where: { show_id: showId }, attributes: ['youtube_id', 'season', 'episode'] }); + for (const row of rows) { + if (excludedIds.has(row.youtube_id) || row.season === null || row.episode === null) continue; + if (!taken.has(row.season)) taken.set(row.season, new Set()); + taken.get(row.season).add(row.episode); + } + return taken; +} + +function episodeTitleOf(info, video) { + return String(info.fulltitle || info.title || video.youTubeVideoName || '').trim() || null; +} + +function assignment(youtubeId, { season, episode, source, timestampSource, episodeTitle }, show) { + return { + ownerChannelId: show.ownerChannelId, + showTitle: show.name, + season, + episode, + source, + timestampSource, + episodeTitle, + fileStem: buildEpisodeStem({ season, episode, dateNumbered: true, videoTitle: episodeTitle, youtubeId }), + }; +} + +/** + * Episode assignments for the videos going to one show. + * @returns {Promise<{assignments: Map<string, Object>, flags: Map<string, string[]>, noDate: Set<string>}>} + */ +async function numberShow(show, entries, stored) { + const assignments = new Map(); + const flags = new Map(); + const noDate = new Set(); + const toNumber = []; + + for (const { subject, info } of entries) { + const { video } = subject; + const row = stored.get(video.youtubeId); + if (show.showId && row && row.show_id === show.showId && row.status === STATUS_ASSIGNED + && row.season !== null && row.episode !== null && row.file_stem) { + assignments.set(video.youtubeId, { + ownerChannelId: show.ownerChannelId, showTitle: show.name, season: row.season, episode: row.episode, + source: row.source, timestampSource: row.timestamp_source, episodeTitle: row.episode_title, fileStem: row.file_stem, + }); + continue; + } + const adoptedCode = parseDateEpisodeCode(path.basename(video.filePath || video.audioFilePath)); + let numberingInfo = info; + const itemFlags = []; + const time = releaseTime(info); + if (!adoptedCode && !time) { + if (!video.last_downloaded_at) { + noDate.add(video.youtubeId); + continue; + } + // Like the post-processor: no upload time, so the download time. + numberingInfo = { ...info, timestamp: Math.floor(new Date(video.last_downloaded_at).getTime() / 1000) }; + itemFlags.push(FLAG.DOWNLOAD_TIME); + } else if (!adoptedCode && time.source === SOURCE_UPLOAD_DATE) { + itemFlags.push(FLAG.UPLOAD_DATE_ONLY); + } + flags.set(video.youtubeId, itemFlags); + toNumber.push({ youtubeId: video.youtubeId, info: numberingInfo, adoptedCode, video, title: episodeTitleOf(info, video) }); + } + + const taken = await takenNumbers(show.showId, new Set(toNumber.map((entry) => entry.youtubeId))); + for (const kept of assignments.values()) { + if (!taken.has(kept.season)) taken.set(kept.season, new Set()); + taken.get(kept.season).add(kept.episode); + } + const { assigned } = assignDateEpisodes(toNumber, taken); + const byId = new Map(toNumber.map((entry) => [entry.youtubeId, entry])); + for (const result of assigned) { + const entry = byId.get(result.youtubeId); + const downloadTime = flags.get(result.youtubeId).includes(FLAG.DOWNLOAD_TIME); + if (result.source === 'adopted') flags.get(result.youtubeId).push(FLAG.ADOPTED); + assignments.set(result.youtubeId, assignment(result.youtubeId, { + season: result.season, + episode: result.episode, + source: result.source, + timestampSource: downloadTime ? null : result.timestampSource, + episodeTitle: entry.title, + }, show)); + } + return { assignments, flags, noDate }; +} + +function problemOf(subject, kind, detail = null) { + const { video } = subject; + return { videoId: video.id, youtubeId: video.youtubeId, title: video.youTubeVideoName, problem: kind, detail }; +} + +// A destination must stay inside the downloads folder, whatever name a +// channel or rendered template contributed to it. +function isInside(baseDir, dir) { + const relative = path.relative(baseDir, dir); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +/** + * Plan every video's move. + * + * @param {Object} params + * @param {Array<Object>} params.subjects - changeScope subjects + * @param {Object} params.context - resolved change + * @param {Map<number, {libraryFolder: string, layout: string}>} params.targets - showPlanner targets + * @param {Map<string, Object>} params.shows - showPlanner shows by owner channel id + * @returns {Promise<{items: Array<Object>, problems: Array<Object>, unchanged: number}>} + */ +async function planDestinations({ subjects, context, targets, shows }) { + const config = configModule.getConfig() || {}; + const baseDir = configModule.directoryPath; + const infos = new Map(); + for (const subject of subjects) infos.set(subject.video.youtubeId, await videoInfoStore.readInfoOrFallback(subject.video)); + + const tvEntries = new Map(); + const movieSubjects = []; + for (const subject of subjects) { + const target = targets.get(subject.video.id); + if (target.layout === LAYOUT_TV) { + const owner = subject.ownerChannelId; + if (!tvEntries.has(owner)) tvEntries.set(owner, []); + tvEntries.get(owner).push({ subject, info: infos.get(subject.video.youtubeId) }); + } else { + movieSubjects.push(subject); + } + } + + const stored = await storedClassifications(subjects); + const assignments = new Map(); + const flags = new Map(); + const noDate = new Set(); + for (const [owner, entries] of tvEntries) { + const numbered = await numberShow(shows.get(owner), entries, stored); + for (const [id, value] of numbered.assignments) assignments.set(id, value); + for (const [id, value] of numbered.flags) flags.set(id, value); + for (const id of numbered.noDate) noDate.add(id); + } + const names = await renderMovieNames(movieSubjects.map((subject) => ({ + youtubeId: subject.video.youtubeId, info: infos.get(subject.video.youtubeId), + }))); + + const items = []; + const problems = []; + let unchanged = 0; + const index = new DirectoryIndex(); + for (const subject of subjects) { + const { video } = subject; + const target = targets.get(video.id); + let destDir; + let stem; + let classification = null; + if (target.layout === LAYOUT_TV) { + if (noDate.has(video.youtubeId)) { + problems.push(problemOf(subject, PROBLEM.NO_DATE)); + continue; + } + classification = assignments.get(video.youtubeId); + stem = classification.fileStem; + destDir = path.join(plannedShowDirectory(shows.get(subject.ownerChannelId)), seasonFolderName(classification.season)); + } else { + const rendered = names.get(video.youtubeId); + if (!rendered) { + problems.push(problemOf(subject, PROBLEM.NO_NAME)); + continue; + } + // The channel's folder as downloads name it (folder_name is yt-dlp's + // sanitized name); a channel row without one gets the rendered name, + // never the raw uploader, which can hold path separators. + const channelFolder = (subject.ownerChannel && subject.ownerChannel.folder_name) || rendered.channelFolder; + const flat = downloadSettingsResolver.resolveSkipVideoFolder({ channel: subject.ownerChannel, config }); + let channelDir; + try { + channelDir = buildChannelPath(baseDir, target.libraryFolder || null, channelFolder); + } catch (err) { + // buildChannelPath refuses a path that leaves the downloads folder. + problems.push(problemOf(subject, PROBLEM.UNSAFE_NAME)); + continue; + } + destDir = flat ? channelDir : path.join(channelDir, rendered.videoFolder); + stem = rendered.stem; + } + if (!isInside(baseDir, destDir)) { + problems.push(problemOf(subject, PROBLEM.UNSAFE_NAME)); + continue; + } + + const planned = await planFiles(video, destDir, stem, index); + if (planned.missing) { + problems.push(problemOf(subject, PROBLEM.MISSING)); + continue; + } + if (planned.unchanged) { + unchanged += 1; + continue; + } + const itemFlags = [...(flags.get(video.youtubeId) || [])]; + if (context.type !== CHANGE_FOLDER_LAYOUT && subject.ownerChannel + && folderKey(subject.libraryFolder) !== folderKey(context.folderBefore(subject.ownerChannel))) { + itemFlags.push(FLAG.OVERRIDE_PLACED); + } + if (classification && subject.currentLayout !== LAYOUT_TV + && planned.files.some((file) => file.from === video.filePath && MOVIE_TAG_EXTENSIONS.has(path.extname(file.from).toLowerCase()))) { + itemFlags.push(FLAG.MOVIE_TAGS); + } + for (const collision of planned.collisions) problems.push(problemOf(subject, PROBLEM.COLLISION, collision)); + + items.push({ + videoId: video.id, + youtubeId: video.youtubeId, + channelId: subject.ownerChannelId, + title: video.youTubeVideoName, + fromLibraryFolder: subject.libraryFolder, + fromLayout: subject.currentLayout, + libraryFolder: target.libraryFolder, + layout: target.layout, + destDir, + files: planned.files, + nfoSources: planned.nfoSources, + sourceDirs: planned.sourceDirs, + oldVideoPath: video.filePath || null, + newVideoPath: planned.newVideoPath, + oldAudioPath: video.audioFilePath || null, + newAudioPath: planned.newAudioPath, + classification, + flags: itemFlags, + }); + } + items.sort((a, b) => (a.youtubeId < b.youtubeId ? -1 : a.youtubeId > b.youtubeId ? 1 : 0)); + return { items, problems, unchanged }; +} + +module.exports = { + planDestinations, + planFiles, + numberShow +}; diff --git a/server/modules/reorganize/followUp.js b/server/modules/reorganize/followUp.js new file mode 100644 index 000000000..9957eecc6 --- /dev/null +++ b/server/modules/reorganize/followUp.js @@ -0,0 +1,142 @@ +/** + * Work after a reorganize's videos moved. + * + * While the lock is still held (files and folders): + * - each show that received episodes gets its tvshow.nfo and art; + * - each channel folder that received videos gets the channel art; + * - channel and show folders the videos left are removed once they hold + * nothing but metadata and art; + * - channels now in a Videos folder get their channel .m3u regenerated. + * + * After the lock is released (servers and playlists, slow and network-bound): + * - the __playlists__ .m3u files and media-server playlists that contain a + * moved video are regenerated and re-synced; + * - the old and new libraries are refreshed; + * - held watch state is pushed back to the servers. + */ + +const path = require('path'); +const configModule = require('../configModule'); +const plexModule = require('../plexModule'); +const m3uGenerator = require('../m3uGenerator'); +const logger = require('../../logger'); +const Channel = require('../../models/channel'); +const TvShow = require('../../models/tvshow'); +const Playlist = require('../../models/playlist'); +const PlaylistVideo = require('../../models/playlistvideo'); +const sidecarWriter = require('../sidecarWriter'); +const mediaServerSync = require('../mediaServers/mediaServerSync'); +const serverRegistry = require('../mediaServers/serverRegistry'); +const watchStatusPushBack = require('../mediaServers/watchStatusPushBack'); +const { cleanupEmptyChannelDirectory } = require('../filesystem/directoryManager'); +const { cleanupOrphanShowFolder, resolveLibraryFolder } = require('../filesystem/showFolderCleanup'); +const { LAYOUT_TV } = require('../tvShows/constants'); +const { showDirectory } = require('../tvShows/channelFolders'); + +// The channel or show folder a path sits in: the first folder inside its +// library folder. +function rootFolderOf(filePath) { + const located = resolveLibraryFolder(filePath, configModule.directoryPath); + if (!located) return null; + const first = path.relative(located.libraryRoot, filePath).split(path.sep)[0]; + return first ? path.join(located.libraryRoot, first) : null; +} + +async function safely(label, context, action) { + try { + await action(); + } catch (err) { + logger.warn({ err, ...context }, label); + } +} + +/** + * @param {Object} params + * @param {Array<Object>} params.items - Done items, each { channelId, plan, classification } + * @param {Array<Object>} params.shows - Pinned shows (with showId) + */ +async function finishFiles({ items, shows }) { + const showIds = new Set(items.filter((item) => item.classification).map((item) => item.classification.ownerChannelId)); + for (const planned of shows) { + if (!planned.showId || !showIds.has(planned.ownerChannelId)) continue; + await safely('Could not write the show metadata after a reorganize', { showId: planned.showId }, async () => { + const show = await TvShow.findByPk(planned.showId); + if (show) await sidecarWriter.writeShowMetadata({ show, showDir: showDirectory(show), plot: planned.plot || null }); + }); + } + + const channelFolders = new Map(); + const leftFolders = new Map(); + for (const { channelId, plan } of items) { + const destRoot = rootFolderOf(plan.newVideoPath || plan.newAudioPath); + if (plan.layout !== LAYOUT_TV && destRoot) channelFolders.set(destRoot, channelId); + const sourceRoot = rootFolderOf(plan.oldVideoPath || plan.oldAudioPath); + if (sourceRoot && sourceRoot !== destRoot) leftFolders.set(sourceRoot, plan.fromLayout); + } + for (const [folderPath, channelId] of channelFolders) { + sidecarWriter.writeFolderArt({ channelId, folderPath }); + } + for (const [folderPath, layout] of leftFolders) { + await safely('Could not remove a folder a reorganize emptied', { folderPath }, async () => { + if (layout === LAYOUT_TV) await cleanupOrphanShowFolder(folderPath); + else await cleanupEmptyChannelDirectory(folderPath, configModule.directoryPath, { includeIgnorableFiles: true }); + }); + } + + const videoChannels = [...new Set(items.filter((item) => item.plan.layout !== LAYOUT_TV).map((item) => item.channelId))]; + const tracked = videoChannels.length > 0 + ? await Channel.findAll({ where: { channel_id: videoChannels, m3u_enabled: true }, attributes: ['channel_id'] }) + : []; + for (const channel of tracked) { + await safely('Could not regenerate the channel .m3u after a reorganize', { channelId: channel.channel_id }, + () => m3uGenerator.generateChannelM3U(channel.channel_id)); + } +} + +async function refreshPlaylists(youtubeIds) { + if (youtubeIds.length === 0) return; + const rows = await PlaylistVideo.findAll({ where: { youtube_id: youtubeIds }, attributes: ['playlist_id'] }); + const playlistIds = [...new Set(rows.map((row) => row.playlist_id))]; + if (playlistIds.length === 0) return; + const playlists = await Playlist.findAll({ where: { playlist_id: playlistIds, enabled: true } }); + for (const playlist of playlists) { + await safely('Could not regenerate a playlist .m3u after a reorganize', { playlistId: playlist.id }, + () => m3uGenerator.generatePlaylistM3U(playlist.id)); + await safely('Could not re-sync a media server playlist after a reorganize', { playlistId: playlist.id }, + () => mediaServerSync.syncPlaylist(playlist.id)); + } +} + +async function refreshLibraries(libraryFolders) { + await safely('Could not refresh the Plex libraries after a reorganize', {}, + () => plexModule.refreshLibrariesForSubfolders([...libraryFolders].map((folder) => folder || null))); + const adapters = serverRegistry.getEnabledAdapters(configModule.getConfig()) + .filter((adapter) => adapter.serverType !== 'plex'); + for (const adapter of adapters) { + await safely('Could not refresh a media server library after a reorganize', { serverType: adapter.serverType }, + () => adapter.triggerLibraryScan(null)); + } +} + +/** + * @param {Object} params + * @param {Array<Object>} params.items - Done items, each { youtubeId, plan } + */ +async function finishServers({ items }) { + await refreshPlaylists(items.map((item) => item.youtubeId)); + const folders = new Set(); + for (const { plan } of items) { + folders.add(plan.fromLibraryFolder || ''); + folders.add(plan.libraryFolder || ''); + } + if (folders.size > 0) await refreshLibraries(folders); + // Pushes whatever holds are pending, this operation's or an earlier one's + // (a resumed operation's holds were created before the restart). + watchStatusPushBack.scheduleFollowUps(); +} + +module.exports = { + finishFiles, + finishServers, + rootFolderOf +}; diff --git a/server/modules/reorganize/index.js b/server/modules/reorganize/index.js new file mode 100644 index 000000000..710a022b8 --- /dev/null +++ b/server/modules/reorganize/index.js @@ -0,0 +1,83 @@ +/** + * Reorganize: move already-downloaded files to where changed settings say + * they belong, when a channel, a library folder or the default subfolder + * switches between the Videos and TV layouts. + * + * - reorganizeLock: the running reorganize; holds downloads, refuses tasks and changes + * - changeContext: a settings change resolved into the world before and after it + * - changeScope: which videos a change moves, and their owner channels + * - showPlanner: destinations, and the shows a change creates or moves + * - destinationPlanner: every video's files, names and episode numbers + * - movieNameRenderer: movie-style names rendered by yt-dlp from stored info + * - revision: the plan's revision token + * - planner: the plan and the preview built from it + * - operationStore: the persistent operation record + * - settingsApplier: applying (and undoing) the settings change + * - itemExecutor: moving one video + * - followUp: metadata, cleanup, playlists, library refresh, push-back + * - operationRunner: starting, running, retrying and resuming operations + */ + +const reorganizeLock = require('./reorganizeLock'); +const operationRunner = require('./operationRunner'); +const operationStore = require('./operationStore'); + +const { EXCLUSIVE_TASKS } = operationRunner; + +class ReorganizeModule { + constructor() { + this.lock = reorganizeLock; + } + + /** + * Wire the modules the runner checks and refuses. Call once at startup. + * @param {Object} deps - jobModule, scheduledTaskManager, videosModule, mediaServerSync, watchStatusSync + */ + initialize(deps) { + operationRunner.initialize(deps); + deps.scheduledTaskManager.setExclusiveBlocker((taskId) => ( + Object.prototype.hasOwnProperty.call(EXCLUSIVE_TASKS, taskId) ? reorganizeLock.runBlocker() : null + )); + } + + preview(change) { + return operationRunner.preview(change); + } + + start(change, revision) { + return operationRunner.start(change, revision); + } + + retry(operationId) { + return operationRunner.retry(operationId); + } + + getOperation(operationId) { + return operationStore.getOperationView(operationId); + } + + /** The running operation, or null. */ + async getActive() { + const active = reorganizeLock.getActive(); + if (!active) return null; + if (!active.operationId) return { id: null, label: active.label, status: 'starting' }; + return operationStore.getOperationView(active.operationId); + } + + /** + * A channel's reorganize state for Channel Settings. + * @returns {Promise<{running: boolean, unmoved: Object|null}>} + */ + async channelState(channelId) { + return { + running: reorganizeLock.coversChannel(channelId), + unmoved: await operationStore.unmovedForChannel(channelId), + }; + } + + recoverInterrupted() { + return operationRunner.recover(); + } +} + +module.exports = new ReorganizeModule(); diff --git a/server/modules/reorganize/itemExecutor.js b/server/modules/reorganize/itemExecutor.js new file mode 100644 index 000000000..0a45c6e84 --- /dev/null +++ b/server/modules/reorganize/itemExecutor.js @@ -0,0 +1,227 @@ +/** + * Moves one video of a reorganize. Every step can run again after a restart: + * a file already at its destination counts as moved, the NFO is rewritten, + * and the database update is skipped when the row already has the new paths. + * + * Order: the episode number is stored first (so it is held), then every + * file moves (a failure part-way moves the earlier files back), then the + * NFO and art are written, the old NFO removed, the videos row and the + * stored info.json updated, and emptied source folders removed. + */ + +const fs = require('fs'); +const path = require('path'); +const Video = require('../../models/video'); +const VideoClassification = require('../../models/videoclassification'); +const configModule = require('../configModule'); +const logger = require('../../logger'); +const videoInfoStore = require('../videoInfoStore'); +const sidecarWriter = require('../sidecarWriter'); +const { unchangedSinceRead, GUARDED_COLUMNS } = require('../videoRowGuard'); +const { moveFileNoClobber } = require('../filesystem/fileOperations'); +const { isDirectoryEffectivelyEmpty, removeDirectoryResilient, isVideoDirectoryFor } = require('../filesystem/directoryManager'); +const { resolveLibraryFolder, locateEpisodeFolders, cleanupEmptyShowFolders } = require('../filesystem/showFolderCleanup'); +const { LAYOUT_TV } = require('../tvShows/constants'); +const { STATUS_ASSIGNED } = require('../tvShows/episodeAllocator'); + +function itemError(message) { + return new Error(message); +} + +// A failure after the files reached their destination: the runner must treat +// the video as moved (its settings and holds stay), and a retry finishes it. +// (A failure before the files were touched leaves `filesMoved` unset, and the +// runner keeps what it already knows about them.) +function movedButUnfinished(err) { + const wrapped = new Error(`The video's files were moved, but finishing failed: ${err.message}. Retry to finish.`); + wrapped.filesMoved = true; + wrapped.cause = err; + return wrapped; +} + +function nfoPathOf(filePath) { + const parsed = path.parse(filePath); + return path.join(parsed.dir, `${parsed.name}.nfo`); +} + +// The planner already refuses these; checked again here, where the files move. +function assertInsideDownloads(files) { + const baseDir = configModule.directoryPath; + for (const file of files) { + const relative = path.relative(baseDir, file.to); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw itemError('The destination is outside the downloads folder.'); + } + } +} + +async function loadVideo(videoId) { + return Video.findByPk(videoId, { + attributes: ['id', 'youtubeId', 'youTubeVideoName', 'youTubeChannelName', 'originalDate', ...GUARDED_COLUMNS, 'removed'], + raw: true, + }); +} + +async function storeClassification(youtubeId, classification, showId) { + const values = { + channel_id: classification.ownerChannelId, + show_id: showId, + status: STATUS_ASSIGNED, + season: classification.season, + episode: classification.episode, + source: classification.source, + timestamp_source: classification.timestampSource || null, + pattern_id: null, + episode_title: classification.episodeTitle, + file_stem: classification.fileStem, + }; + const row = await VideoClassification.findByPk(youtubeId); + if (row) { + await row.update(values); + } else { + await VideoClassification.create({ youtube_id: youtubeId, ...values }); + } +} + +async function statOrNull(filePath) { + try { + return await fs.promises.stat(filePath); + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null; + throw err; + } +} + +// A file whose source is gone while its destination exists is at the destination. +async function anyFileAtDestination(files) { + for (const file of files) { + if (!(await statOrNull(file.from)) && (await statOrNull(file.to))) return true; + } + return false; +} + +/** + * Move every file. On a failure, every file at its destination goes back: + * the ones this attempt moved and the ones an earlier, interrupted attempt + * left there, so the video is either moved or home, never split. The error + * then says where the files are (`filesMoved` true: some file could not be + * brought back; false: every file is back at its source). + * + * @param {Array<{from: string, to: string}>} files + * @param {Set<string>} mediaPaths - The video/audio sources; a sidecar that + * vanished since the preview is nothing to move, the media must be there + */ +async function moveFiles(files, mediaPaths) { + const atDestination = []; + try { + for (const file of files) { + try { + const result = await moveFileNoClobber(file.from, file.to); + if (result === 'moved' || result === 'already-moved') atDestination.push(file); + } catch (err) { + if (err.code !== 'ENOENT' || mediaPaths.has(file.from)) throw err; + logger.warn({ file: file.from }, 'A file of the video is gone since the preview; moving the rest'); + } + } + } catch (err) { + for (const file of atDestination.reverse()) { + try { + await moveFileNoClobber(file.to, file.from); + } catch (rollbackErr) { + logger.error({ err: rollbackErr, from: file.to, to: file.from }, 'Could not move a file back after a failed reorganize move'); + } + } + err.filesMoved = await anyFileAtDestination(files); + throw err; + } +} + +async function removeOldNfos(nfoSources, keep) { + for (const nfo of nfoSources) { + if (nfo === keep) continue; + await fs.promises.rm(nfo, { force: true }); + } +} + +async function updateRow(video, plan) { + const atNew = video.filePath === plan.newVideoPath && video.audioFilePath === plan.newAudioPath; + if (atNew) return; + const [affected] = await Video.update( + { filePath: plan.newVideoPath, audioFilePath: plan.newAudioPath }, + { where: unchangedSinceRead(video) } + ); + if (affected === 0) { + throw itemError('The video changed while its files were moving; the next rescan corrects its path.'); + } +} + +// Remove the per-video folder or the season and show folders the video left. +async function cleanupSources(plan, youtubeId) { + const baseDir = configModule.directoryPath; + for (const dir of plan.sourceDirs) { + if (plan.fromLayout === LAYOUT_TV) { + const located = resolveLibraryFolder(dir, baseDir); + const folders = located ? locateEpisodeFolders(path.join(dir, 'file'), located.libraryRoot) : null; + if (folders) await cleanupEmptyShowFolders(folders); + continue; + } + if (isVideoDirectoryFor(dir, youtubeId, baseDir) && await isDirectoryEffectivelyEmpty(dir)) { + await removeDirectoryResilient(dir); + } + } +} + +/** + * Move one video. + * + * @param {Object} record - tv_reorganize_items row + * @param {Object} params + * @param {(ownerChannelId: string) => number|null} params.showIdFor - The pinned show of an owner channel + * @returns {Promise<void>} Throws with a user-facing message when the video can't move + */ +async function executeItem(record, { showIdFor }) { + const plan = JSON.parse(record.files); + const classification = record.classification ? JSON.parse(record.classification) : null; + + const video = await loadVideo(record.video_id); + if (!video) throw itemError('The video is no longer in the library.'); + if (video.removed) throw itemError('The video was marked missing.'); + const atOld = video.filePath === plan.oldVideoPath && video.audioFilePath === plan.oldAudioPath; + const atNew = video.filePath === plan.newVideoPath && video.audioFilePath === plan.newAudioPath; + if (!atOld && !atNew) throw itemError('The video\'s files changed since the preview.'); + assertInsideDownloads(plan.files); + + if (classification) { + const showId = showIdFor(classification.ownerChannelId); + if (!showId) throw itemError('The video\'s show could not be found.'); + await storeClassification(record.youtube_id, classification, showId); + } + + await moveFiles(plan.files, new Set([plan.oldVideoPath, plan.oldAudioPath].filter(Boolean))); + + const mediaPath = plan.newVideoPath || plan.newAudioPath; + try { + const info = await videoInfoStore.readInfoOrFallback(video); + await sidecarWriter.writeVideoSidecars({ + videoPath: mediaPath, + info, + episode: classification + ? { showTitle: classification.showTitle, season: classification.season, episode: classification.episode, episodeTitle: classification.episodeTitle } + : null, + }); + await removeOldNfos(plan.nfoSources, nfoPathOf(mediaPath)); + await updateRow(video, plan); + await videoInfoStore.rewriteActualPaths(record.youtube_id, new Map(plan.files.map((file) => [file.from, file.to]))); + } catch (err) { + throw movedButUnfinished(err); + } + try { + await cleanupSources(plan, record.youtube_id); + } catch (err) { + logger.warn({ err, youtubeId: record.youtube_id }, 'Could not remove folders a moved video left behind'); + } +} + +module.exports = { + executeItem +}; diff --git a/server/modules/reorganize/movieNameRenderer.js b/server/modules/reorganize/movieNameRenderer.js new file mode 100644 index 000000000..0c01ddc8a --- /dev/null +++ b/server/modules/reorganize/movieNameRenderer.js @@ -0,0 +1,160 @@ +/** + * Movie-style names for videos that leave a TV folder: the channel folder, + * the per-video folder and the file stem a fresh download would get today, + * rendered by yt-dlp from each video's stored info.json against the global + * filename template. Many videos render in one yt-dlp process (it accepts a + * JSON array), offline. + * + * yt-dlp re-selects formats from a loaded info dict, so %(ext)s can differ + * from the file on disk; only the stem is rendered and each file keeps its + * own suffix. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const configModule = require('../configModule'); +const ytDlpRunner = require('../ytDlpRunner'); +const logger = require('../../logger'); +const { CHANNEL_TEMPLATE, composeVideoFolderName, composeThumbnailFilename } = require('../filesystem/constants'); +const YtdlpCommandBuilder = require('../download/ytdlpCommandBuilder'); + +const BATCH_SIZE = 200; +const RENDER_TIMEOUT_MS = 120000; +// Large parts of an info dict that no filename template uses. +const STRIPPED_FIELDS = [ + 'formats', 'thumbnails', 'automatic_captions', 'subtitles', 'heatmap', 'requested_formats', + 'requested_subtitles', 'requested_downloads', 'fragments', 'chapters', +]; +// Custom yt-dlp args that change file names, with their value counts; passed +// on so a rendered name matches what a download writes. +const NAME_ARG_ARITY = { + '--restrict-filenames': 0, + '--no-restrict-filenames': 0, + '--windows-filenames': 0, + '--no-windows-filenames': 0, + '--trim-filenames': 1, + '--trim-file-names': 1, + '--output-na-placeholder': 1, + '--replace-in-metadata': 3, + '--parse-metadata': 2, +}; + +/** + * The name-changing part of the user's custom yt-dlp args. + * @param {string[]} tokens + * @returns {string[]} + */ +function nameAffectingArgs(tokens) { + const picked = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const eq = token.startsWith('--') ? token.indexOf('=') : -1; + const flag = eq >= 0 ? token.slice(0, eq) : token; + if (!Object.prototype.hasOwnProperty.call(NAME_ARG_ARITY, flag)) continue; + const valueCount = eq >= 0 ? 0 : NAME_ARG_ARITY[flag]; + picked.push(...tokens.slice(i, i + 1 + valueCount)); + i += valueCount; + } + return picked; +} + +function renderTemplate(prefix) { + return [CHANNEL_TEMPLATE, composeVideoFolderName(prefix), composeThumbnailFilename(prefix)].join('/'); +} + +function strippedInfo(info) { + const copy = { ...info }; + for (const field of STRIPPED_FIELDS) delete copy[field]; + return copy; +} + +function renderArgs(batchFile, template, customArgs) { + return [ + '--no-update', + '--load-info-json', batchFile, + '--ignore-no-formats-error', + '--simulate', + '--skip-download', + // The naming options every download passes (ytdlpCommandBuilder). + '--windows-filenames', + '--output-na-placeholder', 'Unknown Channel', + '--replace-in-metadata', 'uploader_id', '^@', '', + ...customArgs, + '--quiet', + '--no-warnings', + '--print', '%(id)s', + '--print', 'filename', + '-o', template, + ]; +} + +// yt-dlp prints each entry's id line, then its rendered path. +function parseOutput(stdout, ids) { + const results = new Map(); + const lines = stdout.split(/\r?\n/); + for (let i = 0; i < lines.length - 1; i++) { + const id = lines[i].trim(); + if (!ids.has(id) || results.has(id)) continue; + const segments = lines[i + 1].split('/'); + if (segments.length !== 3 || !segments[2].endsWith(`[${id}]`)) continue; + results.set(id, { channelFolder: segments[0], videoFolder: segments[1], stem: segments[2] }); + i += 1; + } + return results; +} + +async function renderBatch(entries, template, customArgs, workDir) { + const batchFile = path.join(workDir, `batch-${entries[0].youtubeId}.json`); + await fs.promises.writeFile(batchFile, JSON.stringify(entries.map((entry) => strippedInfo(entry.info)))); + try { + const stdout = await ytDlpRunner.run(renderArgs(batchFile, template, customArgs), { timeoutMs: RENDER_TIMEOUT_MS }); + return parseOutput(stdout, new Set(entries.map((entry) => entry.youtubeId))); + } catch (err) { + // One bad info dict fails the whole process: split until it is isolated. + if (entries.length === 1) { + logger.warn({ err, youtubeId: entries[0].youtubeId }, 'Could not render the movie-style file name'); + return new Map(); + } + const half = Math.ceil(entries.length / 2); + const [first, second] = await Promise.all([ + renderBatch(entries.slice(0, half), template, customArgs, workDir), + renderBatch(entries.slice(half), template, customArgs, workDir), + ]); + return new Map([...first, ...second]); + } finally { + await fs.promises.rm(batchFile, { force: true }); + } +} + +/** + * Render movie-style names for videos. + * + * @param {Array<{youtubeId: string, info: Object}>} entries + * @returns {Promise<Map<string, {channelFolder: string, videoFolder: string, stem: string}>>} + * Videos missing from the map could not be rendered + */ +async function renderMovieNames(entries) { + const results = new Map(); + if (entries.length === 0) return results; + const config = configModule.getConfig() || {}; + const template = renderTemplate(config.videoFilenamePrefix); + const customArgs = nameAffectingArgs(YtdlpCommandBuilder.buildCustomArgs(config)); + const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'youtarr-names-')); + try { + for (let start = 0; start < entries.length; start += BATCH_SIZE) { + const rendered = await renderBatch(entries.slice(start, start + BATCH_SIZE), template, customArgs, workDir); + for (const [id, names] of rendered) results.set(id, names); + } + } finally { + await fs.promises.rm(workDir, { recursive: true, force: true }); + } + return results; +} + +module.exports = { + renderMovieNames, + nameAffectingArgs, + renderTemplate, + parseOutput +}; diff --git a/server/modules/reorganize/operationRunner.js b/server/modules/reorganize/operationRunner.js new file mode 100644 index 000000000..5c8a803b4 --- /dev/null +++ b/server/modules/reorganize/operationRunner.js @@ -0,0 +1,408 @@ +/** + * Runs reorganize operations. Only one runs at a time, and only while no + * download job is In Progress and none of the tasks that read or write + * downloaded files is running; while it runs, download jobs wait in the queue + * and those tasks are refused (reorganizeLock). + * + * A run: snapshot the moving videos' watch state (after a sync, when the last + * one is old), apply the settings change and pin its shows, move the videos + * one by one (a failure never stops the others), then write show and channel + * metadata and remove emptied folders. With the lock released, playlists are + * re-synced, libraries refreshed and watch state pushed back. An operation a + * restart interrupted resumes at startup. + */ + +const configModule = require('../configModule'); +const messageEmitter = require('../messageEmitter'); +const logger = require('../../logger'); +const watchStatusHolds = require('../mediaServers/watchStatusHolds'); +const { getLayoutResolver } = require('../tvShows/libraryLayouts'); +const reorganizeLock = require('./reorganizeLock'); +const operationStore = require('./operationStore'); +const { buildPlan, summarizePlan, applyRefusal } = require('./planner'); +const { applySettings, rollbackSettings } = require('./settingsApplier'); +const { executeItem } = require('./itemExecutor'); +const followUp = require('./followUp'); +const { OPERATION_STATUS, ITEM_STATUS, PROGRESS_MESSAGE_TYPE, CHANGE_CHANNEL } = require('./constants'); + +// Scheduled tasks that read or write downloaded files or their rows, refused +// while a reorganize runs (and which refuse a reorganize while they run). +const EXCLUSIVE_TASKS = Object.freeze({ + videoRescanFrequency: 'The filesystem rescan', + archiveBackfillFrequency: 'Library repair', + autoRemovalFrequency: 'Automatic video cleanup', + watchStatusSyncFrequency: 'Watch status sync', + ytdlpUpdateFrequency: 'The yt-dlp update', +}); +const FRESH_SYNC_MAX_AGE_MS = 15 * 60 * 1000; +const FRESH_SYNC_TIMEOUT_MS = 2 * 60 * 1000; +const PROGRESS_INTERVAL_MS = 1000; +const RECOVERY_POLL_MS = 5000; +const RECOVERY_MAX_WAIT_MS = 30 * 60 * 1000; +const NOTHING_MOVED_MESSAGE = 'No video could be moved, so the settings change was undone.'; +const INTERRUPTED_MESSAGE = 'A restart interrupted this reorganize and downloads were still busy when the server came up. Retry to finish it.'; + +function conflict(message, code = null) { + const err = new Error(message); + err.status = 409; + if (code) err.code = code; + return err; +} + +function badRequest(message) { + const err = new Error(message); + err.status = 400; + return err; +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function describeDone(item) { + return { + youtubeId: item.youtube_id, + channelId: item.channel_id, + plan: JSON.parse(item.files), + classification: item.classification ? JSON.parse(item.classification) : null, + }; +} + +class OperationRunner { + constructor() { + this.deps = null; + this.lastProgressAt = 0; + } + + /** + * @param {Object} deps + * @param {Object} deps.jobModule + * @param {Object} deps.scheduledTaskManager + * @param {Object} deps.videosModule + * @param {Object} deps.mediaServerSync + * @param {Object} deps.watchStatusSync + */ + initialize(deps) { + this.deps = deps; + } + + /** + * Why a reorganize can't start right now, or null. + * @returns {{reason: string, message: string}|null} + */ + blocker() { + if (reorganizeLock.isActive()) { + return { reason: 'reorganizing', message: 'Another reorganize is running. Try again when it finishes.' }; + } + return this._activityBlocker(); + } + + // The blocker without the lock itself: what else is touching downloads. + _activityBlocker() { + if (!this.deps) return { reason: 'starting', message: 'The server is still starting.' }; + const { jobModule, scheduledTaskManager, videosModule, mediaServerSync, watchStatusSync } = this.deps; + if (jobModule.getInProgressJobId()) { + return { reason: 'download-running', message: 'Wait for the current download to finish, then try again.' }; + } + const running = Object.keys(EXCLUSIVE_TASKS).find((id) => scheduledTaskManager.isTaskRunningById(id)); + if (running) { + return { reason: 'task-running', message: `${EXCLUSIVE_TASKS[running]} is running. Try again when it finishes.` }; + } + if (videosModule.isBackfillRunning() || jobModule.isArchiveRepairRunning() || watchStatusSync.getStatus().running) { + return { reason: 'task-running', message: 'A library scan or sync is running. Try again when it finishes.' }; + } + if (mediaServerSync.isAnySyncInFlight()) { + return { reason: 'task-running', message: 'A media server playlist sync is running. Try again in a moment.' }; + } + return null; + } + + /** + * The dry run: everything that would move, without moving anything. + * @param {Object} change + */ + async preview(change) { + const plan = await buildPlan(change); + return summarizePlan(plan, { blocked: this.blocker() }); + } + + /** + * Start a previewed reorganize in the background. + * + * @param {Object} change - The change the preview was for + * @param {string} revision - The preview's revision token + * @returns {Promise<{operationId: number|null, applied: boolean}>} operationId null + * when nothing had to move and the settings change was applied directly + */ + async start(change, revision) { + if (typeof revision !== 'string' || !revision) throw badRequest('revision is required'); + const blocked = this.blocker(); + if (blocked) throw conflict(blocked.message, blocked.reason); + + const token = reorganizeLock.acquire({ label: 'downloads' }); + let operation; + let plan; + try { + if (change && change.channelId) reorganizeLock.setScope(token, { channelIds: [change.channelId] }); + plan = await buildPlan(change); + if (plan.revision !== revision) { + throw conflict('Files or settings changed since the preview. Review the move again.', 'STALE_PREVIEW'); + } + // The plan can take a while on a large library, and a job admitted in + // the moment before the lock was taken may have started meanwhile. + const busy = this._activityBlocker(); + if (busy) throw conflict(busy.message, busy.reason); + if (plan.items.length === 0) { + const refusal = applyRefusal(plan); + if (refusal) throw conflict(refusal.message, refusal.reason); + await applySettings({ change: plan.context.stored, shows: plan.shows, layoutBefore: plan.context.layoutBefore }); + logger.info({ change: plan.context.stored }, 'Applied a layout change with no downloaded files to move'); + reorganizeLock.release(token); + return { operationId: null, applied: true }; + } + operation = await operationStore.createOperation(plan); + reorganizeLock.setScope(token, { + operationId: operation.id, + label: plan.context.label, + channelIds: [ + ...plan.items.map((item) => item.channelId), + ...(plan.context.type === CHANGE_CHANNEL ? [plan.context.channel.channel_id] : []), + ], + videoIds: plan.items.map((item) => item.videoId), + youtubeIds: plan.items.map((item) => item.youtubeId), + }); + } catch (err) { + reorganizeLock.release(token); + throw err; + } + + logger.info({ operationId: operation.id, videos: operation.total_items }, 'Reorganize started'); + this._run(operation, token, { plan, freshSync: true }).catch((err) => { + logger.error({ err, operationId: operation.id }, 'Reorganize failed'); + }); + return { operationId: operation.id, applied: false }; + } + + /** + * Try an operation's failed videos again. + * @returns {Promise<{operationId: number}>} + */ + async retry(operationId) { + const operation = await operationStore.findOperation(operationId); + if (!operation) { + const err = new Error('Reorganize not found'); + err.status = 404; + throw err; + } + // A running operation holds the lock, which the blocker reports; a row + // left "running" by a crash with the lock free is retried like any other. + const blocked = this.blocker(); + if (blocked) throw conflict(blocked.message, blocked.reason); + // Failed videos, plus any a stopped run never reached. + const unfinished = await operationStore.itemsWithStatus(operation.id, [ITEM_STATUS.FAILED, ITEM_STATUS.PENDING]); + if (unfinished.length === 0) throw badRequest('Nothing failed in this reorganize.'); + const channelIds = [...new Set(unfinished.map((item) => item.channel_id).filter(Boolean))]; + if (await operationStore.hasNewerOperationFor(operation.id, channelIds)) { + throw conflict('A newer reorganize changed these channels. Review the move again from Channel Settings.'); + } + + const token = reorganizeLock.acquire({ label: operationStore.settingsOf(operation).label }); + try { + reorganizeLock.setScope(token, { + operationId: operation.id, + channelIds, + videoIds: unfinished.map((item) => item.video_id), + youtubeIds: unfinished.map((item) => item.youtube_id), + }); + await operationStore.resetFailedItems(operation.id); + await operationStore.reopenOperation(operation); + } catch (err) { + reorganizeLock.release(token); + throw err; + } + this._run(operation, token, { freshSync: true }).catch((err) => { + logger.error({ err, operationId: operation.id }, 'Reorganize retry failed'); + }); + return { operationId: operation.id }; + } + + /** + * Resume operations a restart interrupted. Waits for startup work that + * touches downloads (library repair, a rescan) to finish first. + */ + async recover() { + const operations = await operationStore.findUnfinished(); + for (const operation of operations) { + if (!(await this._waitUntilFree())) { + // Not resumed alongside whatever is still busy: closed as unfinished, + // so Retry (or the next start) can pick it up. + logger.warn({ operationId: operation.id, blocker: this.blocker() }, 'Downloads are still busy; the interrupted reorganize was not resumed'); + const status = operation.settings_applied ? OPERATION_STATUS.PARTIAL : OPERATION_STATUS.FAILED; + try { + await operationStore.finishOperation(operation, status, INTERRUPTED_MESSAGE); + } catch (err) { + logger.error({ err, operationId: operation.id }, 'Could not record the interrupted reorganize'); + } + continue; + } + const pending = await operationStore.itemsWithStatus(operation.id, [ITEM_STATUS.PENDING]); + const token = reorganizeLock.acquire({ label: operationStore.settingsOf(operation).label }); + reorganizeLock.setScope(token, { + operationId: operation.id, + channelIds: pending.map((item) => item.channel_id), + videoIds: pending.map((item) => item.video_id), + youtubeIds: pending.map((item) => item.youtube_id), + }); + logger.info({ operationId: operation.id, pending: pending.length }, 'Resuming an interrupted reorganize'); + await this._run(operation, token, { freshSync: false }); + } + } + + /** @returns {Promise<boolean>} false when still blocked at the deadline */ + async _waitUntilFree() { + const deadline = Date.now() + RECOVERY_MAX_WAIT_MS; + while (this.blocker()) { + if (Date.now() >= deadline) return false; + await sleep(RECOVERY_POLL_MS); + } + return true; + } + + async _refreshWatchState() { + const { watchStatusSync } = this.deps; + const config = configModule.getConfig() || {}; + if (config.watchStatusSyncEnabled === false) return; + const { lastRun } = watchStatusSync.getStatus(); + const lastAt = lastRun && lastRun.completedAt ? new Date(lastRun.completedAt).getTime() : 0; + if (Date.now() - lastAt < FRESH_SYNC_MAX_AGE_MS) return; + let timer; + try { + await Promise.race([ + watchStatusSync.syncAll('reorganize'), + new Promise((resolve) => { timer = setTimeout(resolve, FRESH_SYNC_TIMEOUT_MS); }), + ]); + } catch (err) { + logger.warn({ err }, 'Watch status sync before a reorganize failed; holding the stored state'); + } finally { + clearTimeout(timer); + } + } + + _broadcast(operation, force = false) { + const now = Date.now(); + if (!force && now - this.lastProgressAt < PROGRESS_INTERVAL_MS) return; + this.lastProgressAt = now; + try { + messageEmitter.emitMessage('broadcast', null, 'reorganize', PROGRESS_MESSAGE_TYPE, { + operationId: operation.id, + status: operation.status, + total: operation.total_items, + done: operation.done_items, + failed: operation.failed_items, + label: operationStore.settingsOf(operation).label, + }); + } catch (err) { + logger.debug({ err }, 'Could not broadcast reorganize progress'); + } + } + + async _run(operation, token, { plan = null, freshSync }) { + const settings = operationStore.settingsOf(operation); + let shows = settings.shows; + let status = OPERATION_STATUS.COMPLETED; + let error = null; + let items = []; + // Items whose files reached their destination, in this run or an earlier + // attempt, finished or not: their settings stay applied and their watch + // state stays held. + const moved = new Set(); + // Every done item of the operation, earlier runs included, for the follow-up. + let finished = []; + try { + this._broadcast(operation, true); + items = await operationStore.itemsWithStatus(operation.id, [ITEM_STATUS.PENDING]); + for (const item of items) if (item.files_moved) moved.add(item.id); + if (freshSync) await this._refreshWatchState(); + await watchStatusHolds.createHolds({ operationId: operation.id, videoIds: items.map((item) => item.video_id) }); + + if (!operation.settings_applied) { + const layoutBefore = plan ? plan.context.layoutBefore : await getLayoutResolver(); + shows = await applySettings({ change: settings.change, shows, layoutBefore }); + await operationStore.markSettingsApplied(operation, shows); + } + const showIds = new Map(shows.map((show) => [show.ownerChannelId, show.showId])); + + for (const item of items) { + try { + await executeItem(item, { showIdFor: (owner) => showIds.get(owner) || null }); + moved.add(item.id); + await operationStore.markItem(item, ITEM_STATUS.DONE); + } catch (err) { + // true: at the destination; false: verified back home; undefined: + // the attempt never reached the files, so what we knew stands. + const { filesMoved } = err; + if (filesMoved === true) moved.add(item.id); + else if (filesMoved === false) moved.delete(item.id); + logger.warn({ err, operationId: operation.id, youtubeId: item.youtube_id, filesMoved }, 'Could not move a video'); + await operationStore.markItem(item, ITEM_STATUS.FAILED, err.message, { filesMoved }); + } + await operationStore.refreshCounts(operation); + this._broadcast(operation); + } + + const counts = await operationStore.refreshCounts(operation); + if (counts.moved === 0 && counts.failed > 0) { + await rollbackSettings({ change: settings.change, shows }); + await operation.update({ settings_applied: false }); + status = OPERATION_STATUS.FAILED; + error = NOTHING_MOVED_MESSAGE; + } else if (counts.failed > 0) { + status = OPERATION_STATUS.PARTIAL; + } + finished = (await operationStore.itemsWithStatus(operation.id, [ITEM_STATUS.DONE])).map(describeDone); + try { + await followUp.finishFiles({ items: finished, shows }); + } catch (err) { + logger.warn({ err, operationId: operation.id }, 'Could not finish the metadata and folders after a reorganize'); + } + } catch (err) { + logger.error({ err, operationId: operation.id }, 'Reorganize stopped'); + status = OPERATION_STATUS.FAILED; + error = err.message; + if (operation.settings_applied && moved.size === 0) { + try { + await rollbackSettings({ change: settings.change, shows }); + await operation.update({ settings_applied: false }); + } catch (rollbackErr) { + logger.error({ err: rollbackErr, operationId: operation.id }, 'Could not undo the settings change of a failed reorganize'); + } + } + } finally { + // Videos whose files stayed put are where the servers know them. + try { + await watchStatusHolds.releaseUnmovedHolds({ + operationId: operation.id, + videoIds: items.filter((item) => !moved.has(item.id)).map((item) => item.video_id), + }); + } catch (err) { + logger.warn({ err, operationId: operation.id }, 'Could not release the watch-state holds of unmoved videos'); + } + try { + await operationStore.refreshCounts(operation); + await operationStore.finishOperation(operation, status, error); + } catch (err) { + logger.error({ err, operationId: operation.id }, 'Could not record the end of a reorganize'); + } + reorganizeLock.release(token); + this._broadcast(operation, true); + logger.info({ operationId: operation.id, status, done: operation.done_items, failed: operation.failed_items }, 'Reorganize finished'); + } + if (finished.length > 0) { + await followUp.finishServers({ items: finished }).catch((err) => { + logger.warn({ err, operationId: operation.id }, 'Could not refresh media servers after a reorganize'); + }); + } + } +} + +module.exports = new OperationRunner(); +module.exports.EXCLUSIVE_TASKS = EXCLUSIVE_TASKS; +module.exports.OperationRunner = OperationRunner; diff --git a/server/modules/reorganize/operationStore.js b/server/modules/reorganize/operationStore.js new file mode 100644 index 000000000..bd8fdd359 --- /dev/null +++ b/server/modules/reorganize/operationStore.js @@ -0,0 +1,255 @@ +/** + * The persistent record of a reorganize: the approved change and every + * video's planned moves, so a restart can finish it and failed videos can be + * retried. Item files and classifications are stored as JSON. + */ + +const { Op } = require('sequelize'); +const { sequelize } = require('../../db'); +const TvReorganizeOperation = require('../../models/tvreorganizeoperation'); +const TvReorganizeItem = require('../../models/tvreorganizeitem'); +const { OPERATION_STATUS, ITEM_STATUS } = require('./constants'); + +const FAILED_ITEM_LIST_LIMIT = 200; +const MAX_ERROR_LENGTH = 1000; + +function itemPayload(item) { + return { + files: item.files.map(({ from, to }) => ({ from, to })), + nfoSources: item.nfoSources, + sourceDirs: item.sourceDirs, + destDir: item.destDir, + oldVideoPath: item.oldVideoPath, + newVideoPath: item.newVideoPath, + oldAudioPath: item.oldAudioPath, + newAudioPath: item.newAudioPath, + layout: item.layout, + libraryFolder: item.libraryFolder, + fromLayout: item.fromLayout, + fromLibraryFolder: item.fromLibraryFolder, + }; +} + +function truncate(message) { + const text = String(message || ''); + return text.length > MAX_ERROR_LENGTH ? `${text.slice(0, MAX_ERROR_LENGTH - 3)}...` : text; +} + +/** + * Record a planned reorganize as running. + * @param {Object} plan - planner.buildPlan's result + * @returns {Promise<Object>} The operation row + */ +async function createOperation(plan) { + const { context } = plan; + return sequelize.transaction(async (transaction) => { + const operation = await TvReorganizeOperation.create({ + change_type: context.type, + scope: String(context.scope || ''), + settings_change: JSON.stringify({ change: context.stored, label: context.label, shows: plan.shows }), + settings_applied: false, + revision: plan.revision, + status: OPERATION_STATUS.RUNNING, + total_items: plan.items.length, + started_at: new Date(), + }, { transaction }); + if (plan.items.length > 0) { + await TvReorganizeItem.bulkCreate(plan.items.map((item) => ({ + operation_id: operation.id, + youtube_id: item.youtubeId, + video_id: item.videoId, + channel_id: item.channelId, + title: item.title ? String(item.title).slice(0, 512) : null, + files: JSON.stringify(itemPayload(item)), + classification: item.classification ? JSON.stringify(item.classification) : null, + status: ITEM_STATUS.PENDING, + })), { transaction }); + } + return operation; + }); +} + +/** @returns {{change: Object, label: string, shows: Array<Object>}} */ +function settingsOf(operation) { + return JSON.parse(operation.settings_change); +} + +async function markSettingsApplied(operation, shows) { + const settings = settingsOf(operation); + await operation.update({ + settings_change: JSON.stringify({ ...settings, shows }), + settings_applied: true, + }); +} + +async function itemsWithStatus(operationId, statuses) { + return TvReorganizeItem.findAll({ where: { operation_id: operationId, status: statuses }, order: [['id', 'ASC']] }); +} + +/** + * @param {Object} item - tv_reorganize_items row + * @param {string} status + * @param {string|null} [error] + * @param {Object} [options] + * @param {boolean} [options.filesMoved] - Where a failed item's files are: + * true at their destination (the video counts as moved), false verified + * back at their sources, undefined when the attempt never reached them + * (what is stored stands). A done item's files are always moved. + */ +async function markItem(item, status, error = null, { filesMoved } = {}) { + await item.update({ + status, + error: error ? truncate(error) : null, + files_moved: status === ITEM_STATUS.DONE || filesMoved === true + || (filesMoved === undefined && Boolean(item.files_moved)), + }); +} + +/** + * Recount the operation's items into its row. + * @returns {Promise<{done: number, failed: number, pending: number, moved: number}>} + * moved: items whose files are at their destination, done or not + */ +async function refreshCounts(operation) { + const rows = await TvReorganizeItem.findAll({ + where: { operation_id: operation.id }, + attributes: ['status', [sequelize.fn('COUNT', sequelize.col('id')), 'count']], + group: ['status'], + raw: true, + }); + const count = (status) => Number((rows.find((row) => row.status === status) || {}).count || 0); + const moved = await TvReorganizeItem.count({ where: { operation_id: operation.id, files_moved: true } }); + const counts = { done: count(ITEM_STATUS.DONE), failed: count(ITEM_STATUS.FAILED), pending: count(ITEM_STATUS.PENDING), moved }; + await operation.update({ done_items: counts.done, failed_items: counts.failed }); + return counts; +} + +async function finishOperation(operation, status, error = null) { + await operation.update({ status, error: error ? truncate(error) : null, finished_at: new Date() }); +} + +async function reopenOperation(operation) { + await operation.update({ status: OPERATION_STATUS.RUNNING, error: null, finished_at: null }); +} + +async function findUnfinished() { + return TvReorganizeOperation.findAll({ where: { status: OPERATION_STATUS.RUNNING }, order: [['id', 'ASC']] }); +} + +async function findOperation(id) { + return TvReorganizeOperation.findByPk(id); +} + +function describeOperation(operation, failedItems = []) { + const settings = settingsOf(operation); + return { + id: operation.id, + changeType: operation.change_type, + change: settings.change, + label: settings.label, + status: operation.status, + total: operation.total_items, + done: operation.done_items, + failed: operation.failed_items, + error: operation.error, + startedAt: operation.started_at, + finishedAt: operation.finished_at, + failedItems: failedItems.map((item) => ({ + id: item.id, youtubeId: item.youtube_id, title: item.title, channelId: item.channel_id, error: item.error, + })), + }; +} + +// The items a retry would take up: failed ones, plus the pending ones of an +// operation that ended before reaching them (a running one is still on them). +function unfinishedStatuses(operation) { + return operation.status === OPERATION_STATUS.RUNNING + ? [ITEM_STATUS.FAILED] + : [ITEM_STATUS.FAILED, ITEM_STATUS.PENDING]; +} + +/** + * The operation's state for the API, with the videos it did not move. + * @returns {Promise<Object|null>} + */ +async function getOperationView(id) { + const operation = await findOperation(id); + if (!operation) return null; + const unfinished = await TvReorganizeItem.findAll({ + where: { operation_id: id, status: unfinishedStatuses(operation) }, + order: [['id', 'ASC']], + limit: FAILED_ITEM_LIST_LIMIT, + }); + return describeOperation(operation, unfinished); +} + +/** + * The newest operation that left videos of this channel unmoved. + * @returns {Promise<{operationId: number, failed: number, status: string}|null>} + */ +async function unmovedForChannel(channelId) { + const latest = await TvReorganizeItem.findOne({ + where: { channel_id: channelId, status: [ITEM_STATUS.FAILED, ITEM_STATUS.PENDING] }, + order: [['operation_id', 'DESC']], + attributes: ['operation_id'], + }); + if (!latest) return null; + const operation = await findOperation(latest.operation_id); + if (!operation) return null; + const failed = await TvReorganizeItem.count({ + where: { operation_id: latest.operation_id, channel_id: channelId, status: unfinishedStatuses(operation) }, + }); + if (failed === 0) return null; + return { operationId: operation.id, failed, status: operation.status }; +} + +/** + * The newest operations, for the activity list. + */ +async function listRecent(limit = 10) { + const operations = await TvReorganizeOperation.findAll({ order: [['id', 'DESC']], limit }); + return operations.map((operation) => describeOperation(operation)); +} + +/** + * Failed items go back to pending for a retry. + * @returns {Promise<number>} + */ +async function resetFailedItems(operationId) { + const [count] = await TvReorganizeItem.update( + { status: ITEM_STATUS.PENDING, error: null }, + { where: { operation_id: operationId, status: ITEM_STATUS.FAILED } } + ); + return count; +} + +/** + * Whether a newer operation covers any of these channels, which makes an + * older operation's stored plan stale for a retry. + */ +async function hasNewerOperationFor(operationId, channelIds) { + if (channelIds.length === 0) return false; + const newer = await TvReorganizeItem.findOne({ + where: { operation_id: { [Op.gt]: operationId }, channel_id: channelIds }, + attributes: ['id'], + }); + return Boolean(newer); +} + +module.exports = { + createOperation, + settingsOf, + markSettingsApplied, + itemsWithStatus, + markItem, + refreshCounts, + finishOperation, + reopenOperation, + findUnfinished, + findOperation, + getOperationView, + unmovedForChannel, + listRecent, + resetFailedItems, + hasNewerOperationFor +}; diff --git a/server/modules/reorganize/planner.js b/server/modules/reorganize/planner.js new file mode 100644 index 000000000..6bacfbd8d --- /dev/null +++ b/server/modules/reorganize/planner.js @@ -0,0 +1,158 @@ +/** + * The reorganize plan for a settings change: every video that moves, where + * its files go, the shows it pins, what can't move, and a revision token. + * The preview shows a summary of it; apply recomputes it under the lock and + * refuses when the token no longer matches. + */ + +const path = require('path'); +const { Op } = require('sequelize'); +const configModule = require('../configModule'); +const VideoWatchStatus = require('../../models/videowatchstatus'); +const { LAYOUT_TV } = require('../tvShows/constants'); +const { episodeCode } = require('../tvShows/episodeNaming'); +const { isHoldable } = require('../mediaServers/watchStatusHolds'); +const { resolveChange } = require('./changeContext'); +const { selectSubjects } = require('./changeScope'); +const { planShows } = require('./showPlanner'); +const { planDestinations } = require('./destinationPlanner'); +const { planRevision } = require('./revision'); +const { PREVIEW_ITEM_LIMIT, PROBLEM, FLAG } = require('./constants'); + +/** + * @param {Object} rawChange - The requested change (changeContext.resolveChange) + * @returns {Promise<Object>} { context, items, problems, unchanged, shows, revision } + */ +async function buildPlan(rawChange) { + const context = await resolveChange(rawChange); + const { subjects } = await selectSubjects(context); + const { targets, shows } = await planShows(subjects, context); + const { items, problems, unchanged } = await planDestinations({ subjects, context, targets, shows }); + const tvOwners = new Set(items.filter((item) => item.layout === LAYOUT_TV).map((item) => item.channelId)); + const plannedShows = [...shows.values()].filter((show) => tvOwners.has(show.ownerChannelId)); + return { + context, + items, + problems, + unchanged, + shows: plannedShows, + revision: planRevision({ change: context.stored, shows: plannedShows, items }), + }; +} + +function relative(filePath) { + if (!filePath) return null; + return path.relative(configModule.directoryPath, filePath); +} + +/** + * Why a plan with nothing to move must not be applied as a bare settings + * change: its videos could not be planned (no name, no date, an unsafe + * destination), so applying would leave them all in the old layout. Files + * that are gone are no reason to refuse. + * @returns {{reason: string, message: string}|null} + */ +function applyRefusal(plan) { + if (plan.items.length > 0) return null; + const stuck = plan.problems.filter((problem) => problem.problem !== PROBLEM.MISSING).length; + if (stuck === 0) return null; + return { + reason: 'problems', + message: `None of the downloaded videos can be moved: ${stuck} could not be given a destination. ` + + 'The change is not applied until that is resolved.', + }; +} + +function countWhere(list, test) { + return list.reduce((total, entry) => total + (test(entry) ? 1 : 0), 0); +} + +// Videos whose watch state the servers will lose with the move, per server. +// Plex accounts other than the owner come from play history, which a move +// never resets, so only the owner's state is counted for Plex. +async function watchStateAtRisk(items) { + const videoIds = items.map((item) => item.videoId); + if (videoIds.length === 0) return []; + const rows = await VideoWatchStatus.findAll({ + where: { + video_id: videoIds, + [Op.or]: [{ played: true }, { position_ms: { [Op.gt]: 0 } }], + }, + attributes: ['video_id', 'server_type', 'server_user_id', 'played', 'position_ms'], + raw: true, + }); + const byServer = new Map(); + for (const row of rows) { + if (!isHoldable(row)) continue; + if (!byServer.has(row.server_type)) byServer.set(row.server_type, { videos: new Set(), users: new Set() }); + byServer.get(row.server_type).videos.add(row.video_id); + byServer.get(row.server_type).users.add(row.server_user_id); + } + return [...byServer.entries()].map(([serverType, { videos, users }]) => ({ + serverType, videos: videos.size, users: users.size, + })); +} + +function describeItem(item) { + const from = item.oldVideoPath || item.oldAudioPath; + const to = item.newVideoPath || item.newAudioPath; + return { + youtubeId: item.youtubeId, + title: item.title, + from: relative(from), + to: relative(to), + episode: item.classification + ? episodeCode({ season: item.classification.season, episode: item.classification.episode, dateNumbered: true }) + : null, + flags: item.flags, + }; +} + +/** + * The preview's JSON for a plan. + * + * @param {Object} plan - buildPlan's result + * @param {Object} [options] + * @param {{reason: string, message: string}|null} [options.blocked] - Why apply would be refused right now + * @returns {Promise<Object>} + */ +async function summarizePlan(plan, { blocked = null } = {}) { + const { context, items, problems } = plan; + const flagged = (flag) => countWhere(items, (item) => item.flags.includes(flag)); + const problemCount = (kind) => countWhere(problems, (problem) => problem.problem === kind); + return { + revision: plan.revision, + needed: items.length > 0, + change: { ...context.stored, label: context.label }, + totals: { + videos: items.length, + toTv: countWhere(items, (item) => item.layout === LAYOUT_TV && item.fromLayout !== LAYOUT_TV), + toVideos: countWhere(items, (item) => item.layout !== LAYOUT_TV && item.fromLayout === LAYOUT_TV), + betweenFolders: countWhere(items, (item) => item.layout === item.fromLayout), + unchanged: plan.unchanged, + missing: problemCount(PROBLEM.MISSING), + collisions: problemCount(PROBLEM.COLLISION), + noName: problemCount(PROBLEM.NO_NAME), + noDate: problemCount(PROBLEM.NO_DATE), + unsafeName: problemCount(PROBLEM.UNSAFE_NAME), + overridePlaced: flagged(FLAG.OVERRIDE_PLACED), + adopted: flagged(FLAG.ADOPTED), + uploadDateOnly: flagged(FLAG.UPLOAD_DATE_ONLY), + downloadTime: flagged(FLAG.DOWNLOAD_TIME), + movieTags: flagged(FLAG.MOVIE_TAGS), + }, + shows: plan.shows.map((show) => ({ + name: show.name, libraryFolder: show.libraryFolder, folderName: show.folderName, action: show.action, + })), + items: items.slice(0, PREVIEW_ITEM_LIMIT).map(describeItem), + problems: problems.slice(0, PREVIEW_ITEM_LIMIT).map((problem) => ({ ...problem, detail: relative(problem.detail) })), + watchState: await watchStateAtRisk(items), + blocked: blocked || applyRefusal(plan), + }; +} + +module.exports = { + buildPlan, + summarizePlan, + applyRefusal +}; diff --git a/server/modules/reorganize/reorganizeLock.js b/server/modules/reorganize/reorganizeLock.js new file mode 100644 index 000000000..258f766e0 --- /dev/null +++ b/server/modules/reorganize/reorganizeLock.js @@ -0,0 +1,128 @@ +/** + * The single running reorganize, if any. While it runs, download jobs are + * held, the file-touching scheduled tasks are refused, and changes to the + * videos and channels it covers are refused with 409. + * + * Dependency-free (no models, no config) so jobModule, scheduledTaskManager + * and the route layer can all consult it without import cycles. + */ + +const { EventEmitter } = require('events'); + +const REORGANIZE_BLOCK_REASON = 'reorganizing'; +const REORGANIZE_RUNNING_CODE = 'REORGANIZE_RUNNING'; +const DEFAULT_MESSAGE = 'Downloads are being reorganized. Try again when that finishes.'; + +function conflictError(message) { + const err = new Error(message || DEFAULT_MESSAGE); + err.status = 409; + err.code = REORGANIZE_RUNNING_CODE; + return err; +} + +class ReorganizeLock extends EventEmitter { + constructor() { + super(); + this.active = null; + } + + /** + * Take the lock. Throws a 409 error when a reorganize already holds it. + * @param {Object} params + * @param {string} params.label - What is being reorganized, for messages and logs + * @returns {Object} The lock token, passed back to setScope and release + */ + acquire({ label }) { + if (this.active) throw conflictError(); + const token = { + label: label || 'downloads', + operationId: null, + channelIds: new Set(), + videoIds: new Set(), + youtubeIds: new Set(), + startedAt: new Date(), + }; + this.active = token; + return token; + } + + /** + * Record what the held lock covers once the operation is planned. + * @param {Object} token - acquire's result + * @param {Object} scope + * @param {number} [scope.operationId] + * @param {string} [scope.label] + * @param {Iterable<string>} [scope.channelIds] + * @param {Iterable<number>} [scope.videoIds] - Videos.id values + * @param {Iterable<string>} [scope.youtubeIds] + */ + setScope(token, { operationId, label, channelIds = [], videoIds = [], youtubeIds = [] }) { + if (this.active !== token) return; + if (operationId !== undefined) token.operationId = operationId; + if (label) token.label = label; + for (const id of channelIds) if (id) token.channelIds.add(id); + for (const id of videoIds) if (id !== null && id !== undefined) token.videoIds.add(Number(id)); + for (const id of youtubeIds) if (id) token.youtubeIds.add(id); + } + + release(token) { + if (!this.active || this.active !== token) return; + this.active = null; + this.emit('released'); + } + + isActive() { + return this.active !== null; + } + + /** @returns {{operationId: number|null, label: string, startedAt: Date}|null} */ + getActive() { + if (!this.active) return null; + const { operationId, label, startedAt } = this.active; + return { operationId, label, startedAt }; + } + + coversChannel(channelId) { + return Boolean(this.active && channelId && this.active.channelIds.has(channelId)); + } + + /** + * @param {Object} params + * @param {Array<number|string>} [params.ids] - Videos.id values + * @param {string[]} [params.youtubeIds] + */ + coversAnyVideo({ ids = [], youtubeIds = [] } = {}) { + if (!this.active) return false; + return ids.some((id) => this.active.videoIds.has(Number(id))) + || youtubeIds.some((id) => this.active.youtubeIds.has(id)); + } + + /** Scheduled-task blocker shape, or null when nothing is running. */ + runBlocker() { + if (!this.active) return null; + return { reason: REORGANIZE_BLOCK_REASON, message: `Waiting for the reorganize of ${this.active.label} to finish.` }; + } + + assertInactive(message) { + if (this.active) throw conflictError(message); + } + + assertChannelFree(channelId, message) { + if (this.coversChannel(channelId)) { + throw conflictError(message || 'This channel\'s downloads are being reorganized. Try again when that finishes.'); + } + } + + assertVideosFree(params, message) { + if (this.coversAnyVideo(params)) { + throw conflictError(message || 'Some of these videos are being reorganized. Try again when that finishes.'); + } + } +} + +const lock = new ReorganizeLock(); + +module.exports = lock; +module.exports.REORGANIZE_BLOCK_REASON = REORGANIZE_BLOCK_REASON; +module.exports.REORGANIZE_RUNNING_CODE = REORGANIZE_RUNNING_CODE; +module.exports.ReorganizeLock = ReorganizeLock; diff --git a/server/modules/reorganize/revision.js b/server/modules/reorganize/revision.js new file mode 100644 index 000000000..d352ef9cb --- /dev/null +++ b/server/modules/reorganize/revision.js @@ -0,0 +1,32 @@ +/** + * The revision token of a reorganize plan: a hash of the change, the show + * locations it pins, and every planned file move with its source's size and + * modification time. Apply recomputes the plan and refuses a token that no + * longer matches, so nothing moves on the strength of a stale preview. + */ + +const crypto = require('crypto'); + +/** + * @param {Object} plan + * @param {Object} plan.change - The normalized change + * @param {Array<Object>} plan.shows - Planned shows + * @param {Array<Object>} plan.items - Planned moves (sorted) + * @returns {string} + */ +function planRevision({ change, shows, items }) { + const payload = { + change, + shows: shows + .map((show) => [show.ownerChannelId, show.action, show.libraryFolder, show.folderName]) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)), + items: items.map((item) => [ + item.youtubeId, + item.files.map((file) => [file.from, file.to, file.size, file.mtimeMs]), + item.classification ? [item.classification.season, item.classification.episode, item.classification.fileStem] : null, + ]), + }; + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +module.exports = { planRevision }; diff --git a/server/modules/reorganize/settingsApplier.js b/server/modules/reorganize/settingsApplier.js new file mode 100644 index 000000000..8e2762578 --- /dev/null +++ b/server/modules/reorganize/settingsApplier.js @@ -0,0 +1,153 @@ +/** + * The settings change a reorganize was approved for. It is applied when the + * operation starts, not when it ends: download jobs are held either way, but + * admission then already sees the new layout (TV folders are video-only), + * Channel Settings shows it, and the shows have to be pinned before files + * move into them. Applying is safe to repeat (a restart resumes it), and it + * is undone when no video could be moved. + */ + +const configModule = require('../configModule'); +const subfolderModule = require('../subfolderModule'); +const m3uGenerator = require('../m3uGenerator'); +const logger = require('../../logger'); +const Channel = require('../../models/channel'); +const TvShow = require('../../models/tvshow'); +const showStore = require('../tvShows/showStore'); +const libraryLayouts = require('../tvShows/libraryLayouts'); +const layoutGuards = require('../tvShows/layoutGuards'); +const channelLayout = require('../tvShows/channelLayout'); +const { syncPlexIgnore } = require('../tvShows/libraryFolders'); +const { LAYOUT_TV, folderKey } = require('../tvShows/constants'); +const { SHOW_ACTION } = require('./showPlanner'); +const { libraryFolderOf } = require('./changeContext'); +const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER } = require('./constants'); + +function sameLocation(show, planned) { + return folderKey(show.library_folder) === folderKey(planned.libraryFolder) + && show.folder_name.toLowerCase() === planned.folderName.toLowerCase(); +} + +async function pinShow(planned) { + if (planned.action === SHOW_ACTION.KEEP) return { ...planned }; + // Also reached again after a restart: the show may already be in place. + const existing = planned.showId + ? await TvShow.findByPk(planned.showId) + : await showStore.findChannelShow(planned.ownerChannelId); + let show; + if (!existing) { + show = await showStore.createChannelShowAt({ + channelId: planned.ownerChannelId, + name: planned.name, + folderName: planned.folderName, + libraryFolder: planned.libraryFolder, + previousVideosFolder: planned.previousVideosFolder || null, + }); + } else { + show = sameLocation(existing, planned) ? existing : await showStore.moveShowTo(existing, planned); + if (planned.previousVideosFolder) await show.update({ previous_videos_folder: planned.previousVideosFolder }); + } + return { ...planned, showId: show.id }; +} + +// Channels whose files move to TV lose their channel .m3u (TV channels have +// none). Removed before the change, while its path still resolves. +async function removeChannelPlaylists(channelIds) { + for (const channelId of channelIds) { + try { + await m3uGenerator.deleteChannelM3U(channelId); + } catch (err) { + logger.warn({ err, channelId }, 'Could not remove the channel .m3u before reorganizing'); + } + } +} + +function setDefaultSubfolder(value) { + configModule.updateConfig({ ...configModule.getConfig(), defaultSubfolder: value || '' }); +} + +async function setFolderLayout(folder, layout) { + if (folder) await subfolderModule.register(folder); + await libraryLayouts.setLayout(folder, layout); + if (!folder) { + try { + await syncPlexIgnore(layout); + } catch (err) { + logger.error({ err }, 'Could not update the main folder .plexignore'); + } + } +} + +/** + * Apply the change and pin its shows. + * + * @param {Object} params + * @param {Object} params.change - The stored (normalized) change + * @param {Array<Object>} params.shows - Planned shows + * @param {(libraryFolder: string) => string} params.layoutBefore - Layouts before the change + * @returns {Promise<Array<Object>>} The shows with their ids + */ +async function applySettings({ change, shows, layoutBefore }) { + const pinned = []; + for (const planned of shows) pinned.push(await pinShow(planned)); + + if (change.type === CHANGE_CHANNEL) { + const channel = await Channel.findOne({ where: { channel_id: change.channelId } }); + if (!channel) throw new Error('The channel no longer exists.'); + const defaultFolder = configModule.getDefaultSubfolder(); + const fromFolder = libraryFolderOf(change.previousSubFolder, defaultFolder); + const toFolder = libraryFolderOf(change.subFolder, defaultFolder); + if (layoutBefore(toFolder) === LAYOUT_TV) await removeChannelPlaylists([change.channelId]); + await channel.update({ sub_folder: change.subFolder }); + await subfolderModule.register(change.subFolder); + // Gives a channel with nothing to move its show too, and records the + // Videos folder it left; a no-op for a show pinned above. + await channelLayout.applyChannelFolderChange({ + channel, + previousSubFolder: change.previousSubFolder, + change: { oldLayout: layoutBefore(fromFolder), newLayout: layoutBefore(toFolder), newFolder: toFolder }, + }); + } else if (change.type === CHANGE_FOLDER_LAYOUT) { + if (change.layout === LAYOUT_TV) { + const users = await layoutGuards.usersOfFolder(change.folder); + await removeChannelPlaylists(users.channels.map((channel) => channel.channel_id)); + } + await setFolderLayout(change.folder, change.layout); + } else if (change.type === CHANGE_DEFAULT_SUBFOLDER) { + if (layoutBefore(change.value) === LAYOUT_TV) { + const users = await layoutGuards.usersOfGlobalDefault(); + await removeChannelPlaylists(users.channels.map((channel) => channel.channel_id)); + } + await subfolderModule.register(change.value); + setDefaultSubfolder(change.value); + } + return pinned; +} + +/** + * Undo the change after no video could be moved. Shows that were moved go + * back; new shows stay (a show without episodes only pins a location). + * + * @param {Object} params + * @param {Object} params.change + * @param {Array<Object>} params.shows - Pinned shows + */ +async function rollbackSettings({ change, shows }) { + for (const show of shows) { + if (show.action !== SHOW_ACTION.MOVE || !show.previousLocation || !show.showId) continue; + const row = await TvShow.findByPk(show.showId); + if (row) await showStore.moveShowTo(row, show.previousLocation); + } + if (change.type === CHANGE_CHANNEL) { + await Channel.update({ sub_folder: change.previousSubFolder }, { where: { channel_id: change.channelId } }); + } else if (change.type === CHANGE_FOLDER_LAYOUT) { + await setFolderLayout(change.folder, change.previousLayout); + } else if (change.type === CHANGE_DEFAULT_SUBFOLDER) { + setDefaultSubfolder(change.previousValue); + } +} + +module.exports = { + applySettings, + rollbackSettings +}; diff --git a/server/modules/reorganize/showPlanner.js b/server/modules/reorganize/showPlanner.js new file mode 100644 index 000000000..dd716599d --- /dev/null +++ b/server/modules/reorganize/showPlanner.js @@ -0,0 +1,133 @@ +/** + * Where each moved video goes, and the channel shows a change creates or + * moves. A video's destination is its channel's folder after the change (a + * library folder whose layout changes keeps its videos), and in a TV folder + * its owner channel's show: + * - a channel that moves takes its show with it; + * - in a folder that switches to TV, an existing show in another TV folder + * keeps its location (the routing rule: a show is never split), and a show + * left in a folder that is no longer TV moves to the new one. + */ + +const path = require('path'); +const showStore = require('../tvShows/showStore'); +const { resolveChannelFolderName } = require('../filesystem/pathBuilder'); +const { ROOT_SENTINEL } = require('../filesystem/constants'); +const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey } = require('../tvShows/constants'); +const { showDirectory } = require('../tvShows/channelFolders'); +const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT } = require('./constants'); +const { libraryRootOf } = require('./changeScope'); + +const SHOW_ACTION = Object.freeze({ KEEP: 'keep', CREATE: 'create', MOVE: 'move' }); + +/** + * @param {Object} subject - changeScope subject + * @param {Object} context - resolved change + * @returns {{libraryFolder: string, layout: string}} + */ +function targetOf(subject, context) { + if (context.type === CHANGE_FOLDER_LAYOUT) { + return { libraryFolder: subject.libraryFolder, layout: context.layoutAfter(subject.libraryFolder) }; + } + const owner = subject.ownerChannel; + const libraryFolder = owner ? context.folderAfter(owner) : subject.libraryFolder; + return { libraryFolder, layout: context.layoutAfter(libraryFolder) }; +} + +// The channel folder a movie-style video sits in, the natural name for a +// show made from it. +function currentChannelFolder(subject) { + if (subject.currentLayout !== LAYOUT_VIDEOS) return null; + const mediaPath = subject.video.filePath || subject.video.audioFilePath; + const relative = path.relative(libraryRootOf(subject.libraryFolder), mediaPath); + return relative.split(path.sep)[0] || null; +} + +function previousVideosFolderFor(context) { + if (context.type !== CHANGE_CHANNEL || context.layoutBefore(context.fromFolder) !== LAYOUT_VIDEOS) return null; + const previous = context.stored.previousSubFolder; + return previous === null || previous === undefined || previous === '' ? ROOT_SENTINEL : previous; +} + +async function planShow({ ownerChannelId, subject, libraryFolder, context, reserved }) { + const owner = subject.ownerChannel; + const existing = await showStore.findChannelShow(ownerChannelId); + const name = (owner && (owner.title || owner.uploader)) || subject.video.youTubeChannelName || ownerChannelId; + const plot = owner ? owner.description || null : null; + + if (existing) { + const keepsLocation = context.type === CHANGE_FOLDER_LAYOUT + ? context.layoutAfter(existing.library_folder) === LAYOUT_TV + : folderKey(existing.library_folder) === folderKey(libraryFolder); + if (keepsLocation) { + return { + ownerChannelId, showId: existing.id, action: SHOW_ACTION.KEEP, name: existing.name, + libraryFolder: existing.library_folder, folderName: existing.folder_name, plot, externalKey: existing.external_key, + }; + } + const folderName = await showStore.planChannelShowFolder({ + channelId: ownerChannelId, folderName: existing.folder_name, libraryFolder, excludeShowId: existing.id, reserved, + }); + return { + ownerChannelId, showId: existing.id, action: SHOW_ACTION.MOVE, name: existing.name, + libraryFolder, folderName, plot, externalKey: existing.external_key, + previousLocation: { libraryFolder: existing.library_folder, folderName: existing.folder_name }, + previousVideosFolder: previousVideosFolderFor(context), + }; + } + + const wanted = (owner && resolveChannelFolderName(owner)) || currentChannelFolder(subject) || name; + const folderName = await showStore.planChannelShowFolder({ channelId: ownerChannelId, folderName: wanted, libraryFolder, reserved }); + return { + ownerChannelId, showId: null, action: SHOW_ACTION.CREATE, name, + libraryFolder, folderName, plot, externalKey: ownerChannelId, + previousVideosFolder: previousVideosFolderFor(context), + }; +} + +/** + * Plan the show of every owner channel with videos going to a TV folder. + * + * @param {Array<Object>} subjects - changeScope subjects + * @param {Object} context - resolved change + * @returns {Promise<{targets: Map<number, {libraryFolder: string, layout: string}>, shows: Map<string, Object>}>} + * targets by Videos.id; shows by owner channel id + */ +async function planShows(subjects, context) { + const targets = new Map(); + const shows = new Map(); + const reserved = new Set(); + for (const subject of subjects) { + const target = targetOf(subject, context); + targets.set(subject.video.id, target); + if (target.layout !== LAYOUT_TV || shows.has(subject.ownerChannelId)) continue; + shows.set(subject.ownerChannelId, await planShow({ + ownerChannelId: subject.ownerChannelId, subject, libraryFolder: target.libraryFolder, context, reserved, + })); + } + return { targets, shows }; +} + +/** + * Absolute folder of a planned show. + */ +function plannedShowDirectory(plannedShow) { + return showDirectory({ library_folder: plannedShow.libraryFolder, folder_name: plannedShow.folderName }); +} + +/** + * Absolute folder a planned show had before the change, or null. + */ +function previousShowDirectory(plannedShow) { + if (!plannedShow.previousLocation) return null; + const { libraryFolder, folderName } = plannedShow.previousLocation; + return showDirectory({ library_folder: libraryFolder, folder_name: folderName }); +} + +module.exports = { + SHOW_ACTION, + planShows, + targetOf, + plannedShowDirectory, + previousShowDirectory +}; diff --git a/server/modules/rescanRunSummary.js b/server/modules/rescanRunSummary.js index d0ac28a78..d6b632eac 100644 --- a/server/modules/rescanRunSummary.js +++ b/server/modules/rescanRunSummary.js @@ -34,7 +34,10 @@ function describe(outcome, result) { function toRunRecord(result = {}) { if (result.skipped) { - return { status: 'skipped', outcome: 'skipped', message: 'A rescan was already running.', details: null }; + const message = result.reason === 'reorganizing' + ? 'Skipped while downloads were being reorganized.' + : 'A rescan was already running.'; + return { status: 'skipped', outcome: 'skipped', message, details: null }; } let outcome = result.status || (result.timedOut ? 'timed-out' : 'completed'); // A finished scan with failed row writes partly failed, like other scheduled diff --git a/server/modules/scheduledTaskManager.js b/server/modules/scheduledTaskManager.js index 291a65e25..e2d15c3d4 100644 --- a/server/modules/scheduledTaskManager.js +++ b/server/modules/scheduledTaskManager.js @@ -51,6 +51,23 @@ function isTaskRunning(id, state) { const tasks = new Map(); let runRecorder = null; +// One blocker that applies across tasks (a reorganize moving files), checked +// for scheduled occurrences as well as manual runs: (id) => block | null. +let exclusiveBlocker = null; + +function setExclusiveBlocker(blocker) { + exclusiveBlocker = typeof blocker === 'function' ? blocker : null; +} + +function exclusiveBlockFor(id) { + if (!exclusiveBlocker) return null; + try { + return exclusiveBlocker(id) || null; + } catch (err) { + logger.warn({ err, task: id }, 'Could not check the cross-task run blocker'); + return null; + } +} // The recorder (scheduledTaskRuns) is injected at startup once the database is // ready; without one, tasks still run but leave no history. @@ -123,6 +140,14 @@ async function execute(id, state, { trigger = 'scheduled', args = {}, force = fa if (runRecorder) await withRecorder((recorder) => recorder.recordSkipped(id)); return null; } + const exclusive = exclusiveBlockFor(id); + if (exclusive) { + const skipped = { status: 'skipped', outcome: 'skipped', message: exclusive.message, details: null }; + const now = new Date(); + await withRecorder((recorder) => recorder.record({ taskKey: id, trigger, startedAt: now, finishedAt: now, ...skipped })); + notifyStatusChanged(id); + return skipped; + } state.running = true; // Remembered so a skipped run (the task did nothing) can leave the // manual-run cooldown where it was instead of restarting it. @@ -247,6 +272,12 @@ function describeTask(id, state) { }; } +// Whether a registered task is running now (by the manager or its own lock). +function isTaskRunningById(id) { + const state = tasks.get(id); + return state ? isTaskRunning(id, state) : false; +} + function getStatus() { return [...tasks.entries()].map(([id, state]) => describeTask(id, state)); } @@ -264,6 +295,8 @@ async function getRunBlocker(id, { enforceEnabled = true, enforceCooldown = true const state = tasks.get(id); if (!state) return block(RUN_BLOCK_REASONS.NOT_REGISTERED); if (isTaskRunning(id, state)) return block(RUN_BLOCK_REASONS.RUNNING); + const exclusive = exclusiveBlockFor(id); + if (exclusive) return { availableAt: null, ...exclusive }; if (state.getRunBlocker) { let taskBlock = null; try { @@ -322,6 +355,6 @@ function announceRun(id, promise) { } module.exports = { - updateTask, stopAll, getStatus, getTaskSnapshot, setRunRecorder, runNow, getRunBlocker, - notifyStatusChanged, announceRun, RUN_BLOCK_REASONS, + updateTask, stopAll, getStatus, getTaskSnapshot, setRunRecorder, setExclusiveBlocker, runNow, getRunBlocker, + notifyStatusChanged, announceRun, isTaskRunningById, RUN_BLOCK_REASONS, }; diff --git a/server/modules/sidecarWriter.js b/server/modules/sidecarWriter.js new file mode 100644 index 000000000..aab5c0ef2 --- /dev/null +++ b/server/modules/sidecarWriter.js @@ -0,0 +1,131 @@ +/** + * Writes a downloaded video's metadata files at its current location: its NFO + * (movie or episode), fanart and backdrop copies of its thumbnail, and the + * art and tvshow.nfo of the channel or show folder it sits in. Input is the + * video's stored info.json (videoInfoStore), so it works long after the + * download. The reorganize uses it after moving a video; a "regenerate + * metadata" maintenance task (#757, #696) can reuse it. + * + * Only the post-processor downloads missing channel images; this writes the + * cached ones. + */ + +const fs = require('fs'); +const path = require('path'); +const configModule = require('./configModule'); +const nfoGenerator = require('./nfoGenerator'); +const logger = require('../logger'); +const { copySyncWithFallback } = require('./filesystem/fileOperations'); +const { buildEpisodeNfo, writeTvShowNfoIfChanged } = require('./tvShows/tvNfo'); +const { earliestEpisodeDate } = require('./tvShows/episodePlacement'); + +const POSTER_FILE = 'poster.jpg'; +const BACKDROP_FILE = 'backdrop.jpg'; + +function settings() { + const config = configModule.getConfig() || {}; + return { + videoNfo: config.writeVideoNfoFiles !== false, + fanart: config.writeVideoFanart === true, + backdrops: config.writeBackdropImages === true, + posters: config.writeChannelPosters !== false, + }; +} + +function stemPath(filePath, suffix) { + const parsed = path.parse(filePath); + return path.join(parsed.dir, `${parsed.name}${suffix}`); +} + +function copyIfMissing(source, target) { + if (!fs.existsSync(source) || fs.existsSync(target)) return false; + copySyncWithFallback(source, target); + return true; +} + +/** + * Write the NFO and thumbnail copies of one video at its current path. + * + * @param {Object} params + * @param {string} params.videoPath - The video (or MP3) file + * @param {Object} params.info - Its info dict + * @param {Object|null} [params.episode] - { showTitle, season, episode, episodeTitle } for a TV episode + * @returns {Promise<string[]>} Paths written + */ +async function writeVideoSidecars({ videoPath, info, episode = null }) { + const options = settings(); + const written = []; + const nfoPath = stemPath(videoPath, '.nfo'); + + // Episodes always get their NFO: without it Jellyfin names the episode + // after the file, SxxEyy included. + if (episode) { + await fs.promises.writeFile(nfoPath, buildEpisodeNfo({ info, ...episode }), 'utf8'); + written.push(nfoPath); + } else if (options.videoNfo && nfoGenerator.writeVideoNfoFile(videoPath, info)) { + written.push(nfoPath); + } + + const thumbnail = stemPath(videoPath, '.jpg'); + if (options.fanart && copyIfMissing(thumbnail, stemPath(videoPath, '-fanart.jpg'))) { + written.push(stemPath(videoPath, '-fanart.jpg')); + } + if (options.backdrops && copyIfMissing(thumbnail, stemPath(videoPath, '-backdrop.jpg'))) { + written.push(stemPath(videoPath, '-backdrop.jpg')); + } + return written; +} + +/** + * Copy a channel's cached avatar and banner into a channel or show folder, + * as the settings ask, without replacing existing files. + * + * @param {Object} params + * @param {string} params.channelId - YouTube channel id whose images to use + * @param {string} params.folderPath + * @returns {string[]} Paths written + */ +function writeFolderArt({ channelId, folderPath }) { + if (!channelId || !folderPath || !fs.existsSync(folderPath)) return []; + const options = settings(); + const imageDir = configModule.getImagePath(); + const written = []; + const copy = (source, name) => { + try { + if (copyIfMissing(path.join(imageDir, source), path.join(folderPath, name))) { + written.push(path.join(folderPath, name)); + } + } catch (err) { + logger.warn({ err, channelId, folderPath, name }, 'Could not copy channel art'); + } + }; + if (options.posters) copy(`channelthumb-${channelId}.jpg`, POSTER_FILE); + if (options.backdrops) copy(`channelbanner-${channelId}.jpg`, BACKDROP_FILE); + return written; +} + +/** + * Write a show folder's tvshow.nfo (when its content changes) and art. + * + * @param {Object} params + * @param {Object} params.show - tv_shows row + * @param {string} params.showDir + * @param {string|null} [params.plot] - Show description (the channel description) + * @returns {Promise<void>} + */ +async function writeShowMetadata({ show, showDir, plot = null }) { + if (!fs.existsSync(showDir)) return; + await writeTvShowNfoIfChanged(showDir, { + title: show.name, + plot, + premiered: await earliestEpisodeDate(show.id), + externalKey: show.external_key, + }); + writeFolderArt({ channelId: show.channel_id, folderPath: showDir }); +} + +module.exports = { + writeVideoSidecars, + writeFolderArt, + writeShowMetadata +}; diff --git a/server/modules/tvShows/__tests__/channelLayout.test.js b/server/modules/tvShows/__tests__/channelLayout.test.js index 153a0838d..a9942db7b 100644 --- a/server/modules/tvShows/__tests__/channelLayout.test.js +++ b/server/modules/tvShows/__tests__/channelLayout.test.js @@ -52,22 +52,41 @@ describe('channelLayout', () => { await expect(check({ newSubFolder: 'TV', newAudioFormat: 'mp3_only' })).rejects.toMatchObject({ status: 400 }); }); - it('refuses a channel with downloads switching layouts', async () => { + it('sends a channel with downloads switching layouts to the reorganize', async () => { Video.count.mockResolvedValue(3); - await expect(check({ newSubFolder: 'TV' })).rejects.toMatchObject({ status: 409 }); + await expect(check({ newSubFolder: 'TV' })).rejects.toMatchObject({ + status: 409, reorganizeRequired: true, change: { type: 'channel', channelId: 'UC1', subFolder: 'TV' }, + }); }); - it('refuses a TV channel with downloads moving to another TV folder', async () => { + it('sends a TV channel with downloads moving to another TV folder to the reorganize', async () => { Video.count.mockResolvedValue(3); await expect(check({ channel: { ...channel, sub_folder: 'TV' }, newSubFolder: 'Kids TV' })) - .rejects.toMatchObject({ status: 409 }); + .rejects.toMatchObject({ status: 409, reorganizeRequired: true }); }); - it('refuses a switch while a download runs', async () => { + it('sends a channel with downloads to the reorganize even while a download runs', async () => { + Video.count.mockResolvedValue(3); + await expect(check({ newSubFolder: 'TV', isDownloadRunning: () => true })) + .rejects.toMatchObject({ reorganizeRequired: true }); + }); + + it('refuses a direct switch while a download runs', async () => { await expect(check({ newSubFolder: 'TV', isDownloadRunning: () => true })) .rejects.toThrow(channelLayout.MESSAGES.running); }); + it('refuses any change while a reorganize moves the channel\'s files', async () => { + const lock = require('../../reorganize/reorganizeLock'); + const token = lock.acquire({ label: 'Mark Rober' }); + lock.setScope(token, { channelIds: ['UC1'] }); + try { + await expect(check({ newAudioFormat: null })).rejects.toMatchObject({ status: 409, code: 'REORGANIZE_RUNNING' }); + } finally { + lock.release(token); + } + }); + it('keeps allowing videos-folder moves for channels with downloads', async () => { Video.count.mockResolvedValue(3); await expect(check({ newSubFolder: 'Music' })).resolves.toMatchObject({ involvesTv: false }); @@ -185,14 +204,14 @@ describe('channelLayout', () => { libraryFolder: 'TV', show: { name: 'Mark Rober', folderName: 'MR', libraryFolder: 'TV' }, tvFolders: ['TV'], - canSwitch: true, + hasDownloads: false, }); }); - it('reports a channel with downloads as unable to switch', async () => { + it('reports a channel with downloads', async () => { Video.count.mockResolvedValue(1); const state = await channelLayout.getChannelTvState(channel); - expect(state).toMatchObject({ layout: 'videos', show: null, canSwitch: false }); + expect(state).toMatchObject({ layout: 'videos', show: null, hasDownloads: true }); }); }); }); diff --git a/server/modules/tvShows/__tests__/layoutGuards.test.js b/server/modules/tvShows/__tests__/layoutGuards.test.js index 7f81c9b99..7b03843c4 100644 --- a/server/modules/tvShows/__tests__/layoutGuards.test.js +++ b/server/modules/tvShows/__tests__/layoutGuards.test.js @@ -168,11 +168,26 @@ describe('layoutGuards', () => { }); }); - describe('assertChannelsHaveNoDownloads', () => { - it('refuses with the given message when a channel has downloads', async () => { - Video.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1); - await expect(layoutGuards.assertChannelsHaveNoDownloads([{ channel_id: 'A' }, { channel_id: 'B' }], 'has files')) - .rejects.toThrow('has files'); + describe('reorganizeRequiredError', () => { + it('carries the change to review in a 409', () => { + const err = layoutGuards.reorganizeRequiredError('Review it', { type: 'folderLayout', folder: 'Kids', layout: 'tv' }); + + expect(err).toMatchObject({ status: 409, reorganizeRequired: true, change: { type: 'folderLayout', folder: 'Kids', layout: 'tv' } }); + }); + }); + + describe('errorBody', () => { + it('is the error message for a plain refusal', () => { + expect(layoutGuards.errorBody(layoutGuards.guardError('No', 400))).toEqual({ error: 'No' }); + }); + + it('adds the change to review and the error code', () => { + const err = layoutGuards.reorganizeRequiredError('Review it', { type: 'channel', channelId: 'UC1', subFolder: 'TV' }); + err.code = 'X'; + + expect(layoutGuards.errorBody(err)).toEqual({ + error: 'Review it', reorganizeRequired: true, change: { type: 'channel', channelId: 'UC1', subFolder: 'TV' }, code: 'X', + }); }); }); }); diff --git a/server/modules/tvShows/__tests__/libraryFolders.test.js b/server/modules/tvShows/__tests__/libraryFolders.test.js index b4a19e98a..399ca42bc 100644 --- a/server/modules/tvShows/__tests__/libraryFolders.test.js +++ b/server/modules/tvShows/__tests__/libraryFolders.test.js @@ -110,15 +110,28 @@ describe('libraryFolders', () => { .rejects.toThrow(libraryFolders.MESSAGES.running); }); - it('refuses a folder that holds downloaded files', async () => { + it('sends a folder that holds downloaded files to the reorganize', async () => { writeFile('__Kids/Channel/video [abcdefghijk].mp4'); - await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toThrow(libraryFolders.MESSAGES.hasFiles); + await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toMatchObject({ + status: 409, reorganizeRequired: true, change: { type: 'folderLayout', folder: 'Kids', layout: 'tv' }, + message: libraryFolders.MESSAGES.reorganize, + }); }); - it('refuses when a channel in the folder has downloads elsewhere on record', async () => { + it('sends a folder whose channels have downloads on record to the reorganize', async () => { Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', sub_folder: 'Kids', enabled: true }]); Video.count.mockResolvedValue(4); - await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toMatchObject({ status: 409 }); + await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toMatchObject({ status: 409, reorganizeRequired: true }); + }); + + it('refuses while a reorganize runs', async () => { + const lock = require('../../reorganize/reorganizeLock'); + const token = lock.acquire({ label: 'x' }); + try { + await expect(libraryFolders.setFolderLayout('Kids', 'tv')).rejects.toThrow(libraryFolders.MESSAGES.reorganizing); + } finally { + lock.release(token); + } }); it('refuses TV for a folder whose channels download MP3', async () => { @@ -165,16 +178,30 @@ describe('libraryFolders', () => { await expect(check()).resolves.toBeUndefined(); }); - it('refuses when channels on the default have downloads', async () => { + it('sends a default whose channels have downloads to the reorganize', async () => { Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', enabled: true }]); Video.count.mockResolvedValue(1); - await expect(check()).rejects.toThrow(libraryFolders.MESSAGES.defaultHasDownloads); + await expect(check()).rejects.toMatchObject({ + status: 409, reorganizeRequired: true, message: libraryFolders.MESSAGES.defaultReorganize, + change: { type: 'defaultSubfolder' }, + }); }); it('refuses while a download runs', async () => { await expect(check({ isDownloadRunning: () => true })).rejects.toThrow(libraryFolders.MESSAGES.defaultRunning); }); + it('refuses any change of the default while a reorganize runs, but not a save of the same value', async () => { + const lock = require('../../reorganize/reorganizeLock'); + const token = lock.acquire({ label: 'x' }); + try { + await expect(check({ newDefault: 'Music' })).rejects.toThrow(libraryFolders.MESSAGES.reorganizing); + await expect(check({ newDefault: 'kids' })).resolves.toBeUndefined(); + } finally { + lock.release(token); + } + }); + it('refuses a TV default when a playlist on the default downloads MP3', async () => { Playlist.findAll.mockResolvedValue([{ title: 'Mix', audio_format: 'mp3_only' }]); await expect(check()).rejects.toMatchObject({ status: 409 }); diff --git a/server/modules/tvShows/__tests__/showStore.test.js b/server/modules/tvShows/__tests__/showStore.test.js index 5ec66cba3..9beec27b3 100644 --- a/server/modules/tvShows/__tests__/showStore.test.js +++ b/server/modules/tvShows/__tests__/showStore.test.js @@ -127,4 +127,56 @@ describe('showStore', () => { }); }); }); + + describe('planned placement (reorganize)', () => { + it('plans the first folder name no other show in the library folder uses', async () => { + TvShow.findAll.mockResolvedValue([{ id: 2, folder_name: 'Mark Rober' }]); + + const name = await showStore.planChannelShowFolder({ channelId: CHANNEL_ID, folderName: 'Mark Rober', libraryFolder: 'TV' }); + + expect(name).toBe(`Mark Rober (${CHANNEL_ID})`); + expect(TvShow.create).not.toHaveBeenCalled(); + }); + + it('compares folder names ignoring case and accents, like the unique key', async () => { + TvShow.findAll.mockResolvedValue([{ id: 2, folder_name: 'cafe' }]); + + await expect(showStore.planChannelShowFolder({ channelId: CHANNEL_ID, folderName: 'Café', libraryFolder: 'TV' })) + .resolves.toBe(`Café (${CHANNEL_ID})`); + }); + + it('lets a moving show keep its own name', async () => { + TvShow.findAll.mockResolvedValue([{ id: 5, folder_name: 'Mark Rober' }]); + + await expect(showStore.planChannelShowFolder({ + channelId: CHANNEL_ID, folderName: 'Mark Rober', libraryFolder: 'TV', excludeShowId: 5, + })).resolves.toBe('Mark Rober'); + }); + + it('never plans the same name twice in one change', async () => { + TvShow.findAll.mockResolvedValue([]); + const reserved = new Set(); + + const first = await showStore.planChannelShowFolder({ channelId: 'UCA', folderName: 'Music', libraryFolder: 'TV', reserved }); + const second = await showStore.planChannelShowFolder({ channelId: 'UCB', folderName: 'Music', libraryFolder: 'TV', reserved }); + + expect([first, second]).toEqual(['Music', 'Music (UCB)']); + }); + + it('creates a show at exactly the planned location', async () => { + await showStore.createChannelShowAt({ channelId: CHANNEL_ID, name: 'Mark Rober', folderName: 'Mark Rober (x)', libraryFolder: 'TV' }); + + expect(TvShow.create).toHaveBeenCalledWith(expect.objectContaining({ + channel_id: CHANNEL_ID, folder_name: 'Mark Rober (x)', library_folder: 'TV', external_key: CHANNEL_ID, kind: 'channel', + })); + }); + + it('moves a show to exactly the planned location', async () => { + const show = { update: jest.fn().mockResolvedValue(undefined) }; + + await showStore.moveShowTo(show, { libraryFolder: '', folderName: 'Mark Rober' }); + + expect(show.update).toHaveBeenCalledWith({ library_folder: '', folder_name: 'Mark Rober' }); + }); + }); }); diff --git a/server/modules/tvShows/channelLayout.js b/server/modules/tvShows/channelLayout.js index 1b8f42de4..22a605e97 100644 --- a/server/modules/tvShows/channelLayout.js +++ b/server/modules/tvShows/channelLayout.js @@ -13,14 +13,16 @@ const { getLayoutResolver, listTvFolders } = require('./libraryLayouts'); const showStore = require('./showStore'); const { effectiveLibraryFolder, showDirectory } = require('./channelFolders'); const layoutGuards = require('./layoutGuards'); +const reorganizeLock = require('../reorganize/reorganizeLock'); +const { CHANGE_CHANNEL } = require('../reorganize/constants'); const LAYOUTS = new Set([LAYOUT_VIDEOS, LAYOUT_TV]); const MESSAGES = { mp3: 'TV shows are video-only. Change this channel\'s download type to Video before saving it to a TV folder.', running: 'Wait for the current download to finish before switching this channel between Videos and TV.', - hasDownloads: 'This channel already has downloaded videos, so it can\'t switch between Videos and TV or move to ' - + 'another TV folder yet.', + reorganize: 'This channel has downloaded videos, so switching between Videos and TV or to another TV folder ' + + 'moves its files. Review the move first.', }; // sub_folder value that targets a library folder explicitly. @@ -30,8 +32,10 @@ function subFolderValueFor(libraryFolder) { /** * Check a channel settings change before it is saved. Refuses an MP3 download - * type in a TV folder, and a folder change that crosses layouts or moves a - * show between TV folders while the channel has downloads or a download runs. + * type in a TV folder; a folder change that crosses layouts or moves a show + * between TV folders goes through the reorganize when the channel has + * downloads (reorganizeRequired 409) and is refused while a download runs. + * Any change is refused while a reorganize is moving the channel's files. * * @param {Object} params * @param {Object} params.channel - Current channels row @@ -41,6 +45,7 @@ function subFolderValueFor(libraryFolder) { * @returns {Promise<{oldFolder: string, newFolder: string, oldLayout: string, newLayout: string, involvesTv: boolean}>} */ async function checkChannelSettingsChange({ channel, newSubFolder, newAudioFormat, isDownloadRunning }) { + reorganizeLock.assertChannelFree(channel.channel_id); const layoutOf = await getLayoutResolver(); const oldFolder = effectiveLibraryFolder(channel.sub_folder); const newFolder = newSubFolder === undefined ? oldFolder : effectiveLibraryFolder(newSubFolder); @@ -55,10 +60,12 @@ async function checkChannelSettingsChange({ channel, newSubFolder, newAudioForma const folderChanged = folderKey(oldFolder) !== folderKey(newFolder); if (folderChanged && change.involvesTv) { - layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.running); if (await layoutGuards.channelHasDownloads(channel.channel_id)) { - throw layoutGuards.guardError(MESSAGES.hasDownloads, 409); + throw layoutGuards.reorganizeRequiredError(MESSAGES.reorganize, { + type: CHANGE_CHANNEL, channelId: channel.channel_id, subFolder: newSubFolder === undefined ? null : newSubFolder, + }); } + layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.running); } return change; } @@ -174,7 +181,6 @@ async function getChannelTvState(channel) { defaultFolder, defaultFolderLayout: layoutOf(defaultFolder), hasDownloads, - canSwitch: !hasDownloads, }; } diff --git a/server/modules/tvShows/layoutGuards.js b/server/modules/tvShows/layoutGuards.js index bf71276b7..da2bd189c 100644 --- a/server/modules/tvShows/layoutGuards.js +++ b/server/modules/tvShows/layoutGuards.js @@ -1,9 +1,11 @@ /** - * Refusals that keep a library folder from mixing layouts. Until downloads can - * be reorganized, a channel or folder that already holds downloaded videos - * can't switch between videos and TV, and nothing switches while a download - * runs (its later videos would land in the new layout, its earlier ones in - * the old). TV layout is also video-only: MP3 downloads stay out of TV folders. + * Refusals that keep a library folder from mixing layouts. A channel or folder + * that already holds downloaded videos switches between videos and TV only + * through the reorganize, which moves its files (a reorganizeRequired 409 + * names the change for its preview), and nothing switches directly while a + * download runs (its later videos would land in the new layout, its earlier + * ones in the old). TV layout is also video-only: MP3 downloads stay out of + * TV folders. */ const fs = require('fs'); @@ -24,6 +26,34 @@ function guardError(message, status) { return err; } +/** + * A 409 for a change that moves downloaded files: the client opens the + * reorganize preview for `change` instead of saving directly. + * @param {string} message + * @param {Object} change - A reorganize change (see reorganize/changeContext) + */ +function reorganizeRequiredError(message, change) { + const err = guardError(message, 409); + err.reorganizeRequired = true; + err.change = change; + return err; +} + +/** + * Response body for a refusal: { error }, plus the change to review when the + * refusal asks for a reorganize, and the error code when there is one. + * @param {Error} error - An error carrying .status + */ +function errorBody(error) { + const body = { error: error.message }; + if (error.reorganizeRequired) { + body.reorganizeRequired = true; + body.change = error.change; + } + if (error.code) body.code = error.code; + return body; +} + /** * @param {() => boolean} [isDownloadRunning] * @param {string} message @@ -138,22 +168,11 @@ async function assertVideoOnlyDestination({ audioFormat, subFolderValue }) { } } -/** - * Refuse when any of the channels has downloaded videos. - * @param {Array} channels - channels rows - * @param {string} message - */ -async function assertChannelsHaveNoDownloads(channels, message) { - for (const channel of channels) { - if (await channelHasDownloads(channel.channel_id)) { - throw guardError(message, 409); - } - } -} - module.exports = { MP3_AUDIO_FORMATS, guardError, + reorganizeRequiredError, + errorBody, isMp3Format, assertNoDownloadRunning, channelHasDownloads, @@ -162,6 +181,5 @@ module.exports = { usersOfFolder, usersOfGlobalDefault, assertNoMp3Users, - assertVideoOnlyDestination, - assertChannelsHaveNoDownloads + assertVideoOnlyDestination }; diff --git a/server/modules/tvShows/libraryFolders.js b/server/modules/tvShows/libraryFolders.js index 6fd9d3250..9ecc99151 100644 --- a/server/modules/tvShows/libraryFolders.js +++ b/server/modules/tvShows/libraryFolders.js @@ -2,8 +2,9 @@ * Library folders as Settings shows them: the main downloads folder and each * __subfolder, with its layout and what uses it. Changing a folder's layout, * or moving the default subfolder to a folder with another layout, changes - * the layout of every channel that downloads there, so both are refused - * while that would leave a folder with videos of the old layout. + * the layout of every channel that downloads there, so when downloaded videos + * would be left in the old layout the change goes through the reorganize + * (a reorganizeRequired 409 naming the change for its preview). */ const fs = require('fs'); @@ -16,6 +17,8 @@ const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey } = require('./constants'); const libraryLayouts = require('./libraryLayouts'); const layoutGuards = require('./layoutGuards'); const { effectiveLibraryFolder } = require('./channelFolders'); +const reorganizeLock = require('../reorganize/reorganizeLock'); +const { CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER } = require('../reorganize/constants'); const LAYOUTS = new Set([LAYOUT_VIDEOS, LAYOUT_TV]); // Main folder as TV: a Plex TV library pointed there skips the subfolders. @@ -25,11 +28,12 @@ const PLEXIGNORE_CONTENT = `${PLEXIGNORE_SUBFOLDER_RULE}\n`; const MESSAGES = { running: 'Wait for the current download to finish before changing a folder\'s layout.', - hasFiles: 'This folder already holds downloaded videos, so its layout can\'t change yet.', + reorganize: 'This folder holds downloaded videos, so changing its layout moves them. Review the move first.', defaultRunning: 'Wait for the current download to finish before switching the default subfolder to a folder ' + 'with a different layout.', - defaultHasDownloads: 'Channels that use the default subfolder already have downloaded videos, so the default can\'t ' - + 'switch to a folder with a different layout yet.', + defaultReorganize: 'Channels that use the default subfolder have downloaded videos, so switching the default to a ' + + 'folder with a different layout moves them. Review the move first.', + reorganizing: 'Downloads are being reorganized. Change folder layouts when that finishes.', }; /** @@ -107,13 +111,19 @@ async function setFolderLayout(name, layout, { isDownloadRunning } = {}) { const layoutOf = await libraryLayouts.getLayoutResolver(); if (layoutOf(folder) === layout) return { changed: false }; - layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.running); + reorganizeLock.assertInactive(MESSAGES.reorganizing); const users = await layoutGuards.usersOfFolder(folder); if (layout === LAYOUT_TV) layoutGuards.assertNoMp3Users(users, 'this folder'); + const reorganize = { type: CHANGE_FOLDER_LAYOUT, folder, layout }; if (await layoutGuards.folderHasFiles(folder)) { - throw layoutGuards.guardError(MESSAGES.hasFiles, 409); + throw layoutGuards.reorganizeRequiredError(MESSAGES.reorganize, reorganize); + } + for (const channel of users.channels) { + if (await layoutGuards.channelHasDownloads(channel.channel_id)) { + throw layoutGuards.reorganizeRequiredError(MESSAGES.reorganize, reorganize); + } } - await layoutGuards.assertChannelsHaveNoDownloads(users.channels, MESSAGES.hasFiles); + layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.running); // A subfolder known only from config (the default subfolder, Plex mappings) // gets its row before the layout is stored on it. @@ -131,9 +141,10 @@ async function setFolderLayout(name, layout, { isDownloadRunning } = {}) { } /** - * Refuse a default subfolder change that would switch the channels and - * playlists following the default between videos and TV while that can't - * be done cleanly. + * Check a default subfolder change that switches the channels and playlists + * following the default between videos and TV: refused while a reorganize + * runs or for MP3 users, sent to the reorganize when those channels have + * downloads, and refused while a download runs otherwise. * * @param {Object} params * @param {string|null} params.oldDefault @@ -141,20 +152,33 @@ async function setFolderLayout(name, layout, { isDownloadRunning } = {}) { * @param {() => boolean} [params.isDownloadRunning] */ async function checkDefaultSubfolderChange({ oldDefault, newDefault, isDownloadRunning }) { + const before = String(oldDefault || '').trim(); + const after = String(newDefault || '').trim(); + if (folderKey(before) === folderKey(after)) return; + // A running reorganize planned its destinations against the current + // default; the channels that follow it must not move under it. + reorganizeLock.assertInactive(MESSAGES.reorganizing); const layoutOf = await libraryLayouts.getLayoutResolver(); - const newLayout = layoutOf(String(newDefault || '').trim()); - if (layoutOf(String(oldDefault || '').trim()) === newLayout) return; + const newLayout = layoutOf(after); + if (layoutOf(before) === newLayout) return; const users = await layoutGuards.usersOfGlobalDefault(); if (newLayout === LAYOUT_TV) layoutGuards.assertNoMp3Users(users, 'the default subfolder'); + for (const channel of users.channels) { + if (await layoutGuards.channelHasDownloads(channel.channel_id)) { + throw layoutGuards.reorganizeRequiredError(MESSAGES.defaultReorganize, { + type: CHANGE_DEFAULT_SUBFOLDER, value: String(newDefault || '').trim(), + }); + } + } layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.defaultRunning); - await layoutGuards.assertChannelsHaveNoDownloads(users.channels, MESSAGES.defaultHasDownloads); } module.exports = { PLEXIGNORE_NAME, PLEXIGNORE_CONTENT, MESSAGES, + syncPlexIgnore, listLibraryFolders, setFolderLayout, checkDefaultSubfolderChange diff --git a/server/modules/tvShows/showStore.js b/server/modules/tvShows/showStore.js index c2822f848..ba15b1c2b 100644 --- a/server/modules/tvShows/showStore.js +++ b/server/modules/tvShows/showStore.js @@ -111,6 +111,66 @@ async function relocateChannelShow(show, libraryFolder) { }); } +// Folder names compare like the unique key's utf8mb4_unicode_ci collation: +// ignoring case and accents. +function folderNameKey(libraryFolder, folderName) { + const fold = (value) => String(value || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase(); + return `${fold(libraryFolder)}/${fold(folderName)}`; +} + +/** + * The folder name a channel show would get in a library folder, without + * writing anything: the first candidate no other show there uses and no + * earlier plan reserved. The reorganize previews and then creates shows with + * exactly these names. + * + * @param {Object} params + * @param {string} params.channelId + * @param {string} params.folderName - Wanted name (sanitized here) + * @param {string} params.libraryFolder + * @param {number|null} [params.excludeShowId] - The show being moved, whose own name doesn't count + * @param {Set<string>} [params.reserved] - Names planned for other shows in the same change; updated + * @returns {Promise<string>} + */ +async function planChannelShowFolder({ channelId, folderName, libraryFolder, excludeShowId = null, reserved = new Set() }) { + const candidates = folderNameCandidates(sanitizeShowFolderName(folderName), channelId); + const used = await TvShow.findAll({ where: { library_folder: libraryFolder || '' }, attributes: ['id', 'folder_name'] }); + const taken = new Set(used + .filter((row) => row.id !== excludeShowId) + .map((row) => folderNameKey(libraryFolder, row.folder_name))); + const choice = candidates.find((name) => { + const key = folderNameKey(libraryFolder, name); + return !taken.has(key) && !reserved.has(key); + }); + if (!choice) throw new Error(`No free show folder name among: ${candidates.join(', ')}`); + reserved.add(folderNameKey(libraryFolder, choice)); + return choice; +} + +/** + * Create a channel show at an exact, planned location. + */ +async function createChannelShowAt({ channelId, name, folderName, libraryFolder, previousVideosFolder = null }) { + return TvShow.create({ + channel_id: channelId, + kind: KIND_CHANNEL_SHOW, + name: name || folderName, + folder_name: folderName, + library_folder: libraryFolder || '', + external_key: channelId, + previous_videos_folder: previousVideosFolder, + }); +} + +/** + * Point a show at an exact, planned location. Moving the files is the + * caller's job. + */ +async function moveShowTo(show, { libraryFolder, folderName }) { + await show.update({ library_folder: libraryFolder || '', folder_name: folderName }); + return show; +} + /** * @param {Object} show - tv_shows row * @returns {{id: number, libraryFolder: string, folderName: string}} @@ -125,5 +185,8 @@ module.exports = { findChannelShow, createChannelShow, relocateChannelShow, + planChannelShowFolder, + createChannelShowAt, + moveShowTo, toLocation }; diff --git a/server/modules/videoInfoStore.js b/server/modules/videoInfoStore.js new file mode 100644 index 000000000..454b8b307 --- /dev/null +++ b/server/modules/videoInfoStore.js @@ -0,0 +1,83 @@ +/** + * The info.json yt-dlp wrote for each downloaded video, kept at + * jobs/info/<id>.info.json. The post-processor stores the video's final paths + * in it (_actual_filepath and friends), which job recovery and the metadata + * processor read back, so anything that moves a video's files rewrites them. + */ + +const fs = require('fs'); +const path = require('path'); +const configModule = require('./configModule'); +const logger = require('../logger'); + +const ACTUAL_PATH_FIELDS = ['_actual_filepath', '_actual_video_filepath', '_actual_audio_filepath']; + +function infoJsonPath(youtubeId) { + return path.join(configModule.getJobsPath(), 'info', `${youtubeId}.info.json`); +} + +/** + * @param {string} youtubeId + * @returns {Promise<Object|null>} null when the file is missing or unreadable + */ +async function readInfo(youtubeId) { + try { + return JSON.parse(await fs.promises.readFile(infoJsonPath(youtubeId), 'utf8')); + } catch (err) { + if (err.code !== 'ENOENT') { + logger.warn({ err, youtubeId }, 'Could not read the stored info.json'); + } + return null; + } +} + +/** + * A video's info dict: the stored info.json, or what its videos row knows + * when that file is gone. A missing upload date is filled from the row. + * + * @param {Object} video - videos row (youtubeId, youTubeVideoName, youTubeChannelName, originalDate) + * @returns {Promise<Object>} + */ +async function readInfoOrFallback(video) { + const stored = await readInfo(video.youtubeId); + const info = stored || { + id: video.youtubeId, + title: video.youTubeVideoName, + uploader: video.youTubeChannelName, + channel: video.youTubeChannelName, + }; + if (!Number.isFinite(info.timestamp) && !info.upload_date && video.originalDate) { + return { ...info, upload_date: video.originalDate }; + } + return info; +} + +/** + * Replace stored final paths that point at moved files. + * @param {string} youtubeId + * @param {Map<string, string>} moves - old path -> new path + * @returns {Promise<boolean>} true when the file was rewritten + */ +async function rewriteActualPaths(youtubeId, moves) { + const info = await readInfo(youtubeId); + if (!info) return false; + let changed = false; + for (const field of ACTUAL_PATH_FIELDS) { + const current = info[field]; + if (typeof current === 'string' && moves.has(current)) { + info[field] = moves.get(current); + changed = true; + } + } + if (!changed) return false; + await fs.promises.writeFile(infoJsonPath(youtubeId), JSON.stringify(info, null, 2)); + return true; +} + +module.exports = { + ACTUAL_PATH_FIELDS, + infoJsonPath, + readInfo, + readInfoOrFallback, + rewriteActualPaths +}; diff --git a/server/modules/videosModule.js b/server/modules/videosModule.js index 495b1c72e..010c70ea6 100644 --- a/server/modules/videosModule.js +++ b/server/modules/videosModule.js @@ -18,6 +18,7 @@ const { probeVideoDimensions } = require('./resolutionTier'); const createLimiter = require('./subscriptionImport/concurrencyLimiter'); const { isRescanCandidate, resolveRescanUpdate } = require('./rescanRowUpdate'); const { unchangedSinceRead, GUARDED_COLUMNS } = require('./videoRowGuard'); +const reorganizeLock = require('./reorganize/reorganizeLock'); // Backfill row updates are applied in parameterized batches of this size, // and flushed mid-chunk at the same cadence so completed work survives a @@ -533,6 +534,12 @@ class VideosModule { logger.info({ trigger }, 'Backfill already running, skipping'); return { skipped: true, reason: 'already-running' }; } + // A reorganize moves files the scan would read; the startup pass checks + // here, scheduled runs are refused by the task manager. + if (reorganizeLock.isActive()) { + logger.info({ trigger }, 'Downloads are being reorganized; skipping the rescan'); + return { skipped: true, reason: 'reorganizing' }; + } this._backfillRunning = true; const startTime = Date.now(); diff --git a/server/routes/__tests__/config.test.js b/server/routes/__tests__/config.test.js index 52dd1b1ea..e8ab5e10e 100644 --- a/server/routes/__tests__/config.test.js +++ b/server/routes/__tests__/config.test.js @@ -260,6 +260,22 @@ describe('POST /updateconfig', () => { expect(res.body).toEqual({ error: 'channels have downloads' }); expect(configModule.updateConfig).not.toHaveBeenCalled(); }); + + test('names the reorganize to preview when the default change moves downloads', async () => { + const libraryFolders = { + checkDefaultSubfolderChange: jest.fn().mockRejectedValue(Object.assign(refusal('Review the move', 409), { + reorganizeRequired: true, change: { type: 'defaultSubfolder', value: 'TV' }, + })), + }; + const { app } = makeApp({ libraryFolders }); + + const res = await supertest(app).post('/updateconfig').send({ defaultSubfolder: 'TV' }); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ + error: 'Review the move', reorganizeRequired: true, change: { type: 'defaultSubfolder', value: 'TV' }, + }); + }); }); test('returns 200 when ytdlpCustomArgs is empty', async () => { diff --git a/server/routes/__tests__/tvReorganize.test.js b/server/routes/__tests__/tvReorganize.test.js new file mode 100644 index 000000000..762172954 --- /dev/null +++ b/server/routes/__tests__/tvReorganize.test.js @@ -0,0 +1,169 @@ +/* eslint-env jest */ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../logger', () => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() })); + +function refusal(message, status, code) { + return Object.assign(new Error(message), { status, code }); +} + +const CHANGE = { type: 'channelLayout', channelId: 'UC1', layout: 'tv' }; + +describe('reorganize routes', () => { + let app; + let reorganize; + let watchStatusHolds; + let watchStatusPushBack; + + beforeEach(() => { + jest.resetModules(); + reorganize = { + preview: jest.fn().mockResolvedValue({ revision: 'rev', needed: true }), + start: jest.fn().mockResolvedValue({ operationId: 3, applied: false }), + getActive: jest.fn().mockResolvedValue(null), + getOperation: jest.fn().mockResolvedValue({ id: 3, status: 'running' }), + retry: jest.fn().mockResolvedValue({ operationId: 3 }), + }; + watchStatusHolds = { + describeHolds: jest.fn().mockResolvedValue([{ id: 1 }]), + countHolds: jest.fn().mockResolvedValue({ pending: 1, failed: 0 }), + reopenHold: jest.fn().mockResolvedValue({ id: 1 }), + dismissHold: jest.fn().mockResolvedValue(true), + }; + watchStatusPushBack = { pushPendingHolds: jest.fn().mockResolvedValue({ pushed: 1, notIndexed: 0, failed: 0 }) }; + const createRoutes = require('../tvReorganize'); + app = express(); + app.use(express.json()); + app.use(createRoutes({ verifyToken: (req, res, next) => next(), reorganize, watchStatusHolds, watchStatusPushBack })); + }); + + describe('POST /api/tv/reorganize/preview', () => { + test('returns the preview for the change', async () => { + const res = await request(app).post('/api/tv/reorganize/preview').send({ change: CHANGE }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ revision: 'rev', needed: true }); + expect(reorganize.preview).toHaveBeenCalledWith(CHANGE); + }); + + test('requires a change object', async () => { + const res = await request(app).post('/api/tv/reorganize/preview').send({ change: 'tv' }); + expect(res.status).toBe(400); + expect(reorganize.preview).not.toHaveBeenCalled(); + }); + + test('passes a refusal through with its status', async () => { + reorganize.preview.mockRejectedValueOnce(refusal('Channel not found', 404)); + const res = await request(app).post('/api/tv/reorganize/preview').send({ change: CHANGE }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Channel not found' }); + }); + + test('returns 500 for an unexpected failure', async () => { + reorganize.preview.mockRejectedValueOnce(new Error('db')); + const res = await request(app).post('/api/tv/reorganize/preview').send({ change: CHANGE }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to preview the reorganize' }); + }); + }); + + describe('POST /api/tv/reorganize', () => { + test('starts the reorganize with 202', async () => { + const res = await request(app).post('/api/tv/reorganize').send({ change: CHANGE, revision: 'rev' }); + expect(res.status).toBe(202); + expect(res.body).toEqual({ operationId: 3, applied: false }); + expect(reorganize.start).toHaveBeenCalledWith(CHANGE, 'rev'); + }); + + test('answers 200 when the change applied with nothing to move', async () => { + reorganize.start.mockResolvedValueOnce({ operationId: null, applied: true }); + const res = await request(app).post('/api/tv/reorganize').send({ change: CHANGE, revision: 'rev' }); + expect(res.status).toBe(200); + }); + + test('requires the revision', async () => { + const res = await request(app).post('/api/tv/reorganize').send({ change: CHANGE }); + expect(res.status).toBe(400); + }); + + test('passes a stale preview through with its code', async () => { + reorganize.start.mockRejectedValueOnce(refusal('Review the move again.', 409, 'STALE_PREVIEW')); + const res = await request(app).post('/api/tv/reorganize').send({ change: CHANGE, revision: 'old' }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'Review the move again.', code: 'STALE_PREVIEW' }); + }); + }); + + describe('operations', () => { + test('returns the running operation', async () => { + reorganize.getActive.mockResolvedValueOnce({ id: 3 }); + const res = await request(app).get('/api/tv/operations/active'); + expect(res.body).toEqual({ operation: { id: 3 } }); + }); + + test('returns an operation by id', async () => { + const res = await request(app).get('/api/tv/operations/3'); + expect(res.status).toBe(200); + expect(reorganize.getOperation).toHaveBeenCalledWith(3); + }); + + test('returns 404 for an unknown operation', async () => { + reorganize.getOperation.mockResolvedValueOnce(null); + const res = await request(app).get('/api/tv/operations/9'); + expect(res.status).toBe(404); + }); + + test('rejects an invalid operation id', async () => { + const res = await request(app).get('/api/tv/operations/abc'); + expect(res.status).toBe(400); + }); + + test('retries an operation\'s failed videos with 202', async () => { + const res = await request(app).post('/api/tv/operations/3/retry'); + expect(res.status).toBe(202); + expect(reorganize.retry).toHaveBeenCalledWith(3); + }); + }); + + describe('watch-state restores', () => { + test('lists pending and failed restores with counts by default', async () => { + const res = await request(app).get('/api/tv/holds'); + expect(res.body).toEqual({ holds: [{ id: 1 }], counts: { pending: 1, failed: 0 } }); + expect(watchStatusHolds.describeHolds).toHaveBeenCalledWith({ states: ['pending', 'failed'] }); + }); + + test('lists the requested states', async () => { + await request(app).get('/api/tv/holds?state=failed'); + expect(watchStatusHolds.describeHolds).toHaveBeenCalledWith({ states: ['failed'] }); + }); + + test('rejects an unknown state', async () => { + const res = await request(app).get('/api/tv/holds?state=lost'); + expect(res.status).toBe(400); + }); + + test('retries a restore by reopening and pushing it now', async () => { + const res = await request(app).post('/api/tv/holds/1/retry'); + expect(res.status).toBe(200); + expect(watchStatusHolds.reopenHold).toHaveBeenCalledWith(1); + expect(watchStatusPushBack.pushPendingHolds).toHaveBeenCalledWith({ holdIds: [1] }); + }); + + test('returns 404 when retrying an unknown restore', async () => { + watchStatusHolds.reopenHold.mockResolvedValueOnce(null); + const res = await request(app).post('/api/tv/holds/9/retry'); + expect(res.status).toBe(404); + }); + + test('dismisses a restore', async () => { + const res = await request(app).post('/api/tv/holds/1/dismiss'); + expect(res.status).toBe(204); + }); + + test('returns 404 when dismissing an unknown restore', async () => { + watchStatusHolds.dismissHold.mockResolvedValueOnce(false); + const res = await request(app).post('/api/tv/holds/9/dismiss'); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/server/routes/__tests__/tvShows.test.js b/server/routes/__tests__/tvShows.test.js index ea9adca95..41ace4292 100644 --- a/server/routes/__tests__/tvShows.test.js +++ b/server/routes/__tests__/tvShows.test.js @@ -15,7 +15,9 @@ describe('TV show routes', () => { let channelSettingsModule; let jobModule; let models; + let reorganize; const channel = { channel_id: 'UC1', sub_folder: 'Kids' }; + const REORGANIZE_STATE = { running: false, unmoved: null }; beforeEach(() => { jest.resetModules(); @@ -32,6 +34,7 @@ describe('TV show routes', () => { }; jobModule = { getInProgressJobId: jest.fn().mockReturnValue(null) }; models = { Channel: { findOne: jest.fn().mockResolvedValue(channel) } }; + reorganize = { channelState: jest.fn().mockResolvedValue(REORGANIZE_STATE) }; const createTvShowRoutes = require('../tvShows'); app = express(); app.use(express.json()); @@ -39,6 +42,13 @@ describe('TV show routes', () => { verifyToken: (req, res, next) => next(), libraryFolders, channelLayout, + layoutGuards: { + errorBody: (error) => ({ + error: error.message, + ...(error.reorganizeRequired ? { reorganizeRequired: true, change: error.change } : {}), + }), + }, + reorganize, channelSettingsModule, jobModule, models, @@ -91,13 +101,25 @@ describe('TV show routes', () => { expect(res.status).toBe(409); expect(res.body).toEqual({ error: 'holds downloads' }); }); + + test('names the change to preview when the folder\'s files must move', async () => { + libraryFolders.setFolderLayout.mockRejectedValueOnce(Object.assign(refusal('Review the move', 409), { + reorganizeRequired: true, change: { type: 'folderLayout', folder: 'TV', layout: 'tv' }, + })); + const res = await request(app).put('/api/library-folders').send({ name: 'TV', layout: 'tv' }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ + error: 'Review the move', reorganizeRequired: true, change: { type: 'folderLayout', folder: 'TV', layout: 'tv' }, + }); + }); }); describe('GET /api/channels/:channelId/tv', () => { - test('returns the channel TV state', async () => { + test('returns the channel TV state with its reorganize state', async () => { const res = await request(app).get('/api/channels/UC1/tv'); expect(res.status).toBe(200); - expect(res.body).toEqual({ layout: 'tv' }); + expect(res.body).toEqual({ layout: 'tv', reorganize: REORGANIZE_STATE }); + expect(reorganize.channelState).toHaveBeenCalledWith('UC1'); }); test('returns 404 for an unknown channel', async () => { @@ -115,7 +137,7 @@ describe('TV show routes', () => { expect(channelSettingsModule.updateChannelSettings).toHaveBeenCalledWith( 'UC1', { sub_folder: 'TV' }, expect.objectContaining({ isDownloadRunning: expect.any(Function) }) ); - expect(res.body).toEqual({ settings: { sub_folder: 'TV' }, tv: { layout: 'tv' } }); + expect(res.body).toEqual({ settings: { sub_folder: 'TV' }, tv: { layout: 'tv', reorganize: REORGANIZE_STATE } }); }); test('rejects a non-string layout with 400', async () => { @@ -141,10 +163,13 @@ describe('TV show routes', () => { expect(res.body).toEqual({ error: 'Choose a TV folder.' }); }); - test('passes a has-downloads refusal through as 409', async () => { - channelSettingsModule.updateChannelSettings.mockRejectedValueOnce(refusal('has downloads', 409)); + test('passes a reorganize-required refusal through as 409 with the change', async () => { + channelSettingsModule.updateChannelSettings.mockRejectedValueOnce(Object.assign(refusal('Review the move', 409), { + reorganizeRequired: true, change: { type: 'channel', channelId: 'UC1', subFolder: 'TV' }, + })); const res = await request(app).put('/api/channels/UC1/tv/layout').send({ layout: 'tv' }); expect(res.status).toBe(409); + expect(res.body).toMatchObject({ reorganizeRequired: true, change: { type: 'channel', channelId: 'UC1', subFolder: 'TV' } }); }); test('returns 500 for an unexpected failure', async () => { diff --git a/server/routes/__tests__/videos.reorganizeDelete.test.js b/server/routes/__tests__/videos.reorganizeDelete.test.js new file mode 100644 index 000000000..77d7403c5 --- /dev/null +++ b/server/routes/__tests__/videos.reorganizeDelete.test.js @@ -0,0 +1,51 @@ +/* eslint-env jest */ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../logger', () => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() })); +jest.mock('../../modules/videoDeletionModule', () => ({ + deleteVideos: jest.fn().mockResolvedValue({ success: true, deleted: [1], failed: [] }), + deleteVideosByYoutubeIds: jest.fn().mockResolvedValue({ success: true, deleted: ['abc'], failed: [] }), +})); + +describe('DELETE /api/videos while a reorganize runs', () => { + let app; + let reorganizeLock; + let videoDeletionModule; + + beforeEach(() => { + jest.clearAllMocks(); + videoDeletionModule = require('../../modules/videoDeletionModule'); + reorganizeLock = { coversAnyVideo: jest.fn(() => false) }; + const createVideoRoutes = require('../videos'); + app = express(); + app.use(express.json()); + app.use((req, res, next) => { req.log = { warn: jest.fn(), error: jest.fn(), info: jest.fn() }; next(); }); + app.use(createVideoRoutes({ + verifyToken: (req, res, next) => next(), + videosModule: {}, + downloadModule: {}, + storageGuard: { isPausedError: () => false }, + layoutGuards: {}, + reorganizeLock, + })); + }); + + test('refuses videos the reorganize is moving', async () => { + reorganizeLock.coversAnyVideo.mockReturnValue(true); + + const response = await request(app).delete('/api/videos').send({ youtubeIds: ['abc'] }); + + expect(response.status).toBe(409); + expect(response.body.error).toMatch(/being reorganized/); + expect(reorganizeLock.coversAnyVideo).toHaveBeenCalledWith({ ids: [], youtubeIds: ['abc'] }); + expect(videoDeletionModule.deleteVideosByYoutubeIds).not.toHaveBeenCalled(); + }); + + test('deletes other videos', async () => { + const response = await request(app).delete('/api/videos').send({ videoIds: [1] }); + + expect(response.status).toBe(200); + expect(videoDeletionModule.deleteVideos).toHaveBeenCalledWith([1]); + }); +}); diff --git a/server/routes/channels.js b/server/routes/channels.js index f01146a5a..e51c355ae 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -171,7 +171,7 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * 400: * description: Invalid payload, invalid settings on an add item (no channel is changed), or an MP3 download type for a channel in a TV folder * 409: - * description: An add item changes the subfolder of a channel that has downloads in progress, or moves a channel with downloaded videos between Videos and TV + * description: An add item changes the subfolder of a channel that has downloads in progress or is being reorganized, or would move downloaded videos between Videos and TV (reorganizeRequired) * 500: * description: Failed to update channels */ @@ -210,9 +210,9 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch if (error.message?.includes('Cannot change subfolder while downloads are in progress')) { return res.status(409).json({ error: error.message }); } - // Library folder layout refusals (MP3 into a TV folder, switching a channel with downloads) + // Library folder layout refusals (MP3 into a TV folder, a change that moves downloaded files) if (error.status === 400 || error.status === 409) { - return res.status(error.status).json({ error: error.message }); + return res.status(error.status).json(layoutGuards.errorBody(error)); } req.log.error({ err: error }, 'Failed to update channels'); res.status(500).json({ @@ -690,7 +690,7 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * 400: * description: An MP3 download type for a channel in a TV folder * 409: - * description: Downloads are in progress, or a folder change would switch a channel with downloaded videos between Videos and TV + * description: Downloads are in progress, a reorganize of the channel is running, or a folder change would move downloaded videos between Videos and TV (reorganizeRequired, with the change to preview through /api/tv/reorganize/preview) * 500: * description: Failed to update settings */ @@ -704,8 +704,8 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch res.json(result); } catch (error) { console.error('Error updating channel settings:', error); - const statusCode = error.status - || (error.message.includes('Cannot change subfolder while downloads are in progress') ? 409 : 500); + if (error.status) return res.status(error.status).json(layoutGuards.errorBody(error)); + const statusCode = error.message.includes('Cannot change subfolder while downloads are in progress') ? 409 : 500; res.status(statusCode).json({ error: error.message }); } }); diff --git a/server/routes/config.js b/server/routes/config.js index 3753412be..de00a7750 100644 --- a/server/routes/config.js +++ b/server/routes/config.js @@ -226,7 +226,7 @@ module.exports = function createConfigRoutes({ * 400: * description: Invalid configuration; schedule errors include a fieldErrors object keyed by config field * 409: - * description: A new defaultSubfolder has a different layout (videos or TV) and the channels using the default have downloads, a download is running, or they download MP3. mainFolderLayout is never changed here (see PUT /api/library-folders). + * description: A new defaultSubfolder has a different layout (videos or TV) and the channels using the default have downloaded videos (reorganizeRequired, with the change to preview through /api/tv/reorganize/preview), a download or a reorganize is running, or they download MP3. mainFolderLayout is never changed here (see PUT /api/library-folders). * 200: * description: Configuration updated successfully * content: @@ -362,7 +362,9 @@ module.exports = function createConfigRoutes({ }); } catch (error) { if (!error.status) throw error; - return res.status(error.status).json({ error: error.message }); + const body = { error: error.message }; + if (error.reorganizeRequired) Object.assign(body, { reorganizeRequired: true, change: error.change }); + return res.status(error.status).json(body); } } diff --git a/server/routes/index.js b/server/routes/index.js index 931233847..b897fa6c7 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -20,6 +20,7 @@ const createSubfolderRoutes = require('./subfolders'); const createSchedulesRoutes = require('./schedules'); const createLogRoutes = require('./logs'); const createTvShowRoutes = require('./tvShows'); +const createTvReorganizeRoutes = require('./tvReorganize'); const videoMetadataModule = require('../modules/videoMetadataModule'); const videoOembedEnricher = require('../modules/videoOembedEnricher'); const playlistModule = require('../modules/playlistModule'); @@ -47,6 +48,9 @@ const logFilesModule = require('../modules/logFilesModule'); const libraryFolders = require('../modules/tvShows/libraryFolders'); const channelLayout = require('../modules/tvShows/channelLayout'); const layoutGuards = require('../modules/tvShows/layoutGuards'); +const reorganize = require('../modules/reorganize'); +const watchStatusHolds = require('../modules/mediaServers/watchStatusHolds'); +const watchStatusPushBack = require('../modules/mediaServers/watchStatusPushBack'); /** * Registers all route modules with the Express app @@ -104,7 +108,7 @@ function registerRoutes(app, deps) { // Video routes app.use(createVideoRoutes({ verifyToken, videosModule, downloadModule, videoOembedEnricher, videoLocalStatus, storageGuard, scheduledTaskManager, ratingMapper, - layoutGuards, + layoutGuards, reorganizeLock: reorganize.lock, })); // Video search routes @@ -155,7 +159,12 @@ function registerRoutes(app, deps) { app.use(createLogRoutes({ verifyToken, logFilesModule, configModule })); // TV show layouts (library folders and channel layouts) - app.use(createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channelSettingsModule, jobModule, models })); + app.use(createTvShowRoutes({ + verifyToken, libraryFolders, channelLayout, layoutGuards, reorganize, channelSettingsModule, jobModule, models, + })); + + // Reorganize (moving downloads between the Videos and TV layouts) and watch-state restores + app.use(createTvReorganizeRoutes({ verifyToken, reorganize, watchStatusHolds, watchStatusPushBack })); // Defensive redirect: /channels -> /subscriptions (frontend handles client-side routing, // this fallback covers direct server-side hits during the transition period) diff --git a/server/routes/tvReorganize.js b/server/routes/tvReorganize.js new file mode 100644 index 000000000..9ce8840ae --- /dev/null +++ b/server/routes/tvReorganize.js @@ -0,0 +1,312 @@ +const express = require('express'); +const logger = require('../logger'); + +const MAX_REVISION_LENGTH = 128; +const HOLD_STATES = new Set(['pending', 'failed', 'restored', 'dismissed']); +const DEFAULT_HOLD_STATES = ['pending', 'failed']; + +function positiveInt(value) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : null; +} + +/** + * Reorganize routes: preview and apply a move of downloaded files between the + * Videos and TV layouts, follow its progress, retry failed videos, and manage + * the watch-state restores it leaves for the media servers. + * + * @param {Object} deps + * @param {Function} deps.verifyToken + * @param {Object} deps.reorganize - modules/reorganize + * @param {Object} deps.watchStatusHolds - modules/mediaServers/watchStatusHolds + * @param {Object} deps.watchStatusPushBack - modules/mediaServers/watchStatusPushBack + * @returns {express.Router} + */ +function createTvReorganizeRoutes({ verifyToken, reorganize, watchStatusHolds, watchStatusPushBack }) { + const router = express.Router(); + + // Refusals carry .status (400/404/409); anything else is unexpected. + const sendError = (res, error, failure, context) => { + if (error.status) { + const body = { error: error.message }; + if (error.code) body.code = error.code; + return res.status(error.status).json(body); + } + logger.error({ err: error, ...context }, failure); + return res.status(500).json({ error: failure }); + }; + + const changeOf = (req) => { + const change = req.body && req.body.change; + return change && typeof change === 'object' && !Array.isArray(change) ? change : null; + }; + + /** + * @swagger + * components: + * schemas: + * ReorganizeChange: + * type: object + * description: | + * A settings change that moves downloaded files. One of: + * { type: channelLayout, channelId, layout (videos|tv), folder? } (the Channel Settings toggle), + * { type: channel, channelId, subFolder } (a channel's sub_folder value), + * { type: folderLayout, folder ("" = main folder), layout }, + * { type: defaultSubfolder, value ("" = main folder)}. + * required: [type] + * properties: + * type: { type: string, enum: [channelLayout, channel, folderLayout, defaultSubfolder] } + * channelId: { type: string } + * layout: { type: string, enum: [videos, tv] } + * folder: { type: string } + * subFolder: { type: string, nullable: true } + * value: { type: string } + */ + + /** + * @swagger + * /api/tv/reorganize/preview: + * post: + * summary: Preview a reorganize (dry run) + * description: Every downloaded video the change would move, with old and new paths and episode numbers (the first 200), what can't move (missing files, a destination held by another file), the shows it creates or moves, how many videos have watch state a media server would lose, and complete totals. Nothing is written. The revision token must be passed to apply. blocked says why apply would be refused right now (a download or a file task running). + * tags: [TV Shows] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [change] + * properties: + * change: { $ref: '#/components/schemas/ReorganizeChange' } + * responses: + * 200: { description: The preview, with revision, needed, totals, shows, items, problems, watchState and blocked } + * 400: { description: Invalid change, or a change that doesn't need a reorganize } + * 404: { description: Channel or subfolder not found } + * 409: { description: The change is refused (an MP3 download type for a TV folder) } + * 500: { description: Failed to preview the reorganize } + */ + router.post('/api/tv/reorganize/preview', verifyToken, async (req, res) => { + const change = changeOf(req); + if (!change) return res.status(400).json({ error: 'change is required' }); + try { + return res.json(await reorganize.preview(change)); + } catch (error) { + return sendError(res, error, 'Failed to preview the reorganize', { changeType: change.type }); + } + }); + + /** + * @swagger + * /api/tv/reorganize: + * post: + * summary: Apply a previewed reorganize + * description: Applies the settings change and moves the files in the background, holding download jobs until it ends. Progress is broadcast as tvReorganizeProgress WebSocket messages. When nothing has to move the change is applied directly (200, applied true). + * tags: [TV Shows] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [change, revision] + * properties: + * change: { $ref: '#/components/schemas/ReorganizeChange' } + * revision: { type: string, description: The preview's revision token } + * responses: + * 200: { description: Nothing had to move; the change was applied } + * 202: { description: Started; operationId identifies the operation } + * 400: { description: Invalid change or missing revision } + * 404: { description: Channel or subfolder not found } + * 409: { description: A download, a file task or another reorganize is running, or files or settings changed since the preview (code STALE_PREVIEW) } + * 500: { description: Failed to start the reorganize } + */ + router.post('/api/tv/reorganize', verifyToken, async (req, res) => { + const change = changeOf(req); + const { revision } = req.body || {}; + if (!change) return res.status(400).json({ error: 'change is required' }); + if (typeof revision !== 'string' || !revision || revision.length > MAX_REVISION_LENGTH) { + return res.status(400).json({ error: 'revision is required' }); + } + try { + const result = await reorganize.start(change, revision); + return res.status(result.operationId ? 202 : 200).json(result); + } catch (error) { + return sendError(res, error, 'Failed to start the reorganize', { changeType: change.type }); + } + }); + + /** + * @swagger + * /api/tv/operations/active: + * get: + * summary: Get the running reorganize + * tags: [TV Shows] + * responses: + * 200: { description: "{ operation } with the running operation, or null" } + * 500: { description: Failed to load the running reorganize } + */ + router.get('/api/tv/operations/active', verifyToken, async (req, res) => { + try { + return res.json({ operation: await reorganize.getActive() }); + } catch (error) { + return sendError(res, error, 'Failed to load the running reorganize'); + } + }); + + /** + * @swagger + * /api/tv/operations/{operationId}: + * get: + * summary: Get a reorganize + * description: Its status (running, completed, partial, failed), counts, and the videos that could not be moved with why. + * tags: [TV Shows] + * parameters: + * - in: path + * name: operationId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: The operation } + * 400: { description: Invalid id } + * 404: { description: Reorganize not found } + * 500: { description: Failed to load the reorganize } + */ + router.get('/api/tv/operations/:operationId', verifyToken, async (req, res) => { + const operationId = positiveInt(req.params.operationId); + if (!operationId) return res.status(400).json({ error: 'Invalid operation id' }); + try { + const operation = await reorganize.getOperation(operationId); + if (!operation) return res.status(404).json({ error: 'Reorganize not found' }); + return res.json(operation); + } catch (error) { + return sendError(res, error, 'Failed to load the reorganize', { operationId }); + } + }); + + /** + * @swagger + * /api/tv/operations/{operationId}/retry: + * post: + * summary: Retry a reorganize's failed videos + * tags: [TV Shows] + * parameters: + * - in: path + * name: operationId + * required: true + * schema: { type: integer } + * responses: + * 202: { description: Retry started } + * 400: { description: Invalid id, or nothing failed } + * 404: { description: Reorganize not found } + * 409: { description: It is still running, something that blocks a reorganize is running, or a newer reorganize changed the same channels } + * 500: { description: Failed to retry the reorganize } + */ + router.post('/api/tv/operations/:operationId/retry', verifyToken, async (req, res) => { + const operationId = positiveInt(req.params.operationId); + if (!operationId) return res.status(400).json({ error: 'Invalid operation id' }); + try { + return res.status(202).json(await reorganize.retry(operationId)); + } catch (error) { + return sendError(res, error, 'Failed to retry the reorganize', { operationId }); + } + }); + + /** + * @swagger + * /api/tv/holds: + * get: + * summary: List watch-state restores + * description: Watch state Youtarr keeps for videos a reorganize moved until the media servers show it again. Pending restores are still being pushed; failed ones were not restored within 14 days and keep protecting Youtarr's state until dismissed. + * tags: [TV Shows] + * parameters: + * - in: query + * name: state + * schema: { type: string, description: 'Comma-separated: pending, failed, restored, dismissed (default pending,failed)' } + * responses: + * 200: { description: "{ holds, counts }" } + * 400: { description: Unknown state } + * 500: { description: Failed to list watch-state restores } + */ + router.get('/api/tv/holds', verifyToken, async (req, res) => { + const states = typeof req.query.state === 'string' && req.query.state + ? req.query.state.split(',').map((state) => state.trim()) + : DEFAULT_HOLD_STATES; + if (states.some((state) => !HOLD_STATES.has(state))) { + return res.status(400).json({ error: 'Unknown restore state' }); + } + try { + const [holds, counts] = await Promise.all([ + watchStatusHolds.describeHolds({ states }), + watchStatusHolds.countHolds(), + ]); + return res.json({ holds, counts }); + } catch (error) { + return sendError(res, error, 'Failed to list watch-state restores'); + } + }); + + /** + * @swagger + * /api/tv/holds/{holdId}/retry: + * post: + * summary: Retry a watch-state restore + * description: Pushes the held state to the media server now and gives the restore another 14 days. + * tags: [TV Shows] + * parameters: + * - in: path + * name: holdId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: The push result } + * 400: { description: Invalid id } + * 404: { description: Restore not found } + * 500: { description: Failed to retry the restore } + */ + router.post('/api/tv/holds/:holdId/retry', verifyToken, async (req, res) => { + const holdId = positiveInt(req.params.holdId); + if (!holdId) return res.status(400).json({ error: 'Invalid restore id' }); + try { + const hold = await watchStatusHolds.reopenHold(holdId); + if (!hold) return res.status(404).json({ error: 'Restore not found' }); + return res.json(await watchStatusPushBack.pushPendingHolds({ holdIds: [holdId] })); + } catch (error) { + return sendError(res, error, 'Failed to retry the restore', { holdId }); + } + }); + + /** + * @swagger + * /api/tv/holds/{holdId}/dismiss: + * post: + * summary: Dismiss a watch-state restore + * description: Stops protecting Youtarr's stored watch state for this video and user; the next sync takes the server's state. + * tags: [TV Shows] + * parameters: + * - in: path + * name: holdId + * required: true + * schema: { type: integer } + * responses: + * 204: { description: Dismissed } + * 400: { description: Invalid id } + * 404: { description: Restore not found } + * 500: { description: Failed to dismiss the restore } + */ + router.post('/api/tv/holds/:holdId/dismiss', verifyToken, async (req, res) => { + const holdId = positiveInt(req.params.holdId); + if (!holdId) return res.status(400).json({ error: 'Invalid restore id' }); + try { + const dismissed = await watchStatusHolds.dismissHold(holdId); + if (!dismissed) return res.status(404).json({ error: 'Restore not found' }); + return res.status(204).end(); + } catch (error) { + return sendError(res, error, 'Failed to dismiss the restore', { holdId }); + } + }); + + return router; +} + +module.exports = createTvReorganizeRoutes; diff --git a/server/routes/tvShows.js b/server/routes/tvShows.js index 29147a0af..e2b997d39 100644 --- a/server/routes/tvShows.js +++ b/server/routes/tvShows.js @@ -9,19 +9,24 @@ const MAX_FOLDER_NAME_LENGTH = 100; * @param {Function} deps.verifyToken * @param {Object} deps.libraryFolders - tvShows/libraryFolders * @param {Object} deps.channelLayout - tvShows/channelLayout + * @param {Object} deps.layoutGuards - tvShows/layoutGuards (refusal bodies) + * @param {Object} deps.reorganize - modules/reorganize (a channel's reorganize state) * @param {Object} deps.channelSettingsModule * @param {Object} deps.jobModule - Its running job blocks layout switches * @param {Object} deps.models * @returns {express.Router} */ -function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channelSettingsModule, jobModule, models }) { +function createTvShowRoutes({ + verifyToken, libraryFolders, channelLayout, layoutGuards, reorganize, channelSettingsModule, jobModule, models, +}) { const router = express.Router(); const isDownloadRunning = () => Boolean(jobModule.getInProgressJobId()); - // Refusals carry .status; anything else is unexpected. + // Refusals carry .status (a change that moves files also names the change + // to preview); anything else is unexpected. const sendError = (res, error, failure, context) => { if (error.status) { - return res.status(error.status).json({ error: error.message }); + return res.status(error.status).json(layoutGuards.errorBody(error)); } logger.error({ err: error, ...context }, failure); return res.status(500).json({ error: failure }); @@ -69,7 +74,7 @@ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channe * /api/library-folders: * put: * summary: Change a library folder's layout - * description: Sets the layout of the main folder (name "") or a subfolder. Every channel that downloads to the folder changes layout with it, so the change is refused while the folder holds downloaded videos or a download runs, and TV is refused while a channel or playlist there downloads MP3. Switching the main folder to TV writes a .plexignore there that hides the __ subfolders from a Plex library pointed at it. + * description: Sets the layout of the main folder (name "") or a subfolder. Every channel that downloads to the folder changes layout with it, so a folder holding downloaded videos is answered with a reorganizeRequired 409 (the change goes through the reorganize, which moves the files), a direct change is refused while a download runs, and TV is refused while a channel or playlist there downloads MP3. Switching the main folder to TV writes a .plexignore there that hides the __ subfolders from a Plex library pointed at it. * tags: [TV Shows] * requestBody: * required: true @@ -85,7 +90,7 @@ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channe * 200: { description: The updated folder list and whether anything changed } * 400: { description: Invalid name or layout } * 404: { description: Unknown subfolder } - * 409: { description: The folder holds downloads, a download is running, or its channels download MP3 } + * 409: { description: "The folder holds downloaded videos (reorganizeRequired, with the change to preview through /api/tv/reorganize/preview), a download or a reorganize is running, or its channels download MP3" } * 500: { description: Failed to change the folder layout } */ router.put('/api/library-folders', verifyToken, async (req, res) => { @@ -109,7 +114,7 @@ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channe * /api/channels/{channelId}/tv: * get: * summary: Get a channel's TV layout - * description: Whether the channel downloads to a TV folder, its show (name and folder) when it has one, the TV folders it can use, and whether it can switch layouts (only while it has no downloaded videos). + * description: Whether the channel downloads to a TV folder, its show (name and folder) when it has one, the TV folders it can use, whether it has downloaded videos (switching then goes through the reorganize), and its reorganize state (running, and videos a reorganize could not move). * tags: [TV Shows] * parameters: * - in: path @@ -125,7 +130,11 @@ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channe try { const channel = await findChannel(req.params.channelId); if (!channel) return res.status(404).json({ error: 'Channel not found' }); - return res.json(await channelLayout.getChannelTvState(channel)); + const [tv, reorganizeState] = await Promise.all([ + channelLayout.getChannelTvState(channel), + reorganize.channelState(channel.channel_id), + ]); + return res.json({ ...tv, reorganize: reorganizeState }); } catch (error) { return sendError(res, error, 'Failed to load the channel\'s TV state', { channelId: req.params.channelId }); } @@ -136,7 +145,7 @@ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channe * /api/channels/{channelId}/tv/layout: * put: * summary: Switch a channel between Videos and TV - * description: Moves the channel to a folder with the requested layout. TV uses the given folder, else the default subfolder when it is a TV folder, else the only TV folder. Videos uses the given folder, else the folder the channel left for TV, else the default subfolder. Refused while the channel has downloaded videos or a download runs, and for TV while the channel downloads MP3. + * description: Moves the channel to a folder with the requested layout. TV uses the given folder, else the default subfolder when it is a TV folder, else the only TV folder. Videos uses the given folder, else the folder the channel left for TV, else the default subfolder. A channel with downloaded videos is answered with a reorganizeRequired 409 naming the change to preview; refused while a download or a reorganize of the channel runs, and for TV while the channel downloads MP3. * tags: [TV Shows] * parameters: * - in: path @@ -157,7 +166,7 @@ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channe * 200: { description: The saved channel settings and the channel's TV state } * 400: { description: Invalid layout or folder, no TV folder, or a folder must be chosen } * 404: { description: Channel not found } - * 409: { description: The channel has downloaded videos or a download is running } + * 409: { description: "The channel has downloaded videos (reorganizeRequired, with the change to preview), or a download or a reorganize of the channel is running" } * 500: { description: Failed to switch the channel's layout } */ router.put('/api/channels/:channelId/tv/layout', verifyToken, async (req, res) => { @@ -177,7 +186,8 @@ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, channe channelId, { sub_folder: subFolder }, { isDownloadRunning } ); const updated = await findChannel(channelId); - return res.json({ settings: result.settings, tv: await channelLayout.getChannelTvState(updated) }); + const tv = await channelLayout.getChannelTvState(updated); + return res.json({ settings: result.settings, tv: { ...tv, reorganize: await reorganize.channelState(channelId) } }); } catch (error) { return sendError(res, error, 'Failed to switch the channel\'s layout', { channelId, layout }); } diff --git a/server/routes/videos.js b/server/routes/videos.js index 0416a0b6f..c59ec0e46 100644 --- a/server/routes/videos.js +++ b/server/routes/videos.js @@ -53,11 +53,12 @@ const apiKeyDownloadLimiter = rateLimit({ * @param {Object} deps.videosModule - Videos module * @param {Object} deps.downloadModule - Download module * @param {Object} deps.ratingMapper - Rating validation/normalization module + * @param {Object} [deps.reorganizeLock] - The running reorganize, whose videos can't be deleted * @returns {express.Router} */ module.exports = function createVideoRoutes({ verifyToken, videosModule, downloadModule, videoOembedEnricher, videoLocalStatus, storageGuard, scheduledTaskManager, ratingMapper, - layoutGuards, + layoutGuards, reorganizeLock, }) { const router = express.Router(); /** @@ -401,6 +402,8 @@ module.exports = function createVideoRoutes({ * description: Videos deleted successfully * 400: * description: Invalid request + * 409: + * description: Some of the videos are being reorganized * 500: * description: Failed to delete videos */ @@ -419,6 +422,11 @@ module.exports = function createVideoRoutes({ }); } + // Moving files must not race a delete of the same videos. + if (reorganizeLock && reorganizeLock.coversAnyVideo({ ids: videoIds || [], youtubeIds: youtubeIds || [] })) { + return res.status(409).json({ error: 'Some of these videos are being reorganized. Try again when that finishes.' }); + } + const videoDeletionModule = require('../modules/videoDeletionModule'); let result; diff --git a/server/server.js b/server/server.js index d01ef9340..0eb6ed60a 100644 --- a/server/server.js +++ b/server/server.js @@ -263,6 +263,9 @@ const initialize = async () => { const scheduledTaskRuns = require('./modules/scheduledTaskRuns'); const scheduledTaskManager = require('./modules/scheduledTaskManager'); const storageGuard = require('./modules/storageGuard'); + const reorganize = require('./modules/reorganize'); + const mediaServerSync = require('./modules/mediaServers/mediaServerSync'); + const watchStatusSync = require('./modules/mediaServers/watchStatusSync'); // Runs left "running" by the previous process never finished; close them // out before any timer fires, then start recording this process's runs. @@ -346,6 +349,15 @@ const initialize = async () => { storageGuard.initialize().catch((err) => { logger.error({ err }, 'Initial download pause check failed'); }); + // A reorganize (moving downloads between the Videos and TV layouts) holds + // download jobs and refuses the tasks that touch files. One a restart + // interrupted resumes once startup work on downloads has finished. + reorganize.initialize({ jobModule, scheduledTaskManager, videosModule, mediaServerSync, watchStatusSync }); + if (process.env.NODE_ENV !== 'test') { + reorganize.recoverInterrupted().catch((err) => { + logger.error({ err }, 'Could not resume an interrupted reorganize'); + }); + } subscriptionImportModule.init({ channelModule, jobModule, From 839a057c1b30b4d169d32bd871e97a7a4f70733b Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Sun, 4 Oct 2026 11:24:28 -0700 Subject: [PATCH 05/12] feat(tv): check media server libraries per folder Setting up a TV folder meant guessing whether the media server library for it was right. Settings and Channel Settings now show, per library folder and per configured server, which libraries hold the folder and what in their setup works against Youtarr's files: no TV library yet, a library of the wrong type, the Plex Series or a legacy agent, a Jellyfin or Emby library that saves NFO files or looks items up online, another library showing the same files again, a library nested inside another on Jellyfin or Emby (which leaves it empty), a mount under another name, or a missing Plex refresh mapping. Server paths differ from the container's, so folders are matched by a location ending in __name and by sampling a few files from each library and pairing them with Youtarr's downloads by YouTube id. - Add GET /api/library-folders/check (optionally for given folders, checked as a given layout) and PUT /api/library-folders/plex-mapping. Channel Settings adds the Plex refresh mapping itself when exactly one Plex TV library holds the channel's folder; the folder list offers it as a button. The saved mapping reaches an open Settings page without discarding its unsaved edits, including pending edits to other mappings. - Show the check in the move review for the TV folders videos move into, evaluated as TV folders even while they are still saved as Videos folders, and only for the move being previewed. - List only the libraries that may hold Youtarr's files when Plex watch sync and playlist sync and Jellyfin/Emby push-back read a server, so an unrelated TV library is no longer listed episode by episode. A library whose sample says nothing (empty, unreadable, or YouTube downloads Youtarr can't place) stays in scope, as do the configured and mapped Plex sections. - Re-sync media server playlists 5 and 15 minutes after a reorganize: a move that keeps file names first resolved to the item at the old path. Remove Youtarr's tvshow.nfo from a channel folder that is movie-style again; a Jellyfin Mixed library kept showing the channel as a series. - Recheck a move review blocked by a running task every 5 seconds, after a failed recheck too, so Move enables without reopening the dialog. Word the default-subfolder confirmation for a layout change, which moves the channels' videos. - Document the TV library settings for Plex, Jellyfin and Emby with the servers' own labels, one library per folder on Jellyfin and Emby, and advise against Mixed libraries. Refs: #175 --- CLAUDE.md | 9 +- .../ChannelPage/ChannelSettingsDialog.tsx | 1 + .../components/ChannelTvMediaServers.tsx | 92 ++++ .../components/ChannelTvSection.tsx | 27 +- .../__tests__/ChannelTvMediaServers.test.tsx | 99 +++++ .../__tests__/ChannelTvSection.test.tsx | 18 +- .../sections/CoreSettingsSection.tsx | 10 +- .../__tests__/CoreSettingsSection.test.tsx | 36 +- .../components/LibraryFoldersList.tsx | 42 +- .../__tests__/LibraryFoldersList.test.tsx | 75 ++++ .../shared/LibraryCheck/LibraryCheckNotes.tsx | 112 +++++ .../__tests__/LibraryCheckNotes.test.tsx | 142 +++++++ .../shared/Reorganize/ReorganizeDialog.tsx | 8 +- .../Reorganize/ReorganizePreviewBody.tsx | 24 +- .../__tests__/ReorganizeDialog.test.tsx | 45 ++ .../__tests__/ReorganizePreviewBody.test.tsx | 32 ++ .../__tests__/useReorganizePreview.test.ts | 69 ++- .../Reorganize/hooks/useReorganizePreview.ts | 34 +- .../shared/Reorganize/reorganizeText.ts | 4 +- client/src/hooks/__tests__/useConfig.test.ts | 79 +++- .../hooks/__tests__/useLibraryCheck.test.ts | 138 ++++++ client/src/hooks/useConfig.ts | 43 +- client/src/hooks/useLibraryCheck.ts | 122 ++++++ client/src/types/libraryCheck.ts | 61 +++ client/src/types/reorganize.ts | 2 + .../src/utils/__tests__/configPatch.test.ts | 46 ++ .../utils/__tests__/libraryLayouts.test.ts | 5 + client/src/utils/configPatch.ts | 36 ++ client/src/utils/libraryLayouts.ts | 5 + docs/CONFIG.md | 5 +- docs/MEDIA_SERVERS.md | 25 +- docs/USAGE_GUIDE.md | 46 +- docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md | 33 ++ docs/YOUTARR_VS_ALTERNATIVES.md | 2 +- docs/media-servers/emby.md | 87 +++- docs/media-servers/jellyfin.md | 84 +++- docs/media-servers/kodi.md | 13 + docs/media-servers/plex.md | 65 ++- .../server.additional-routes.test.js | 1 + server/__tests__/server.apikeys.test.js | 1 + server/__tests__/server.auth-sessions.test.js | 1 + server/__tests__/server.core.test.js | 1 + server/__tests__/server.plex-routes.test.js | 1 + server/__tests__/server.routes.test.js | 1 + .../__tests__/libraryCheck.test.js | 307 ++++++++++++++ .../__tests__/libraryLocator.test.js | 106 +++++ .../__tests__/libraryMatcher.test.js | 159 +++++++ .../__tests__/mediaServerSync.test.js | 36 +- .../__tests__/watchStatusPushBack.test.js | 13 +- .../__tests__/watchStatusSync.test.js | 14 + .../adapters/__tests__/embyAdapter.test.js | 35 ++ .../__tests__/jellyfinAdapter.test.js | 61 +++ .../adapters/__tests__/libraryOptions.test.js | 66 +++ .../adapters/__tests__/plexAdapter.test.js | 71 ++++ .../mediaServers/adapters/baseAdapter.js | 38 +- .../mediaServers/adapters/embyAdapter.js | 86 +++- .../mediaServers/adapters/jellyfinAdapter.js | 89 +++- .../mediaServers/adapters/libraryOptions.js | 82 ++++ .../mediaServers/adapters/plexAdapter.js | 86 +++- server/modules/mediaServers/libraryCheck.js | 394 ++++++++++++++++++ server/modules/mediaServers/libraryLocator.js | 99 +++++ server/modules/mediaServers/libraryMatcher.js | 226 ++++++++++ .../modules/mediaServers/mediaServerSync.js | 12 +- .../mediaServers/watchStatusPushBack.js | 4 +- .../modules/mediaServers/watchStatusSync.js | 10 +- .../reorganize/__tests__/followUp.test.js | 57 +++ .../reorganize/__tests__/planner.test.js | 13 + server/modules/reorganize/followUp.js | 50 ++- server/modules/reorganize/planner.js | 2 + server/routes/__tests__/tvShows.test.js | 82 ++++ server/routes/index.js | 2 + server/routes/tvShows.js | 140 +++++++ 72 files changed, 3958 insertions(+), 164 deletions(-) create mode 100644 client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx create mode 100644 client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx create mode 100644 client/src/components/shared/LibraryCheck/LibraryCheckNotes.tsx create mode 100644 client/src/components/shared/LibraryCheck/__tests__/LibraryCheckNotes.test.tsx create mode 100644 client/src/hooks/__tests__/useLibraryCheck.test.ts create mode 100644 client/src/hooks/useLibraryCheck.ts create mode 100644 client/src/types/libraryCheck.ts create mode 100644 client/src/utils/__tests__/configPatch.test.ts create mode 100644 client/src/utils/configPatch.ts create mode 100644 server/modules/mediaServers/__tests__/libraryCheck.test.js create mode 100644 server/modules/mediaServers/__tests__/libraryLocator.test.js create mode 100644 server/modules/mediaServers/__tests__/libraryMatcher.test.js create mode 100644 server/modules/mediaServers/adapters/__tests__/libraryOptions.test.js create mode 100644 server/modules/mediaServers/adapters/libraryOptions.js create mode 100644 server/modules/mediaServers/libraryCheck.js create mode 100644 server/modules/mediaServers/libraryLocator.js create mode 100644 server/modules/mediaServers/libraryMatcher.js diff --git a/CLAUDE.md b/CLAUDE.md index 659dad20a..ede9189b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,25 +47,26 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`<library folder>/<show>/Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. -- `modules/tvShows/` (TV show support; channel shows and the reorganize ship in this branch, title shows come later): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. -- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art, emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos, Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings; items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. +- `modules/tvShows/` (TV show support; channel shows and the reorganize ship in this branch, title shows come later): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. +- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings; items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. - `modules/mediaServers/watchStatusHolds.js` + `watchStatusPushBack.js`: watch state a reorganize protects. Before files move, each watched or in-progress `video_watch_status` row (Plex: the owner only; other accounts come from play history, which a move never resets) is snapshotted into `watch_status_holds`; `watchStatusSync` passes every server's matches through `applyHolds`, which drops downgrades of held rows (the moved item looks new and unwatched, and until a rescan the server lists the old path, which the current-copy rule rejects) and ends a hold when the item at the video's current path (matching it with more trailing segments than the path the video came from, kept in the snapshot as `fromPath`: between TV folders the stale item shares the show, season and file names) shows at least the held state or a newer one. A push stamps the server item with the push time: a last-watched time within 10 minutes of the hold's last actual write (`last_pushed_at`; an attempt that found the state already there writes nothing and leaves no echo) is that echo, not a watch, so the row keeps its historical play count and last-watched time (restored holds are consulted for this too), while a later real watch is written as is. Re-holding a row for a later reorganize resets its push record (the earlier push went to the earlier location). Push-back first reads the user's state of the item (`getWatchState`: Plex `/library/metadata/{key}` as the owner, Jellyfin and Emby `/Users/{id}/Items/{id}`; `watchStateCompare` holds the pure `isAtLeast`/`isLaterWatch` the sync uses too) and leaves a state at least as watched, or watched since the snapshot, alone; otherwise it writes the state through the adapters' `setWatchState` (Plex `/:/scrobble` and `/:/progress` as the owner; Jellyfin `/UserPlayedItems` and `/UserItems/{id}/UserData`, falling back to the legacy `/Users/{id}/...` routes; Emby `/Users/{id}/PlayedItems` and `/Users/{id}/Items/{id}/UserData`), finding items with `resolveItemMatchesByPaths` (an item is skipped only when the same item answers for both the new and the old path and fits the old one at least as well, i.e. the stale item before the server rescans; two different items mean the new file is indexed; an explicit retry takes whatever matches); a hold is pushed once, and again only when a sync finds the item at the new path below the held state (`applyHolds` records that as the hold's error), since a blind re-push could undo a watch made in between. It runs 1, 5 and 15 minutes after a reorganize and at the end of every sync, an hour apart per hold. Holds not restored within 14 days become failed (still protecting the row) until retried or dismissed (`GET /api/tv/holds`, `POST /api/tv/holds/:id/retry|dismiss`). - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. - `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10, and episodes by listing show sections with type=4, only when a file in the batch is named SxxEyy), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). +- `modules/mediaServers/libraryMatcher.js` + `libraryLocator.js` + `libraryCheck.js`: which server libraries hold Youtarr's library folders. Adapters list libraries (`listLibraries()`: type `videos`/`tv`/`mixed`/`music`/`other`, server-side locations, Plex agent/scanner, Jellyfin/Emby `nfoSaver`/`onlineFetchers` from `adapters/libraryOptions.js`, a null `MetadataSavers` resolved through the server's `/System/Configuration` defaults) and sample a few file paths per library (`sampleItemPaths`). `libraryMatcher` (pure) finds a folder by a location ending in `__<name>` (its parent is the main folder) and by sampled downloads paired with Youtarr's paths by `[id]` (the shared tail below the downloads folder gives each side's folder; a stale copy in another subfolder is ignored), assumes each subfolder beneath a known main folder, and relates each location to each folder as `exact`/`covers`/`inside`, flagging an exact match whose last segment isn't `__<name>` (`folderSegmentMissing`, a mount at the folder itself). `libraryLocator.scopeFor(adapter)` gives the library ids that may hold Youtarr's files (null when the main folder can't be found): the matcher's related libraries plus every sampled library whose sample was inconclusive (no files, unreadable, or `[id]`-named files Youtarr can't place), so only a library whose sample shows plainly unrelated files is left out. Plex `fetchWatchStates` and `resolveItemMatchesByPaths`/`resolveItemIdsByFilepaths` (watch sync, playlist sync) and Jellyfin/Emby `resolveItemMatchesByPaths` (push-back, per library via `parentId`) take it as `{ libraryIds }`; the configured Plex YouTube library and the sections in `plexSubfolderLibraryMappings` are always in scope. `libraryCheck.check({ folders, layout })` (the `layout` checks the folders given as that layout; the reorganize preview passes `tv` for `tvFolders`, which may still be saved as Videos folders) reports per folder and server `ok`/`warning`/`missing`/`unreachable` with issues: `noLibrary`, `wrongType`, `ambiguous`, `duplicateLibrary`, `plexSeriesAgent`, `plexLegacyAgent`, `nfoSaver`, `onlineFetchers`, `folderNameMissing`, `overlap`/`overlapTv` (Plex shows a nested folder twice; a TV main folder's Plex library is exempt thanks to `.plexignore`), `nestedLibrary` (Jellyfin and Emby show a folder in one library only: a library inside another library's folder is skipped, so a TV folder under a root library counts as missing), `insideFolder`, and for Plex TV subfolders `plexMapping` (`mappedLibraryId`, `suggestedLibraryId`: the one exact TV library) with `plexMappingMissing`/`plexMappingMismatch`. `applyPlexMapping(folder, libraryId)` adds the suggested mapping, never replacing one, and answers with the saved `plexSubfolderLibraryMappings`, which the client's `useLibraryCheck` hands to `useConfig` through `CONFIG_PATCHED_EVENT`: the saved copy takes the list as is and the draft takes only the server's change since that copy (`utils/configPatch.mergeServerChange`, keyed by subfolder), so an open Settings page keeps its unsaved edits, including pending edits to other mappings, and its next save carries the new mapping. A Videos folder counts as held by a music library pointed at it (MP3 channels; one at a parent folder says nothing about the videos); TV folders need a video library. - `modules/subscriptionImport/`: bulk channel import (`importJobRunner`, `takeoutParser`, `cookiesFetcher`, `thumbnailEnricher`, `concurrencyLimiter`, `errorClassifier`). ### Frontend (client/src/) - `App.tsx`: app routing plus a global `fetch()` override that detects 503 `requiresDbFix` responses and surfaces the database error overlay. You can use normal `fetch()` anywhere; database errors are handled automatically. - `components/`: feature directories and pages. Complex features pair a top-level `FeatureName.tsx` with a same-named `FeatureName/` directory holding `components/`, `hooks/`, and `__tests__/`. Examples of this sibling-file layout: `Subscriptions.tsx` + `Subscriptions/` (renamed from `ChannelManager`), `Configuration.tsx` + `Configuration/`, `ChannelPage.tsx` + `ChannelPage/`, `PlaylistPage.tsx` + `PlaylistPage/`. Newer features (e.g. `SubscriptionImport/`) put the main component at `FeatureName/index.tsx` instead; either layout is acceptable for new features. -- `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards, `Reorganize/` for the reorganize review and progress dialog: `ReorganizeDialog` previews a change and follows the operation it starts, opened by Channel Settings (layout toggle and folder saves), the library folder list and Settings saves of the default subfolder whenever the server answers `reorganizeRequired`; `reorganizeErrors` turns those refusals into a `ReorganizeRequiredError` carrying the change; the opener saves the folder optimistically when the move starts (`onApplied`), then follows the operation with `hooks/useReorganizeOutcome` (once per run end, told apart by `finishedAt`; `onRetried` bumps its attempt) and reads the saved folder back at each end, since the server undoes the change when nothing moved and applies it again when a retry moves some). +- `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards, `LibraryCheck/LibraryCheckNotes` for one folder's library check per media server (setup hints, the Plex refresh mapping button; used by Channel Settings' `ChannelPage/components/ChannelTvMediaServers` (runs the check for the channel's TV folder and adds the Plex mapping automatically), Settings' `LibraryFoldersList` and the reorganize preview), `Reorganize/` for the reorganize review and progress dialog: `ReorganizeDialog` previews a change (computed again every 5 s while a task that ends on its own blocks it, `useReorganizePreview`; with the library check for its `tvFolders`) and follows the operation it starts, opened by Channel Settings (layout toggle and folder saves), the library folder list and Settings saves of the default subfolder whenever the server answers `reorganizeRequired`; `reorganizeErrors` turns those refusals into a `ReorganizeRequiredError` carrying the change; the opener saves the folder optimistically when the move starts (`onApplied`), then follows the operation with `hooks/useReorganizeOutcome` (once per run end, told apart by `finishedAt`; `onRetried` bumps its attempt) and reads the saved folder back at each end, since the server undoes the change when nothing moved and applies it again when a retry moves some). - `components/ui/`: theme-neutral UI primitives (Button, Card, Dialog, Select, etc.) built on Radix and styled via CSS variables + Tailwind. Use these instead of Material-UI imports in new code. `AccordionContent` stays mounted when collapsed (tests can query it) but is `invisible`, so its controls leave the tab order and accessibility tree. - `components/layout/`: app shell and navigation chrome. `AppShell.tsx` is the outer frame; `NavSidebar.tsx` / `NavHeader.tsx` own desktop and mobile nav; `navLayoutConstants.ts` holds shared sidebar/header sizing constants; `layoutFallback.css` provides fallback CSS variables for themes that skip layout overrides; `ReorganizeBanner.tsx` shows a running reorganize on every page (downloads wait for it). - `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle, the watch-state restores a reorganize left (`sections/components/WatchStateRestores.tsx` via `hooks/useWatchStateHolds.ts`: pending count, failed restores with Retry and Dismiss) and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. - `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`). - `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow. - `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface. -- `hooks/`: app-wide custom hooks for data fetching and state. +- `hooks/`: app-wide custom hooks for data fetching and state (e.g. `useLibraryCheck` for `GET /api/library-folders/check` and the Plex mapping, refetched on `LIBRARY_FOLDERS_UPDATED_EVENT`). - `contexts/` and `providers/`: React Context for cross-cutting concerns (auth token, WebSocket, theme). `contexts/ThemeEngineContext.tsx` owns the active theme mode, resolves the layout policy for the current viewport, and injects theme CSS variables onto the document root. - `config/configSchema.ts`: the `CONFIG_FIELDS` registry. Use this pattern when adding new configuration fields; it auto-derives types, defaults, and change tracking. - `types/`, `utils/`: shared types and helpers. diff --git a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx index 3ece6787e..6ad34c7d5 100644 --- a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx +++ b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx @@ -873,6 +873,7 @@ function ChannelSettingsDialog({ case 'tv': return ( <ChannelTvSection + token={token} channelName={channelName} tv={tv} loading={tvLoading} diff --git a/client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx b/client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx new file mode 100644 index 000000000..178411ca2 --- /dev/null +++ b/client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Alert, Box, Button, CircularProgress, Typography } from '../../ui'; +import { useLibraryCheck } from '../../../hooks/useLibraryCheck'; +import { folderKey } from '../../../utils/libraryLayouts'; +import { LibraryCheckNotes } from '../../shared/LibraryCheck/LibraryCheckNotes'; + +const PLEX_SETUP_NOTE = + 'Plex: add a TV Shows library for this folder (scanner Plex TV Series, agent Plex NFO Series or Plex Personal Media). ' + + 'Youtarr then maps the folder to it so new episodes refresh that library.'; +const JELLYFIN_EMBY_SETUP_NOTE = + 'Jellyfin and Emby: add a Shows (Emby: TV Shows) library for this folder with NFO saving off and all metadata downloaders and image fetchers off; on Emby, keep the NFO metadata reader on.'; + +/** How to set up media server libraries for a TV folder, for setups the check can't read. */ +export function MediaServerSetupNotes() { + return ( + <Box className="flex flex-col gap-1 rounded-[var(--radius-ui)] bg-muted p-3"> + <Typography variant="body2" className="font-semibold"> + Media server setup + </Typography> + <Typography variant="caption" color="text.secondary"> + {PLEX_SETUP_NOTE} + </Typography> + <Typography variant="caption" color="text.secondary"> + {JELLYFIN_EMBY_SETUP_NOTE} + </Typography> + </Box> + ); +} + +export interface ChannelTvMediaServersProps { + token: string | null; + /** The TV library folder the channel's show lives in ('' = main folder) */ + folder: string; +} + +/** + * The library check for a TV channel's folder. When exactly one Plex TV + * library holds a TV subfolder that has no refresh mapping yet, the mapping + * is added right away. + */ +function ChannelTvMediaServers({ token, folder }: ChannelTvMediaServersProps) { + const { data, loading, error, refetch, applyPlexMapping } = useLibraryCheck(token, { folders: [folder] }); + const [mappedName, setMappedName] = useState<string | null>(null); + const attempted = useRef<string | null>(null); + const report = data?.folders.find((entry) => folderKey(entry.name) === folderKey(folder)) ?? null; + const plex = report?.servers.find((server) => server.serverType === 'plex'); + const suggestion = plex?.plexMapping?.suggestedLibraryId ?? null; + const needsMapping = Boolean(report?.name && suggestion && !plex?.plexMapping?.mappedLibraryId); + + useEffect(() => { + if (!needsMapping || !report || !suggestion) return; + const key = `${folderKey(report.name)}:${suggestion}`; + if (attempted.current === key) return; + attempted.current = key; + const libraryName = plex?.libraries.find((library) => library.id === suggestion)?.name ?? null; + // A failure leaves the issue with its own button. + applyPlexMapping(report.name, suggestion).then(() => setMappedName(libraryName)).catch(() => undefined); + }, [needsMapping, report, suggestion, plex, applyPlexMapping]); + + if (!data) { + if (error) return <Alert severity="warning">{error}</Alert>; + return loading ? ( + <Box className="flex items-center gap-2 text-sm text-muted-foreground"> + <CircularProgress size={16} /> + <span>Checking media server libraries...</span> + </Box> + ) : null; + } + if (data.servers.length === 0 || !report) return <MediaServerSetupNotes />; + + return ( + <Box className="flex flex-col gap-2 rounded-[var(--radius-ui)] bg-muted p-3"> + <Box className="flex flex-wrap items-center justify-between gap-2"> + <Typography variant="body2" className="font-semibold"> + Media servers + </Typography> + <Button size="small" variant="text" loading={loading} onClick={() => { void refetch(); }}> + Check again + </Button> + </Box> + <LibraryCheckNotes folder={report} servers={data.servers} showSetupHints onApplyPlexMapping={applyPlexMapping} /> + {mappedName && ( + <Typography variant="caption" color="text.secondary"> + New episodes now refresh the Plex library {mappedName}. + </Typography> + )} + {error && <Typography variant="caption" color="error">{error}</Typography>} + </Box> + ); +} + +export default ChannelTvMediaServers; diff --git a/client/src/components/ChannelPage/components/ChannelTvSection.tsx b/client/src/components/ChannelPage/components/ChannelTvSection.tsx index 69f2061c1..a2c913b1a 100644 --- a/client/src/components/ChannelPage/components/ChannelTvSection.tsx +++ b/client/src/components/ChannelPage/components/ChannelTvSection.tsx @@ -13,6 +13,7 @@ import { import { ChannelTvState, LibraryFolder, LibraryLayout } from '../../../types/tvShows'; import { libraryFolderLabel } from '../../../utils/libraryLayouts'; import TvFolderSetup from './TvFolderSetup'; +import ChannelTvMediaServers, { MediaServerSetupNotes } from './ChannelTvMediaServers'; /** The server's refusal when it can't tell which Videos folder to switch back to. */ export const CHOOSE_VIDEOS_FOLDER_MESSAGE = 'Choose a Videos folder.'; @@ -23,15 +24,11 @@ const REORGANIZING_NOTE = "This channel's downloaded videos are being moved."; const DEFAULT_FOLDER_TV_NOTE = "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; const NO_VIDEOS_FOLDER_MESSAGE = 'No library folder uses the Videos layout yet.'; -const PLEX_SETUP_NOTE = - 'Plex: add a TV Shows library for this folder (scanner Plex TV Series, agent Plex Personal Media or Plex NFO Series). ' - + 'Then map the folder under Settings > Plex > subfolder library mappings so new episodes refresh it.'; -const JELLYFIN_EMBY_SETUP_NOTE = - 'Jellyfin and Emby: add a Shows library for this folder with the NFO reader on, NFO saving off and online metadata off.'; type Step = 'idle' | 'pickTv' | 'pickVideos' | 'setupTv'; export interface ChannelTvSectionProps { + token: string | null; channelName: string; tv: ChannelTvState | null; loading: boolean; @@ -90,24 +87,9 @@ function FolderPicker({ prompt, label, folders, value, onChange, actionLabel, on ); } -function MediaServerSetupNotes() { - return ( - <Box className="flex flex-col gap-1 rounded-[var(--radius-ui)] bg-muted p-3"> - <Typography variant="body2" className="font-semibold"> - Media server setup - </Typography> - <Typography variant="caption" color="text.secondary"> - {PLEX_SETUP_NOTE} - </Typography> - <Typography variant="caption" color="text.secondary"> - {JELLYFIN_EMBY_SETUP_NOTE} - </Typography> - </Box> - ); -} - /** Channel Settings section that switches a channel between Videos and TV show. */ function ChannelTvSection({ + token, channelName, tv, loading, @@ -294,7 +276,8 @@ function ChannelTvSection({ {tv.defaultFolderLayout === 'tv' && <Alert severity="info">{DEFAULT_FOLDER_TV_NOTE}</Alert>} - {(isTv || step === 'setupTv') && <MediaServerSetupNotes />} + {isTv && <ChannelTvMediaServers token={token} folder={tv.show?.libraryFolder ?? tv.libraryFolder} />} + {!isTv && step === 'setupTv' && <MediaServerSetupNotes />} </Box> ); } diff --git a/client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx b/client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx new file mode 100644 index 000000000..6998149dd --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import ChannelTvMediaServers from '../ChannelTvMediaServers'; +import type { UseLibraryCheckResult } from '../../../../hooks/useLibraryCheck'; +import type { LibraryCheckResponse, PlexMappingState } from '../../../../types/libraryCheck'; + +let mockCheck: UseLibraryCheckResult; + +jest.mock('../../../../hooks/useLibraryCheck', () => ({ + useLibraryCheck: () => mockCheck, +})); + +const checkWith = (plexMapping: PlexMappingState): LibraryCheckResponse => ({ + servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null }], + folders: [{ + name: 'TV Shows', + layout: 'tv', + hasFiles: true, + channels: 1, + servers: [{ + serverType: 'plex', + status: 'ok', + libraries: [{ id: '41', name: 'YouTube TV', type: 'tv', location: 'Q:\\Y\\__TV Shows', relation: 'exact' }], + issues: [], + plexMapping, + }], + }], +}); + +function setCheck(overrides: Partial<UseLibraryCheckResult>) { + mockCheck = { + data: null, + loading: false, + error: null, + refetch: jest.fn().mockResolvedValue(undefined), + applyPlexMapping: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('ChannelTvMediaServers', () => { + test('shows progress while the check runs', () => { + setCheck({ loading: true }); + render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + expect(screen.getByText('Checking media server libraries...')).toBeInTheDocument(); + }); + + test('falls back to the setup notes without configured media servers', () => { + setCheck({ data: { servers: [], folders: [] } }); + render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + expect(screen.getByText('Media server setup')).toBeInTheDocument(); + }); + + test("shows the libraries that hold the channel's folder", () => { + setCheck({ data: checkWith({ mappedLibraryId: '41', suggestedLibraryId: '41' }) }); + render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + expect(screen.getByText(/YouTube TV/)).toBeInTheDocument(); + }); + + test('maps the folder to the one Plex TV library that holds it', async () => { + setCheck({ data: checkWith({ mappedLibraryId: null, suggestedLibraryId: '41' }) }); + render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + await waitFor(() => expect(mockCheck.applyPlexMapping).toHaveBeenCalledWith('TV Shows', '41')); + expect(await screen.findByText('New episodes now refresh the Plex library YouTube TV.')).toBeInTheDocument(); + }); + + test('leaves an existing mapping alone', () => { + setCheck({ data: checkWith({ mappedLibraryId: '37', suggestedLibraryId: '41' }) }); + render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + expect(mockCheck.applyPlexMapping).not.toHaveBeenCalled(); + }); + + test('maps only once for the same folder and library', async () => { + setCheck({ data: checkWith({ mappedLibraryId: null, suggestedLibraryId: '41' }) }); + const { rerender } = render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + await waitFor(() => expect(mockCheck.applyPlexMapping).toHaveBeenCalledTimes(1)); + + rerender(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + expect(mockCheck.applyPlexMapping).toHaveBeenCalledTimes(1); + }); + + test('checks again on request', async () => { + const user = userEvent.setup(); + setCheck({ data: checkWith({ mappedLibraryId: '41', suggestedLibraryId: '41' }) }); + render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + await user.click(screen.getByRole('button', { name: 'Check again' })); + + expect(mockCheck.refetch).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx index 9f2f3ae2d..ce49a0639 100644 --- a/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx +++ b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx @@ -4,6 +4,18 @@ import '@testing-library/jest-dom'; import ChannelTvSection, { ChannelTvSectionProps } from '../ChannelTvSection'; import { ChannelTvState, LibraryFolder } from '../../../../types/tvShows'; +jest.mock('../ChannelTvMediaServers', () => { + const actual = jest.requireActual('../ChannelTvMediaServers'); + return { + __esModule: true, + ...actual, + default: function MockChannelTvMediaServers({ folder }: { folder: string }) { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'tv-media-servers' }, `check:${folder}`); + }, + }; +}); + const videosTv: ChannelTvState = { layout: 'videos', libraryFolder: 'Gaming', @@ -33,6 +45,7 @@ const libraryFolders = [folder('', 'videos'), folder('Gaming', 'videos'), folder function renderSection(overrides: Partial<ChannelTvSectionProps> = {}) { const props: ChannelTvSectionProps = { + token: 'token', channelName: 'Tech Channel', tv: videosTv, loading: false, @@ -213,16 +226,17 @@ describe('ChannelTvSection', () => { ).toBeInTheDocument(); }); - test('shows the media server notes for a TV channel', () => { + test("checks the media server libraries of a TV channel's show folder", () => { renderSection({ tv: tvTv }); - expect(screen.getByText(/Plex: add a TV Shows library for this folder/)).toBeInTheDocument(); + expect(screen.getByTestId('tv-media-servers')).toHaveTextContent('check:Anime'); }); test('hides the media server notes for a Videos channel', () => { renderSection(); expect(screen.queryByText('Media server setup')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tv-media-servers')).not.toBeInTheDocument(); }); test('shows a spinner while the TV state loads', () => { diff --git a/client/src/components/Configuration/sections/CoreSettingsSection.tsx b/client/src/components/Configuration/sections/CoreSettingsSection.tsx index 4c2056c0b..60d0435f9 100644 --- a/client/src/components/Configuration/sections/CoreSettingsSection.tsx +++ b/client/src/components/Configuration/sections/CoreSettingsSection.tsx @@ -40,6 +40,10 @@ import { ConfigState, DeploymentEnvironment, PlatformManagedState } from '../typ import { getChannelFilesOptions } from '../helpers'; import { ScheduleSummary } from './components/ScheduleSummary'; +const DEFAULT_LAYOUT_CHANGE_NOTE = + 'One of these folders saves videos and the other saves TV shows, so the downloaded videos of the channels that use ' + + 'the default subfolder move. When you save, you review the move first.'; + interface CoreSettingsSectionProps { config: ConfigState; deploymentEnvironment: DeploymentEnvironment; @@ -115,6 +119,10 @@ export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ } }; + // Between a Videos and a TV folder, saving moves the downloaded videos of the channels on the default. + const defaultLayoutChanges = pendingDefaultSubfolder !== null + && layoutOf(config.defaultSubfolder || '') !== layoutOf(pendingDefaultSubfolder || ''); + const handleConfirmDefaultSubfolder = () => { onConfigChange({ defaultSubfolder: pendingDefaultSubfolder || '' }); setShowConfirmDialog(false); @@ -764,7 +772,7 @@ export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ </strong> </DialogContentText> <DialogContentText className="mt-2" style={{ fontStyle: 'italic' }}> - Existing videos will not be moved. + {defaultLayoutChanges ? DEFAULT_LAYOUT_CHANGE_NOTE : 'Existing videos will not be moved.'} </DialogContentText> </DialogContent> <DialogActions> diff --git a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx index bde992fb1..ec3da4517 100644 --- a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx @@ -21,17 +21,25 @@ jest.mock('../../../../hooks/useSubfolders', () => ({ })); // Mock useLibraryFolders hook to prevent network requests +const mockLayouts: Record<string, string> = {}; jest.mock('../../../../hooks/useLibraryFolders', () => ({ useLibraryFolders: () => ({ folders: [{ name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 0 }], loading: false, error: null, - layoutOf: () => 'videos', + layoutOf: (name: string) => mockLayouts[name] ?? 'videos', refetch: () => Promise.resolve(), setFolderLayout: () => Promise.resolve(), }), })); +// Mock useLibraryCheck hook to prevent network requests +jest.mock('../../../../hooks/useLibraryCheck', () => ({ + useLibraryCheck: () => ({ + data: null, loading: false, error: null, refetch: () => Promise.resolve(), applyPlexMapping: () => Promise.resolve(), + }), +})); + // Mock SubtitleLanguageSelector to simplify testing jest.mock('../../SubtitleLanguageSelector', () => ({ __esModule: true, @@ -1166,6 +1174,32 @@ describe('CoreSettingsSection Component', () => { } as unknown as Response); }); + test('says existing videos stay put between folders with the same layout', async () => { + const user = userEvent.setup(); + mockFetch.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) }); + renderWithProviders(<CoreSettingsSection {...createSectionProps({ config: createConfig({ defaultSubfolder: '' }) })} />); + + await openSubfolderDialog(user); + + expect(await screen.findByText('Existing videos will not be moved.')).toBeInTheDocument(); + }); + + test('says videos move when the new default has the other layout', async () => { + const user = userEvent.setup(); + mockLayouts.NewFolder = 'tv'; + mockFetch.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) }); + try { + renderWithProviders(<CoreSettingsSection {...createSectionProps({ config: createConfig({ defaultSubfolder: '' }) })} />); + + await openSubfolderDialog(user); + + expect(await screen.findByText(/the downloaded videos of the channels that use the default subfolder move/)).toBeInTheDocument(); + expect(screen.queryByText('Existing videos will not be moved.')).not.toBeInTheDocument(); + } finally { + delete mockLayouts.NewFolder; + } + }); + test('shows "No tracked channels" message when count is 0', async () => { const user = userEvent.setup(); mockFetch.mockResolvedValue({ diff --git a/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx b/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx index 1f71c5b43..438249243 100644 --- a/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx +++ b/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx @@ -14,6 +14,10 @@ import { libraryFolderLabel } from '../../../../utils/libraryLayouts'; import { LibraryFolder, LibraryLayout } from '../../../../types/tvShows'; import { MainFolderTvDialog } from './MainFolderTvDialog'; import { ReorganizeDialog, useReorganizeRequest, isReorganizeRequired } from '../../../shared/Reorganize'; +import { LibraryCheckNotes } from '../../../shared/LibraryCheck/LibraryCheckNotes'; +import { useLibraryCheck } from '../../../../hooks/useLibraryCheck'; +import { folderKey } from '../../../../utils/libraryLayouts'; +import { LibraryCheckFolder, LibraryCheckServer } from '../../../../types/libraryCheck'; interface LibraryFoldersListProps { /** The section's useLibraryFolders result, shared so the page loads the folders once */ @@ -26,6 +30,7 @@ const CHANGE_FAILED = 'Failed to change the folder layout'; const EXPLANATION = "Each folder's layout must match its media server library: Videos for a Plex Other Videos or Jellyfin/Emby Movies library, TV shows for a TV library."; const HAS_FILES_CAPTION = 'Holds downloaded videos: changing its layout moves them, and you review the move first.'; +const MEDIA_SERVER_CHECK_CAPTION = 'Under each folder: the media server libraries that hold it, and what to fix.'; const DEFAULT_TV_NOTE = "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; @@ -42,9 +47,12 @@ interface LibraryFolderRowProps { folder: LibraryFolder; disabled: boolean; onLayoutChange: (folder: LibraryFolder, layout: LibraryLayout) => void; + /** The library check's report for this folder, once loaded */ + check?: { report: LibraryCheckFolder; servers: LibraryCheckServer[] } | null; + onApplyPlexMapping?: (folder: string, libraryId: string) => Promise<void>; } -const LibraryFolderRow: React.FC<LibraryFolderRowProps> = ({ folder, disabled, onLayoutChange }) => { +const LibraryFolderRow: React.FC<LibraryFolderRowProps> = ({ folder, disabled, onLayoutChange, check, onApplyPlexMapping }) => { const label = libraryFolderLabel(folder.name); return ( @@ -64,6 +72,16 @@ const LibraryFolderRow: React.FC<LibraryFolderRowProps> = ({ folder, disabled, o {HAS_FILES_CAPTION} </Typography> )} + {check && ( + <Box className="mt-1"> + <LibraryCheckNotes + folder={check.report} + servers={check.servers} + onApplyPlexMapping={onApplyPlexMapping} + problemsOnly={folder.layout !== 'tv'} + /> + </Box> + )} </Box> <Select size="small" @@ -91,6 +109,14 @@ export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library, const [confirmMainTv, setConfirmMainTv] = useState(false); // A layout change that moves downloaded files is reviewed in the reorganize dialog. const reorganize = useReorganizeRequest(); + const libraryCheck = useLibraryCheck(token); + const checkServers = libraryCheck.data?.servers ?? []; + // Folders in use: TV folders, and Videos folders that hold files or channels. + const checkFor = (folder: LibraryFolder) => { + if (checkServers.length === 0 || (folder.layout !== 'tv' && !folder.hasFiles && folder.channels === 0)) return null; + const report = libraryCheck.data?.folders.find((entry) => folderKey(entry.name) === folderKey(folder.name)); + return report ? { report, servers: checkServers } : null; + }; const applyLayout = async (name: string, layout: LibraryLayout) => { setChanging(true); @@ -161,6 +187,18 @@ export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library, </Alert> )} + {checkServers.length > 0 && ( + <Box className="flex flex-wrap items-center gap-2"> + <Typography variant="caption" color="text.secondary"> + {MEDIA_SERVER_CHECK_CAPTION} + </Typography> + <Button size="small" variant="text" loading={libraryCheck.loading} onClick={() => void libraryCheck.refetch()}> + Check again + </Button> + </Box> + )} + {libraryCheck.error && <Alert severity="warning">{libraryCheck.error}</Alert>} + {loading && folders.length === 0 ? ( <Box className="flex items-center gap-2 text-sm text-muted-foreground"> <CircularProgress size={16} /> @@ -175,6 +213,8 @@ export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library, folder={folder} disabled={changing} onLayoutChange={handleLayoutChange} + check={checkFor(folder)} + onApplyPlexMapping={libraryCheck.applyPlexMapping} /> ))} </Box> diff --git a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx index bbcac97cf..af93d451a 100644 --- a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx +++ b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx @@ -21,6 +21,27 @@ jest.mock('../../../../shared/Reorganize', () => { const mockSetFolderLayout = jest.fn(); const mockRefetch = jest.fn(); +const mockCheckRefetch = jest.fn(); +const mockApplyPlexMapping = jest.fn(); +let mockCheckData: unknown = null; + +jest.mock('../../../../../hooks/useLibraryCheck', () => ({ + useLibraryCheck: () => ({ + data: mockCheckData, + loading: false, + error: null, + refetch: mockCheckRefetch, + applyPlexMapping: mockApplyPlexMapping, + }), +})); + +const checkReport = (name: string, layout: 'videos' | 'tv', status: string, issues: { code: string; message: string; libraryId?: string }[] = []) => ({ + name, + layout, + hasFiles: true, + channels: 1, + servers: [{ serverType: 'plex', status, libraries: [{ id: '41', name: 'YouTube TV', type: layout, location: 'Q:\\Y', relation: 'exact' }], issues }], +}); const FOLDERS: LibraryFolder[] = [ { name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 3 }, @@ -59,6 +80,7 @@ async function chooseLayout(user: User, folderLabel: string, optionLabel: string describe('LibraryFoldersList', () => { beforeEach(() => { mockHook(); + mockCheckData = null; }); test('renders a row for each folder with its label', () => { @@ -267,4 +289,57 @@ describe('LibraryFoldersList', () => { expect(mockRefetch).toHaveBeenCalled(); }); + + describe('media server check', () => { + const servers = [{ serverType: 'plex', name: 'Plex', reachable: true, error: null }]; + + test("shows each TV folder's libraries", () => { + mockCheckData = { servers, folders: [checkReport('Kids', 'tv', 'ok')] }; + render(<LibraryFoldersList library={library} token="token" />); + + expect(within(rowFor('__Kids')).getByText(/YouTube TV/)).toBeInTheDocument(); + }); + + test('shows only problems for a Videos folder', () => { + mockCheckData = { + servers, + folders: [checkReport('', 'videos', 'ok'), checkReport('Shows', 'videos', 'warning', [{ code: 'wrongType', message: 'YouTube TV is a TV Shows library.' }])], + }; + render(<LibraryFoldersList library={library} token="token" />); + + expect(within(rowFor('Main folder')).queryByText(/YouTube TV/)).not.toBeInTheDocument(); + expect(within(rowFor('__Shows')).getByText('YouTube TV is a TV Shows library.')).toBeInTheDocument(); + }); + + test('maps a TV folder for Plex refreshes from its issue', async () => { + const user = userEvent.setup(); + mockApplyPlexMapping.mockResolvedValue(undefined); + mockCheckData = { + servers, + folders: [checkReport('Kids', 'tv', 'warning', [{ code: 'plexMappingMissing', message: "New episodes don't refresh YouTube TV.", libraryId: '41' }])], + }; + render(<LibraryFoldersList library={library} token="token" />); + + await user.click(screen.getByRole('button', { name: 'Refresh this library' })); + + expect(mockApplyPlexMapping).toHaveBeenCalledWith('Kids', '41'); + }); + + test('checks again on request', async () => { + const user = userEvent.setup(); + mockCheckData = { servers, folders: [] }; + render(<LibraryFoldersList library={library} token="token" />); + + await user.click(screen.getByRole('button', { name: 'Check again' })); + + expect(mockCheckRefetch).toHaveBeenCalled(); + }); + + test('shows nothing without configured media servers', () => { + mockCheckData = { servers: [], folders: [checkReport('Kids', 'tv', 'ok')] }; + render(<LibraryFoldersList library={library} token="token" />); + + expect(screen.queryByRole('button', { name: 'Check again' })).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/shared/LibraryCheck/LibraryCheckNotes.tsx b/client/src/components/shared/LibraryCheck/LibraryCheckNotes.tsx new file mode 100644 index 000000000..982f7bb54 --- /dev/null +++ b/client/src/components/shared/LibraryCheck/LibraryCheckNotes.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { Box, Button, Typography } from '../../ui'; +import { CheckCircle, Info, Warning, XCircle } from '../../../lib/icons'; +import { + LibraryCheckFolder, LibraryCheckServer, LibraryCheckServerReport, MediaServerType, +} from '../../../types/libraryCheck'; + +const MAPPING_ISSUE = 'plexMappingMissing'; +const MAPPING_FAILED_MESSAGE = 'Could not save the Plex library mapping.'; + +function folderLabel(name: string): string { + return name ? `__${name}` : 'the main folder'; +} + +/** How to add a TV library for a folder, per server. */ +export function tvSetupHint(serverType: MediaServerType, folder: string): string { + const label = folderLabel(folder); + switch (serverType) { + case 'plex': + return `Add a TV Shows library for ${label}: scanner Plex TV Series, agent Plex NFO Series (or Plex Personal Media), ` + + 'with local assets on.'; + case 'jellyfin': + return `Add a Shows library for ${label} with NFO saving off and all metadata downloaders and image fetchers off.`; + default: + return `Add a TV Shows library for ${label} with the NFO metadata reader on, NFO saving off and all metadata downloaders and image fetchers off.`; + } +} + +function summaryOf(report: LibraryCheckServerReport, layout: LibraryCheckFolder['layout']): string { + if (report.status === 'unreachable') return "couldn't be checked"; + if (report.status === 'missing') return layout === 'tv' ? 'no TV library holds this folder' : 'not in a library'; + const names = [...new Set(report.libraries.filter((library) => library.relation !== 'inside').map((library) => library.name))]; + return names.join(', '); +} + +function StatusIcon({ status }: { status: LibraryCheckServerReport['status'] }) { + const className = 'mt-0.5 h-4 w-4 shrink-0'; + if (status === 'ok') return <CheckCircle aria-hidden className={`${className} text-success`} />; + if (status === 'unreachable') return <XCircle aria-hidden className={`${className} text-destructive`} />; + if (status === 'missing') return <Info aria-hidden className={`${className} text-muted-foreground`} />; + return <Warning aria-hidden className={`${className} text-warning`} />; +} + +export interface LibraryCheckNotesProps { + folder: LibraryCheckFolder; + servers: LibraryCheckServer[]; + /** Tell how to add a TV library on servers that have none (TV folder setup) */ + showSetupHints?: boolean; + /** Offer to map a TV folder to the Plex library that holds it, so new episodes refresh it */ + onApplyPlexMapping?: (folder: string, libraryId: string) => Promise<void>; + /** Leave out servers where all is well */ + problemsOnly?: boolean; +} + +/** What each media server's libraries do with one library folder. */ +export function LibraryCheckNotes({ + folder, servers, showSetupHints = false, onApplyPlexMapping, problemsOnly = false, +}: LibraryCheckNotesProps) { + const [mapping, setMapping] = useState(false); + const [mappingError, setMappingError] = useState<string | null>(null); + const nameOf = (serverType: MediaServerType) => servers.find((server) => server.serverType === serverType)?.name ?? serverType; + const reports = folder.servers.filter((report) => !problemsOnly || report.status !== 'ok'); + if (reports.length === 0) return null; + + const mapFolder = async (libraryId: string) => { + if (!onApplyPlexMapping) return; + setMapping(true); + setMappingError(null); + try { + await onApplyPlexMapping(folder.name, libraryId); + } catch (err: unknown) { + setMappingError(err instanceof Error && err.message ? err.message : MAPPING_FAILED_MESSAGE); + } finally { + setMapping(false); + } + }; + + return ( + <Box component="ul" aria-label={`Media server libraries for ${folderLabel(folder.name)}`} className="m-0 flex list-none flex-col gap-1.5 p-0"> + {reports.map((report) => ( + <li key={report.serverType} className="flex gap-2"> + <StatusIcon status={report.status} /> + <Box className="min-w-0"> + <Typography variant="body2" className="break-words"> + <span className="font-medium">{nameOf(report.serverType)}:</span> {summaryOf(report, folder.layout)} + </Typography> + {report.issues.filter((issue) => issue.code !== 'noLibrary').map((issue, index) => ( + <Box key={`${issue.code}-${issue.libraryId ?? index}`} className="flex flex-wrap items-center gap-2"> + <Typography variant="caption" color="text.secondary" className="break-words">{issue.message}</Typography> + {issue.code === MAPPING_ISSUE && issue.libraryId && onApplyPlexMapping && ( + <Button size="small" variant="outlined" loading={mapping} onClick={() => { void mapFolder(issue.libraryId as string); }}> + Refresh this library + </Button> + )} + </Box> + ))} + {report.status === 'missing' && folder.layout === 'tv' && showSetupHints && ( + <Typography variant="caption" color="text.secondary" className="block"> + {tvSetupHint(report.serverType, folder.name)} + </Typography> + )} + {report.serverType === 'plex' && mappingError && ( + <Typography variant="caption" color="error" className="block">{mappingError}</Typography> + )} + </Box> + </li> + ))} + </Box> + ); +} + +export default LibraryCheckNotes; diff --git a/client/src/components/shared/LibraryCheck/__tests__/LibraryCheckNotes.test.tsx b/client/src/components/shared/LibraryCheck/__tests__/LibraryCheckNotes.test.tsx new file mode 100644 index 000000000..277adcfa4 --- /dev/null +++ b/client/src/components/shared/LibraryCheck/__tests__/LibraryCheckNotes.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { LibraryCheckNotes } from '../LibraryCheckNotes'; +import { LibraryCheckFolder, LibraryCheckServer, LibraryCheckServerReport } from '../../../../types/libraryCheck'; + +const SERVERS: LibraryCheckServer[] = [ + { serverType: 'plex', name: 'Plex', reachable: true, error: null }, + { serverType: 'jellyfin', name: 'Jellyfin', reachable: true, error: null }, + { serverType: 'emby', name: 'Emby', reachable: false, error: 'connect ECONNREFUSED' }, +]; + +const report = (overrides: Partial<LibraryCheckServerReport>): LibraryCheckServerReport => ({ + serverType: 'plex', + status: 'ok', + libraries: [{ id: '41', name: 'YouTube TV', type: 'tv', location: 'Q:\\Y\\__TV', relation: 'exact' }], + issues: [], + ...overrides, +}); + +const tvFolder = (servers: LibraryCheckServerReport[]): LibraryCheckFolder => ({ + name: 'TV', layout: 'tv', hasFiles: true, channels: 1, servers, +}); + +function itemFor(serverName: string): HTMLElement { + const item = screen.getAllByRole('listitem').find((entry) => within(entry).queryByText(`${serverName}:`)); + if (!item) throw new Error(`No line for ${serverName}`); + return item; +} + +describe('LibraryCheckNotes', () => { + test("names the library that holds the folder on each server", () => { + render(<LibraryCheckNotes folder={tvFolder([report({})])} servers={SERVERS} />); + + expect(itemFor('Plex')).toHaveTextContent('YouTube TV'); + }); + + test('lists the issues the server reported', () => { + render(<LibraryCheckNotes + folder={tvFolder([report({ status: 'warning', issues: [{ code: 'plexSeriesAgent', message: 'YouTube TV uses the Plex Series agent.' }] })])} + servers={SERVERS} + />); + + expect(within(itemFor('Plex')).getByText('YouTube TV uses the Plex Series agent.')).toBeInTheDocument(); + }); + + test('lists two issues of the same kind without complaint', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + render(<LibraryCheckNotes + folder={tvFolder([report({ + status: 'warning', + issues: [ + { code: 'duplicateLibrary', message: 'TV A, TV B all point at __TV.' }, + { code: 'duplicateLibrary', message: 'TV C, TV D all point at __TV.' }, + ], + })])} + servers={SERVERS} + />); + + const item = itemFor('Plex'); + expect(within(item).getByText('TV A, TV B all point at __TV.')).toBeInTheDocument(); + expect(within(item).getByText('TV C, TV D all point at __TV.')).toBeInTheDocument(); + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + test('says how to add a TV library during setup', () => { + render(<LibraryCheckNotes + folder={tvFolder([report({ serverType: 'jellyfin', status: 'missing', libraries: [], issues: [{ code: 'noLibrary', message: 'No Jellyfin Shows library holds __TV.' }] })])} + servers={SERVERS} + showSetupHints + />); + + const item = itemFor('Jellyfin'); + expect(item).toHaveTextContent('no TV library holds this folder'); + expect(item).toHaveTextContent('Add a Shows library for __TV'); + }); + + test('leaves out the setup steps outside setup', () => { + render(<LibraryCheckNotes + folder={tvFolder([report({ serverType: 'jellyfin', status: 'missing', libraries: [] })])} + servers={SERVERS} + />); + + expect(itemFor('Jellyfin')).not.toHaveTextContent('Add a Shows library'); + }); + + test("says when a server couldn't be checked", () => { + render(<LibraryCheckNotes + folder={tvFolder([report({ serverType: 'emby', status: 'unreachable', libraries: [], issues: [{ code: 'unreachable', message: "Couldn't read Emby's libraries: connect ECONNREFUSED" }] })])} + servers={SERVERS} + />); + + expect(itemFor('Emby')).toHaveTextContent("couldn't be checked"); + }); + + test('can leave out servers where all is well', () => { + render(<LibraryCheckNotes + folder={tvFolder([report({}), report({ serverType: 'jellyfin', status: 'missing', libraries: [] })])} + servers={SERVERS} + problemsOnly + />); + + expect(screen.getAllByRole('listitem')).toHaveLength(1); + expect(screen.queryByText('Plex:')).not.toBeInTheDocument(); + }); + + test('renders nothing when every server is fine and only problems are wanted', () => { + const { container } = render(<LibraryCheckNotes folder={tvFolder([report({})])} servers={SERVERS} problemsOnly />); + + expect(container).toBeEmptyDOMElement(); + }); + + test('maps the folder for Plex refreshes from a missing mapping', async () => { + const user = userEvent.setup(); + const onApplyPlexMapping = jest.fn().mockResolvedValue(undefined); + render(<LibraryCheckNotes + folder={tvFolder([report({ status: 'warning', issues: [{ code: 'plexMappingMissing', message: "New episodes don't refresh YouTube TV.", libraryId: '41' }] })])} + servers={SERVERS} + onApplyPlexMapping={onApplyPlexMapping} + />); + + await user.click(screen.getByRole('button', { name: 'Refresh this library' })); + + expect(onApplyPlexMapping).toHaveBeenCalledWith('TV', '41'); + }); + + test("shows the server's refusal when the mapping fails", async () => { + const user = userEvent.setup(); + const onApplyPlexMapping = jest.fn().mockRejectedValue(new Error('__TV already refreshes another Plex library.')); + render(<LibraryCheckNotes + folder={tvFolder([report({ status: 'warning', issues: [{ code: 'plexMappingMissing', message: 'Not refreshed.', libraryId: '41' }] })])} + servers={SERVERS} + onApplyPlexMapping={onApplyPlexMapping} + />); + + await user.click(screen.getByRole('button', { name: 'Refresh this library' })); + + expect(await screen.findByText('__TV already refreshes another Plex library.')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/shared/Reorganize/ReorganizeDialog.tsx b/client/src/components/shared/Reorganize/ReorganizeDialog.tsx index 007504d62..60f7b5183 100644 --- a/client/src/components/shared/Reorganize/ReorganizeDialog.tsx +++ b/client/src/components/shared/Reorganize/ReorganizeDialog.tsx @@ -16,6 +16,7 @@ import { useReorganizeOperation } from './hooks/useReorganizeOperation'; import ReorganizePreviewBody from './ReorganizePreviewBody'; import ReorganizeOperationBody from './ReorganizeOperationBody'; import { serverCodeOf, serverMessageOf } from './reorganizeErrors'; +import { useLibraryCheck } from '../../../hooks/useLibraryCheck'; import { countOf } from './reorganizeText'; const STALE_PREVIEW_CODE = 'STALE_PREVIEW'; @@ -57,6 +58,11 @@ function ReorganizeDialog({ const previewing = open && operationId === null; const { preview, loading, error: previewError, refresh } = useReorganizePreview(token, previewing ? change : null); const { operation, error: operationError, retrying, retry } = useReorganizeOperation(token, open ? operationId : null); + const tvFolders = preview?.tvFolders ?? []; + // Checked as TV folders: a folder switching layouts is still saved as a Videos folder until the move. + const { data: libraryCheck } = useLibraryCheck(token, { + folders: tvFolders, layout: 'tv', enabled: previewing && tvFolders.length > 0, + }); const start = async () => { if (!preview || !change || startingRef.current) return; @@ -101,7 +107,7 @@ function ReorganizeDialog({ {loading && <LinearProgress />} {previewError && <Alert severity="error">{previewError}</Alert>} {startError && <Alert severity="error">{startError}</Alert>} - {preview && <ReorganizePreviewBody preview={preview} />} + {preview && <ReorganizePreviewBody preview={preview} libraryCheck={libraryCheck} />} </div> ) : ( <div className="pt-2"> diff --git a/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx index a30af9713..c86f30014 100644 --- a/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx +++ b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx @@ -1,12 +1,33 @@ import React from 'react'; import { Alert, Box, Chip, Typography } from '../../ui'; import { ReorganizePreview, ReorganizePreviewItem } from '../../../types/reorganize'; +import { LibraryCheckResponse } from '../../../types/libraryCheck'; +import { LibraryCheckNotes } from '../LibraryCheck/LibraryCheckNotes'; import { agree, countOf, DOWNLOADS_WAIT_NOTE, folderName, MOVIE_TAGS_NOTE, serverName, WATCH_STATE_NOTE, } from './reorganizeText'; interface ReorganizePreviewBodyProps { preview: ReorganizePreview; + /** The library check for the TV folders videos move into */ + libraryCheck?: LibraryCheckResponse | null; +} + +/** Media server library problems for the TV folders videos move into. */ +function LibraryProblems({ libraryCheck }: { libraryCheck: LibraryCheckResponse }) { + const folders = libraryCheck.folders.filter((folder) => folder.servers.some((server) => server.status !== 'ok')); + if (folders.length === 0) return null; + return ( + <Alert severity="warning"> + <Typography variant="body2" className="mb-1">Check the media server libraries for these TV folders before or after the move:</Typography> + {folders.map((folder) => ( + <Box key={folder.name || 'main-folder'} className="mt-1"> + <Typography variant="body2" className="font-semibold">{folderName(folder.name)}</Typography> + <LibraryCheckNotes folder={folder} servers={libraryCheck.servers} showSetupHints problemsOnly /> + </Box> + ))} + </Alert> + ); } function MoveRow({ item }: { item: ReorganizePreviewItem }) { @@ -91,7 +112,7 @@ function BlockedAlert({ blocked }: { blocked: NonNullable<ReorganizePreview['blo } /** The dry run of a reorganize: what moves where, and what to know first. */ -function ReorganizePreviewBody({ preview }: ReorganizePreviewBodyProps) { +function ReorganizePreviewBody({ preview, libraryCheck = null }: ReorganizePreviewBodyProps) { const problems = problemLines(preview); if (!preview.needed) { return ( @@ -139,6 +160,7 @@ function ReorganizePreviewBody({ preview }: ReorganizePreviewBodyProps) { {problems.map((line) => <Typography key={line} variant="body2">{line}</Typography>)} </Alert> )} + {libraryCheck && <LibraryProblems libraryCheck={libraryCheck} />} {preview.watchState.length > 0 && ( <Alert severity="info"> <Typography variant="body2">{WATCH_STATE_NOTE}</Typography> diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx index ea0fe4789..2fe49654c 100644 --- a/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx @@ -123,4 +123,49 @@ describe('ReorganizeDialog', () => { expect(axios.post).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); }); + + test('checks the media server libraries of the TV folders videos move into', async () => { + axios.post.mockResolvedValueOnce({ data: { ...PREVIEW, tvFolders: ['TV'] } }); + axios.get.mockResolvedValue({ + data: { + servers: [{ serverType: 'emby', name: 'Emby', reachable: true, error: null }], + folders: [{ name: 'TV', layout: 'tv', hasFiles: true, channels: 1, servers: [{ serverType: 'emby', status: 'missing', libraries: [], issues: [] }] }], + }, + }); + renderDialog(); + + expect(await screen.findByText(/Add a TV Shows library for __TV/)).toBeInTheDocument(); + const [url, options] = axios.get.mock.calls.find(([calledUrl]: [string]) => calledUrl === '/api/library-folders/check'); + expect(url).toBe('/api/library-folders/check'); + expect((options.params as URLSearchParams).getAll('folder')).toEqual(['TV']); + // The folder may still be saved as a Videos folder: the move is what makes it a TV folder. + expect((options.params as URLSearchParams).get('layout')).toBe('tv'); + }); + + test('forgets the previous move\'s library problems when the next move has no TV folder', async () => { + const problems = { + servers: [{ serverType: 'emby', name: 'Emby', reachable: true, error: null }], + folders: [{ name: 'TV', layout: 'tv', hasFiles: true, channels: 1, servers: [{ serverType: 'emby', status: 'missing', libraries: [], issues: [] }] }], + }; + axios.post.mockResolvedValueOnce({ data: { ...PREVIEW, tvFolders: ['TV'] } }); + axios.get.mockResolvedValue({ data: problems }); + const onClose = jest.fn(); + const { rerender } = render(<ReorganizeDialog open token="token" change={CHANGE} onClose={onClose} onApplied={jest.fn()} />); + expect(await screen.findByText(/Check the media server libraries/)).toBeInTheDocument(); + + const back = { type: 'channelLayout' as const, channelId: 'UC1', layout: 'videos' as const }; + axios.post.mockResolvedValueOnce({ data: { ...PREVIEW, revision: 'rev2', tvFolders: [] } }); + rerender(<ReorganizeDialog open token="token" change={back} onClose={onClose} onApplied={jest.fn()} />); + + await screen.findByRole('button', { name: 'Move 2 videos' }); + await waitFor(() => expect(screen.queryByText(/Check the media server libraries/)).not.toBeInTheDocument()); + }); + + test("doesn't check media servers when no video becomes a TV episode", async () => { + axios.post.mockResolvedValueOnce({ data: { ...PREVIEW, tvFolders: [] } }); + renderDialog(); + + expect(await screen.findByRole('button', { name: 'Move 2 videos' })).toBeInTheDocument(); + expect(axios.get).not.toHaveBeenCalledWith('/api/library-folders/check', expect.anything()); + }); }); diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx index b02a91c8f..8316119f6 100644 --- a/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx @@ -101,4 +101,36 @@ describe('ReorganizePreviewBody', () => { expect(screen.getByText(/1 video would land outside the downloads folder/)).toBeInTheDocument(); }); + + test('says how many videos keep movie tags, in one sentence', () => { + render(<ReorganizePreviewBody preview={preview({ totals: { ...TOTALS, movieTags: 1 } })} />); + + expect(screen.getByText(/^1 video keeps movie tags inside the video file\./)).toBeInTheDocument(); + }); + + describe('media server libraries', () => { + const libraryCheck = (status: 'ok' | 'missing') => ({ + servers: [{ serverType: 'jellyfin' as const, name: 'Jellyfin', reachable: true, error: null }], + folders: [{ + name: 'TV', + layout: 'tv' as const, + hasFiles: true, + channels: 1, + servers: [{ serverType: 'jellyfin' as const, status, libraries: [], issues: [] }], + }], + }); + + test('points out a TV folder no library holds yet', () => { + render(<ReorganizePreviewBody preview={preview()} libraryCheck={libraryCheck('missing')} />); + + expect(screen.getByText(/Check the media server libraries for these TV folders/)).toBeInTheDocument(); + expect(screen.getByText(/Add a Shows library for __TV/)).toBeInTheDocument(); + }); + + test('says nothing when every library is fine', () => { + render(<ReorganizePreviewBody preview={preview()} libraryCheck={libraryCheck('ok')} />); + + expect(screen.queryByText(/Check the media server libraries/)).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts index b20797d1f..6b7f309a6 100644 --- a/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts +++ b/client/src/components/shared/Reorganize/hooks/__tests__/useReorganizePreview.test.ts @@ -1,5 +1,5 @@ import { renderHook, waitFor, act } from '@testing-library/react'; -import { useReorganizePreview } from '../useReorganizePreview'; +import { useReorganizePreview, BLOCKED_RECHECK_MS } from '../useReorganizePreview'; jest.mock('axios', () => ({ post: jest.fn(), @@ -49,4 +49,71 @@ describe('useReorganizePreview', () => { expect(result.current.preview).toEqual({ ...PREVIEW, revision: 'rev2' }); }); + + describe('a preview held up by a running task', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('is computed again until the task ends', async () => { + axios.post + .mockResolvedValueOnce({ data: { ...PREVIEW, blocked: { reason: 'task-running', message: 'A sync is running.' } } }) + .mockResolvedValueOnce({ data: { ...PREVIEW, blocked: null } }); + const { result } = renderHook(() => useReorganizePreview('token', CHANGE)); + await waitFor(() => expect(result.current.preview?.blocked).toBeTruthy()); + + await act(async () => { jest.advanceTimersByTime(BLOCKED_RECHECK_MS); }); + + await waitFor(() => expect(result.current.preview?.blocked).toBeNull()); + expect(axios.post).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); + }); + + test('is not computed again when no video could be planned', async () => { + axios.post.mockResolvedValueOnce({ data: { ...PREVIEW, blocked: { reason: 'problems', message: 'Nothing can move.' } } }); + const { result } = renderHook(() => useReorganizePreview('token', CHANGE)); + await waitFor(() => expect(result.current.preview?.blocked).toBeTruthy()); + + await act(async () => { jest.advanceTimersByTime(BLOCKED_RECHECK_MS * 3); }); + + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + test('keeps the preview when a recheck fails', async () => { + const blocked = { ...PREVIEW, blocked: { reason: 'task-running', message: 'A sync is running.' } }; + axios.post + .mockResolvedValueOnce({ data: blocked }) + .mockRejectedValueOnce({ response: { status: 500, data: { error: 'boom' } } }); + const { result } = renderHook(() => useReorganizePreview('token', CHANGE)); + await waitFor(() => expect(result.current.preview).toEqual(blocked)); + + await act(async () => { jest.advanceTimersByTime(BLOCKED_RECHECK_MS); }); + + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); + expect(result.current.preview).toEqual(blocked); + expect(result.current.error).toBeNull(); + }); + + test('is computed again after a failed recheck', async () => { + const blocked = { ...PREVIEW, blocked: { reason: 'task-running', message: 'A sync is running.' } }; + axios.post + .mockResolvedValueOnce({ data: blocked }) + .mockRejectedValueOnce({ response: { status: 500, data: { error: 'boom' } } }) + .mockResolvedValueOnce({ data: { ...PREVIEW, blocked: null } }); + const { result } = renderHook(() => useReorganizePreview('token', CHANGE)); + await waitFor(() => expect(result.current.preview).toEqual(blocked)); + + await act(async () => { jest.advanceTimersByTime(BLOCKED_RECHECK_MS); }); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); + await act(async () => { jest.advanceTimersByTime(BLOCKED_RECHECK_MS); }); + + await waitFor(() => expect(result.current.preview?.blocked).toBeNull()); + expect(axios.post).toHaveBeenCalledTimes(3); + }); + }); }); diff --git a/client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts b/client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts index a2f4ec199..3bfbc8a54 100644 --- a/client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts +++ b/client/src/components/shared/Reorganize/hooks/useReorganizePreview.ts @@ -3,6 +3,12 @@ import axios from 'axios'; import { ReorganizeChange, ReorganizePreview } from '../../../../types/reorganize'; import { serverMessageOf } from '../reorganizeErrors'; +// A preview held up by a task that ends on its own (a download, a sync) is +// computed again until the task ends, so the move can start without +// reopening the dialog. Nothing ends a 'problems' refusal. +export const BLOCKED_RECHECK_MS = 5000; +const PROBLEMS_REASON = 'problems'; + export interface UseReorganizePreviewResult { preview: ReorganizePreview | null; loading: boolean; @@ -20,11 +26,13 @@ export function useReorganizePreview(token: string | null, change: ReorganizeCha const requestSeq = useRef(0); const changeKey = change ? JSON.stringify(change) : null; - const fetchPreview = useCallback(async () => { + const fetchPreview = useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => { if (!token || !changeKey) return; const seq = ++requestSeq.current; - setLoading(true); - setError(null); + if (!quiet) { + setLoading(true); + setError(null); + } try { const response = await axios.post<ReorganizePreview>( '/api/tv/reorganize/preview', @@ -33,11 +41,13 @@ export function useReorganizePreview(token: string | null, change: ReorganizeCha ); if (seq === requestSeq.current) setPreview(response.data); } catch (err: unknown) { - if (seq === requestSeq.current) { + // A failed recheck keeps the preview on screen. + if (seq === requestSeq.current && !quiet) { setPreview(null); setError(serverMessageOf(err, 'Failed to preview the move')); } } finally { + // Also ends a full load this recheck overtook. if (seq === requestSeq.current) setLoading(false); } }, [token, changeKey]); @@ -50,7 +60,21 @@ export function useReorganizePreview(token: string | null, change: ReorganizeCha }; }, [fetchPreview]); - return { preview, loading, error, refresh: fetchPreview }; + const blockedReason = preview?.blocked?.reason ?? null; + // Each recheck arms the next one once it has settled, so a failed recheck + // (which leaves the preview as it was) doesn't end the polling. + const [recheckTick, setRecheckTick] = useState(0); + useEffect(() => { + if (!blockedReason || blockedReason === PROBLEMS_REASON) return undefined; + const timer = setTimeout(() => { + fetchPreview({ quiet: true }).finally(() => setRecheckTick((tick) => tick + 1)); + }, BLOCKED_RECHECK_MS); + return () => clearTimeout(timer); + }, [recheckTick, blockedReason, fetchPreview]); + + const refresh = useCallback(() => fetchPreview(), [fetchPreview]); + + return { preview, loading, error, refresh }; } export default useReorganizePreview; diff --git a/client/src/components/shared/Reorganize/reorganizeText.ts b/client/src/components/shared/Reorganize/reorganizeText.ts index d3ffa4d89..f1b468c0e 100644 --- a/client/src/components/shared/Reorganize/reorganizeText.ts +++ b/client/src/components/shared/Reorganize/reorganizeText.ts @@ -26,9 +26,9 @@ export function folderName(libraryFolder: string): string { export const DOWNLOADS_WAIT_NOTE = 'Downloads wait in the queue until the move finishes.'; -export const WATCH_STATE_NOTE = 'Media servers show moved videos as new, unwatched items. Youtarr keeps its own watched ' +export const WATCH_STATE_NOTE = 'Media servers may show moved videos as new, unwatched items. Youtarr keeps its own watched ' + 'state and restores it on Plex (the server owner), Jellyfin and Emby once they have scanned the moved files. ' + 'Other Plex accounts keep their history in Youtarr, but not on Plex.'; -export const MOVIE_TAGS_NOTE = 'keep movie tags inside the video file. The NFO files are rewritten, but Plex Personal ' +export const MOVIE_TAGS_NOTE = 'movie tags inside the video file. The NFO files are rewritten, but Plex Personal ' + 'Media may keep showing the old title.'; diff --git a/client/src/hooks/__tests__/useConfig.test.ts b/client/src/hooks/__tests__/useConfig.test.ts index 1b8b55468..5bece1aed 100644 --- a/client/src/hooks/__tests__/useConfig.test.ts +++ b/client/src/hooks/__tests__/useConfig.test.ts @@ -1,5 +1,5 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { useConfig } from '../useConfig'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useConfig, CONFIG_PATCHED_EVENT } from '../useConfig'; import { LoggingStatus } from '../../components/Configuration/types'; const LOGGING: LoggingStatus = { @@ -38,3 +38,78 @@ describe('useConfig logging status', () => { expect(result.current.config.logLevel).toBe('debug'); }); }); + +describe('useConfig patched from elsewhere', () => { + const originalFetch = global.fetch; + const MAPPINGS = [{ subfolder: 'TV', libraryId: '41' }]; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ preferredResolution: '720', plexSubfolderLibraryMappings: [] }), + }) as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + test('takes a saved field into the config and its saved copy, keeping unsaved edits', async () => { + const { result } = renderHook(() => useConfig('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => { result.current.setConfig((prev) => ({ ...prev, preferredResolution: '1080' })); }); + + act(() => { + window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: { plexSubfolderLibraryMappings: MAPPINGS } })); + }); + + expect(result.current.config.plexSubfolderLibraryMappings).toEqual(MAPPINGS); + expect(result.current.config.preferredResolution).toBe('1080'); + expect(result.current.initialConfig?.plexSubfolderLibraryMappings).toEqual(MAPPINGS); + expect(result.current.initialConfig?.preferredResolution).toBe('720'); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('useConfig patched while the mappings themselves have unsaved edits', () => { + const originalFetch = global.fetch; + const KIDS = { subfolder: 'Kids', libraryId: '12' }; + const TV = { subfolder: 'TV', libraryId: '41' }; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ plexSubfolderLibraryMappings: [KIDS] }), + }) as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + test('keeps a pending removal of another folder\'s mapping and still shows the draft as changed', async () => { + const { result } = renderHook(() => useConfig('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => { result.current.setConfig((prev) => ({ ...prev, plexSubfolderLibraryMappings: [] })); }); + + act(() => { + window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: { plexSubfolderLibraryMappings: [KIDS, TV] } })); + }); + + expect(result.current.config.plexSubfolderLibraryMappings).toEqual([TV]); + expect(result.current.initialConfig?.plexSubfolderLibraryMappings).toEqual([KIDS, TV]); + }); + + test('keeps a pending change of another folder\'s mapping', async () => { + const edited = { subfolder: 'Kids', libraryId: '99' }; + const { result } = renderHook(() => useConfig('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => { result.current.setConfig((prev) => ({ ...prev, plexSubfolderLibraryMappings: [edited] })); }); + + act(() => { + window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: { plexSubfolderLibraryMappings: [KIDS, TV] } })); + }); + + expect(result.current.config.plexSubfolderLibraryMappings).toEqual([edited, TV]); + }); +}); diff --git a/client/src/hooks/__tests__/useLibraryCheck.test.ts b/client/src/hooks/__tests__/useLibraryCheck.test.ts new file mode 100644 index 000000000..a013ce296 --- /dev/null +++ b/client/src/hooks/__tests__/useLibraryCheck.test.ts @@ -0,0 +1,138 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useLibraryCheck } from '../useLibraryCheck'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../useLibraryFolders'; +import { CONFIG_PATCHED_EVENT } from '../useConfig'; + +jest.mock('axios', () => ({ + get: jest.fn(), + put: jest.fn(), + isAxiosError: (err: { isAxiosError?: boolean }) => Boolean(err && err.isAxiosError), +})); + +const axios = require('axios'); + +const RESPONSE = { servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null }], folders: [] }; + +describe('useLibraryCheck', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: RESPONSE }); + }); + + test('loads the check', async () => { + const { result } = renderHook(() => useLibraryCheck('token')); + + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + expect(axios.get).toHaveBeenCalledWith('/api/library-folders/check', expect.objectContaining({ + headers: { 'x-access-token': 'token' }, + })); + }); + + test('asks only for the given folders', async () => { + const { result } = renderHook(() => useLibraryCheck('token', { folders: ['TV', ''] })); + + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + const params = axios.get.mock.calls[0][1].params as URLSearchParams; + expect(params.getAll('folder')).toEqual(['TV', '']); + }); + + test('asks for the given folders to be checked as another layout', async () => { + const { result } = renderHook(() => useLibraryCheck('token', { folders: ['TV'], layout: 'tv' })); + + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + const params = axios.get.mock.calls[0][1].params as URLSearchParams; + expect(params.get('layout')).toBe('tv'); + }); + + test('waits while disabled', () => { + renderHook(() => useLibraryCheck('token', { enabled: false })); + + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('stops loading when disabled while a check is in flight', async () => { + let settle: (value: { data: typeof RESPONSE }) => void = () => undefined; + axios.get.mockReturnValue(new Promise((resolve) => { settle = resolve; })); + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => useLibraryCheck('token', { enabled }), + { initialProps: { enabled: true } } + ); + await waitFor(() => expect(result.current.loading).toBe(true)); + + rerender({ enabled: false }); + await act(async () => { settle({ data: RESPONSE }); }); + + expect(result.current.loading).toBe(false); + }); + + test('drops the previous folders\' report when disabled for other folders', async () => { + const { result, rerender } = renderHook( + ({ folders, enabled }: { folders: string[]; enabled: boolean }) => useLibraryCheck('token', { folders, enabled }), + { initialProps: { folders: ['TV'], enabled: true } } + ); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + + rerender({ folders: [], enabled: false }); + + await waitFor(() => expect(result.current.data).toBeNull()); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + test('drops the previous folders\' report while checking other folders', async () => { + let settle: (value: { data: typeof RESPONSE }) => void = () => undefined; + const { result, rerender } = renderHook( + ({ folders }: { folders: string[] }) => useLibraryCheck('token', { folders }), + { initialProps: { folders: ['TV'] } } + ); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + axios.get.mockReturnValue(new Promise((resolve) => { settle = resolve; })); + + rerender({ folders: ['Kids'] }); + + await waitFor(() => expect(result.current.loading).toBe(true)); + expect(result.current.data).toBeNull(); + await act(async () => { settle({ data: RESPONSE }); }); + }); + + test("shows the server's error", async () => { + axios.get.mockRejectedValue({ isAxiosError: true, response: { data: { error: 'Failed to check the media server libraries' } } }); + const { result } = renderHook(() => useLibraryCheck('token')); + + await waitFor(() => expect(result.current.error).toBe('Failed to check the media server libraries')); + }); + + test('checks again when a folder layout changes', async () => { + const { result } = renderHook(() => useLibraryCheck('token')); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + + act(() => { window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); }); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + test('saves a Plex mapping, hands the saved mappings to the config and checks again', async () => { + const mappings = [{ subfolder: 'Kids', libraryId: '12' }, { subfolder: 'TV', libraryId: '41' }]; + axios.put.mockResolvedValue({ data: { mappedLibraryId: '41', plexSubfolderLibraryMappings: mappings } }); + const patches: unknown[] = []; + const listener = (event: Event) => { patches.push((event as CustomEvent).detail); }; + window.addEventListener(CONFIG_PATCHED_EVENT, listener); + const { result } = renderHook(() => useLibraryCheck('token')); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + + await act(async () => { await result.current.applyPlexMapping('TV', '41'); }); + + expect(axios.put).toHaveBeenCalledWith('/api/library-folders/plex-mapping', { folder: 'TV', libraryId: '41' }, expect.any(Object)); + expect(patches).toEqual([{ plexSubfolderLibraryMappings: mappings }]); + expect(axios.get).toHaveBeenCalledTimes(2); + window.removeEventListener(CONFIG_PATCHED_EVENT, listener); + }); + + test("throws the server's refusal when the mapping fails", async () => { + axios.put.mockRejectedValue({ isAxiosError: true, response: { data: { error: 'Already mapped.' } } }); + const { result } = renderHook(() => useLibraryCheck('token')); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + + await expect(result.current.applyPlexMapping('TV', '41')).rejects.toThrow('Already mapped.'); + }); +}); diff --git a/client/src/hooks/useConfig.ts b/client/src/hooks/useConfig.ts index 5db5ecb1c..a6294589a 100644 --- a/client/src/hooks/useConfig.ts +++ b/client/src/hooks/useConfig.ts @@ -1,8 +1,36 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { ConfigState, PlatformManagedState, DeploymentEnvironment, LoggingStatus } from '../components/Configuration/types'; import { DEFAULT_CONFIG } from '../config/configSchema'; +import { mergeServerChange } from '../utils/configPatch'; +import { folderKey } from '../utils/libraryLayouts'; export const CONFIG_UPDATED_EVENT = 'config-updated'; +/** + * A field the server saved outside the Settings form (detail: the saved + * fields). The saved copy takes the field as is; the draft takes only what + * the server changed since that copy, so an open Settings page keeps its + * unsaved edits, including edits to the same list, and its next save carries + * the server's change. + */ +export const CONFIG_PATCHED_EVENT = 'config-patched'; + +type PlexMapping = ConfigState['plexSubfolderLibraryMappings'][number]; +const mappingKey = (mapping: PlexMapping) => folderKey(mapping.subfolder); + +// The draft after a server-side change: keyed lists are merged entry by entry +// against the saved copy they were edited from; other fields are replaced. +function patchDraft(draft: ConfigState, baseline: ConfigState | null, patch: Partial<ConfigState>): ConfigState { + const next = { ...draft, ...patch }; + if (patch.plexSubfolderLibraryMappings) { + next.plexSubfolderLibraryMappings = mergeServerChange( + draft.plexSubfolderLibraryMappings, + baseline?.plexSubfolderLibraryMappings, + patch.plexSubfolderLibraryMappings, + mappingKey + ); + } + return next; +} interface UseConfigResult { config: ConfigState; @@ -20,6 +48,9 @@ interface UseConfigResult { export function useConfig(token: string | null): UseConfigResult { const [config, setConfig] = useState<ConfigState>(DEFAULT_CONFIG); const [initialConfig, setInitialConfig] = useState<ConfigState | null>(null); + // The saved copy as the patch handler sees it (state updaters can't read each other). + const initialConfigRef = useRef<ConfigState | null>(null); + initialConfigRef.current = initialConfig; const [isPlatformManaged, setIsPlatformManaged] = useState<PlatformManagedState>({ plexUrl: false, authEnabled: true, @@ -126,10 +157,20 @@ export function useConfig(token: string | null): UseConfigResult { } }; + const handleConfigPatched = (event: Event) => { + const patch = (event as CustomEvent<Partial<ConfigState> | undefined>).detail; + if (!patch) return; + const baseline = initialConfigRef.current; + setConfig((prev) => patchDraft(prev, baseline, patch)); + setInitialConfig((prev) => (prev ? { ...prev, ...patch } : prev)); + }; + window.addEventListener(CONFIG_UPDATED_EVENT, handleConfigUpdated); + window.addEventListener(CONFIG_PATCHED_EVENT, handleConfigPatched); return () => { window.removeEventListener(CONFIG_UPDATED_EVENT, handleConfigUpdated); + window.removeEventListener(CONFIG_PATCHED_EVENT, handleConfigPatched); }; }, [fetchConfig]); diff --git a/client/src/hooks/useLibraryCheck.ts b/client/src/hooks/useLibraryCheck.ts new file mode 100644 index 000000000..ea2bd26c0 --- /dev/null +++ b/client/src/hooks/useLibraryCheck.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { LibraryCheckResponse } from '../types/libraryCheck'; +import type { LibraryLayout } from '../types/tvShows'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from './useLibraryFolders'; +import { CONFIG_PATCHED_EVENT } from './useConfig'; +import type { ConfigState } from '../components/Configuration/types'; + +const CHECK_FAILED_MESSAGE = 'Could not check the media server libraries'; +const MAPPING_FAILED_MESSAGE = 'Could not save the Plex library mapping'; + +export interface UseLibraryCheckOptions { + /** Only these library folders ('' = main folder); every folder when omitted */ + folders?: string[] | null; + /** Check the given folders as this layout instead of their saved one (a preview of a layout change) */ + layout?: LibraryLayout | null; + /** Skip the check (e.g. until the folders are known) */ + enabled?: boolean; +} + +export interface UseLibraryCheckResult { + data: LibraryCheckResponse | null; + loading: boolean; + error: string | null; + refetch: () => Promise<void>; + /** Map a TV subfolder to the Plex library that holds it, then check again; throws with the server's message */ + applyPlexMapping: (folder: string, libraryId: string) => Promise<void>; +} + +/** PUT /api/library-folders/plex-mapping */ +interface PlexMappingResponse { + mappedLibraryId: string; + plexSubfolderLibraryMappings: ConfigState['plexSubfolderLibraryMappings']; +} + +function serverMessage(err: unknown, fallback: string): string { + if (axios.isAxiosError(err)) { + const data = err.response?.data as { error?: string } | undefined; + if (data?.error) return data.error; + } + return fallback; +} + +/** Which media server libraries hold each library folder, and what works against Youtarr there. */ +export function useLibraryCheck( + token: string | null, + { folders = null, layout = null, enabled = true }: UseLibraryCheckOptions = {} +): UseLibraryCheckResult { + const [data, setData] = useState<LibraryCheckResponse | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const requestSeq = useRef(0); + const foldersKey = folders ? JSON.stringify(folders) : null; + // A report is about the folders it was asked for: a new set starts from nothing. + const reportedKey = useRef(foldersKey); + + const fetchCheck = useCallback(async () => { + if (reportedKey.current !== foldersKey) { + reportedKey.current = foldersKey; + setData(null); + } + if (!token || !enabled) { + setLoading(false); + return; + } + const seq = ++requestSeq.current; + setLoading(true); + setError(null); + const params = new URLSearchParams(); + if (foldersKey) (JSON.parse(foldersKey) as string[]).forEach((folder) => params.append('folder', folder)); + if (foldersKey && layout) params.set('layout', layout); + try { + const response = await axios.get<LibraryCheckResponse>('/api/library-folders/check', { + headers: { 'x-access-token': token }, + params, + }); + if (seq === requestSeq.current) setData(response.data); + } catch (err: unknown) { + if (seq === requestSeq.current) setError(serverMessage(err, CHECK_FAILED_MESSAGE)); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [token, enabled, foldersKey, layout]); + + const applyPlexMapping = useCallback(async (folder: string, libraryId: string) => { + if (!token) return; + let saved: PlexMappingResponse; + try { + const response = await axios.put<PlexMappingResponse>( + '/api/library-folders/plex-mapping', + { folder, libraryId }, + { headers: { 'x-access-token': token } } + ); + saved = response.data; + } catch (err: unknown) { + throw new Error(serverMessage(err, MAPPING_FAILED_MESSAGE)); + } + // The mappings live in the config, which an open Settings page keeps a + // copy of (and saves whole): hand it the saved list rather than reloading + // it over the page's unsaved edits. + const patch: Partial<ConfigState> = { plexSubfolderLibraryMappings: saved.plexSubfolderLibraryMappings }; + window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: patch })); + await fetchCheck(); + }, [token, fetchCheck]); + + useEffect(() => { + fetchCheck(); + return () => { + requestSeq.current += 1; + }; + }, [fetchCheck]); + + useEffect(() => { + const handler = () => { fetchCheck(); }; + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + return () => window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + }, [fetchCheck]); + + return { data, loading, error, refetch: fetchCheck, applyPlexMapping }; +} + +export default useLibraryCheck; diff --git a/client/src/types/libraryCheck.ts b/client/src/types/libraryCheck.ts new file mode 100644 index 000000000..5ced9e267 --- /dev/null +++ b/client/src/types/libraryCheck.ts @@ -0,0 +1,61 @@ +import type { LibraryLayout } from './tvShows'; + +export type MediaServerType = 'plex' | 'jellyfin' | 'emby'; + +/** How a server library relates to a folder: points at it, at a parent folder, or inside it. */ +export type LibraryRelation = 'exact' | 'covers' | 'inside'; + +export type LibraryCheckStatus = 'ok' | 'warning' | 'missing' | 'unreachable'; + +export interface LibraryCheckLibrary { + id: string; + name: string; + type: 'videos' | 'tv' | 'mixed' | 'music' | 'other'; + /** The library's folder, as the server sees it */ + location: string; + relation: LibraryRelation; +} + +export interface LibraryCheckIssue { + code: string; + message: string; + libraryId?: string; +} + +export interface PlexMappingState { + /** The library new downloads in this folder refresh, when a mapping exists */ + mappedLibraryId: string | null; + /** The one Plex TV library that holds the folder */ + suggestedLibraryId: string | null; +} + +export interface LibraryCheckServerReport { + serverType: MediaServerType; + status: LibraryCheckStatus; + libraries: LibraryCheckLibrary[]; + issues: LibraryCheckIssue[]; + /** Plex only, TV subfolders only */ + plexMapping?: PlexMappingState; +} + +export interface LibraryCheckFolder { + /** '' for the main folder */ + name: string; + layout: LibraryLayout; + hasFiles: boolean; + channels: number; + servers: LibraryCheckServerReport[]; +} + +export interface LibraryCheckServer { + serverType: MediaServerType; + name: string; + reachable: boolean; + error: string | null; +} + +/** GET /api/library-folders/check */ +export interface LibraryCheckResponse { + servers: LibraryCheckServer[]; + folders: LibraryCheckFolder[]; +} diff --git a/client/src/types/reorganize.ts b/client/src/types/reorganize.ts index 463dce592..8df0f6058 100644 --- a/client/src/types/reorganize.ts +++ b/client/src/types/reorganize.ts @@ -73,6 +73,8 @@ export interface ReorganizePreview { change: ReorganizeChange & { label: string }; totals: ReorganizeTotals; shows: ReorganizePreviewShow[]; + /** The TV library folders videos move into ('' = main folder) */ + tvFolders?: string[]; /** The first 200 moves */ items: ReorganizePreviewItem[]; /** The first 200 videos that can't move or need attention */ diff --git a/client/src/utils/__tests__/configPatch.test.ts b/client/src/utils/__tests__/configPatch.test.ts new file mode 100644 index 000000000..c55d2fcd3 --- /dev/null +++ b/client/src/utils/__tests__/configPatch.test.ts @@ -0,0 +1,46 @@ +import { mergeServerChange } from '../configPatch'; + +interface Mapping { subfolder: string | null; libraryId: string } + +const keyOf = (mapping: Mapping) => (mapping.subfolder || '').toLowerCase(); +const kids: Mapping = { subfolder: 'Kids', libraryId: '12' }; +const tv: Mapping = { subfolder: 'TV', libraryId: '41' }; +const music: Mapping = { subfolder: 'Music', libraryId: '40' }; + +describe('mergeServerChange', () => { + test('adds what the server added, after the draft\'s own entries', () => { + expect(mergeServerChange([kids], [kids], [kids, tv], keyOf)).toEqual([kids, tv]); + }); + + test('keeps a pending removal while taking the server\'s addition', () => { + // The user removed Kids but has not saved; the server added TV meanwhile. + expect(mergeServerChange([], [kids], [kids, tv], keyOf)).toEqual([tv]); + }); + + test('keeps a pending edit of an entry the server did not touch', () => { + const edited: Mapping = { subfolder: 'Kids', libraryId: '99' }; + expect(mergeServerChange([edited], [kids], [kids, tv], keyOf)).toEqual([edited, tv]); + }); + + test('keeps a pending addition the server does not know yet', () => { + expect(mergeServerChange([kids, music], [kids], [kids, tv], keyOf)).toEqual([kids, music, tv]); + }); + + test('applies a change the server made to an entry', () => { + const moved: Mapping = { subfolder: 'Kids', libraryId: '13' }; + expect(mergeServerChange([kids], [kids], [moved], keyOf)).toEqual([moved]); + }); + + test('applies a removal the server made', () => { + expect(mergeServerChange([kids, tv], [kids, tv], [tv], keyOf)).toEqual([tv]); + }); + + test('matches entries by key, so a re-cased name replaces rather than duplicates', () => { + const lower: Mapping = { subfolder: 'kids', libraryId: '12' }; + expect(mergeServerChange([kids], [kids], [lower, tv], keyOf)).toEqual([lower, tv]); + }); + + test('takes the saved list as is when there is no baseline yet', () => { + expect(mergeServerChange([music], null, [kids, tv], keyOf)).toEqual([kids, tv]); + }); +}); diff --git a/client/src/utils/__tests__/libraryLayouts.test.ts b/client/src/utils/__tests__/libraryLayouts.test.ts index f51589fc5..6a4901167 100644 --- a/client/src/utils/__tests__/libraryLayouts.test.ts +++ b/client/src/utils/__tests__/libraryLayouts.test.ts @@ -1,4 +1,5 @@ import { + folderKey, effectiveLibraryFolder, buildLayoutResolver, libraryFolderLabel, @@ -46,4 +47,8 @@ describe('libraryLayouts utils', () => { test('resolves everything to videos before folders load', () => { expect(VIDEOS_EVERYWHERE('TV')).toBe('videos'); }); + + test('compares folder names ignoring case and surrounding spaces', () => { + expect([folderKey(' TV Shows '), folderKey(''), folderKey(null)]).toEqual(['tv shows', '', '']); + }); }); diff --git a/client/src/utils/configPatch.ts b/client/src/utils/configPatch.ts new file mode 100644 index 000000000..fd41c247a --- /dev/null +++ b/client/src/utils/configPatch.ts @@ -0,0 +1,36 @@ +import isEqual from 'lodash/isEqual'; + +/** + * Brings a server-side change to a list of keyed entries into an unsaved + * draft of that list without losing the draft's own edits. + * + * `baseline` is the last saved list the draft was edited from and `saved` the + * list the server holds now. Entries the server added or changed since the + * baseline are put into the draft (replacing the draft's entry of that key), + * entries the server removed are taken out, and everything else in the draft, + * including pending additions, removals and edits of other keys, is kept. + * Without a baseline the saved list is taken as is. + */ +export function mergeServerChange<T>( + draft: T[], + baseline: T[] | null | undefined, + saved: T[], + keyOf: (entry: T) => string +): T[] { + if (!baseline) return saved; + const baselineByKey = new Map(baseline.map((entry) => [keyOf(entry), entry])); + const savedKeys = new Set(saved.map(keyOf)); + const changed = new Map(saved + .filter((entry) => !isEqual(baselineByKey.get(keyOf(entry)), entry)) + .map((entry) => [keyOf(entry), entry])); + const removed = new Set([...baselineByKey.keys()].filter((key) => !savedKeys.has(key))); + + const merged = draft + .filter((entry) => !removed.has(keyOf(entry))) + .map((entry) => changed.get(keyOf(entry)) ?? entry); + const present = new Set(merged.map(keyOf)); + for (const [key, entry] of changed) { + if (!present.has(key)) merged.push(entry); + } + return merged; +} diff --git a/client/src/utils/libraryLayouts.ts b/client/src/utils/libraryLayouts.ts index e759e1eeb..69921de50 100644 --- a/client/src/utils/libraryLayouts.ts +++ b/client/src/utils/libraryLayouts.ts @@ -26,6 +26,11 @@ export function buildLayoutResolver(folders: LibraryFolder[]): LayoutResolver { return (libraryFolder) => layouts.get((libraryFolder || '').trim().toLowerCase()) || 'videos'; } +/** Comparison key for a library folder name ('' = main folder), ignoring case like the server. */ +export function folderKey(name: string | null | undefined): string { + return (name || '').trim().toLowerCase(); +} + /** "Main folder" or "__Name". */ export function libraryFolderLabel(name: string): string { return name ? `__${name}` : 'Main folder'; diff --git a/docs/CONFIG.md b/docs/CONFIG.md index adc4e2f2e..1c70893e6 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -171,7 +171,7 @@ Youtarr must be running at the scheduled time; missed occurrences are not replay - **Default**: `"videos"` - **Options**: `"videos"`, `"tv"` - **Description**: Layout of the main downloads folder, for files saved directly in it rather than in a `__subfolder`. `"videos"` saves each video movie-style, as before. `"tv"` saves channels as TV shows (`<show>/Season NN/SxxEyy - Title [id].ext`) for a TV-type media server library. Each `__subfolder` has its own layout, set in **Settings > Core > File Structure > Library folders**. -- **Note**: Changed only through the Library folders list, which refuses the change while the folder holds downloaded files or a download is running. A Settings save keeps the stored value. Switching the main folder to TV writes a `.plexignore` containing `__*/*` to the main folder, so a Plex TV library pointed there skips the subfolders. +- **Note**: Changed only through the Library folders list. When the main folder holds downloaded videos, the change moves them into the other layout after you review the move (the reorganize); a direct change is refused while a download runs. A Settings save keeps the stored value. Switching the main folder to TV writes a `.plexignore` containing `__*/*` to the main folder, so a Plex TV library pointed there skips the subfolders. - **TV folders are video-only**: TV library scanners skip audio files. A channel or playlist whose download type is MP3 cannot be saved to a TV folder, a folder with MP3 channels or playlists cannot switch to TV, and a download request that names a TV folder together with an MP3 type is refused. When the destination is only known per video (a pasted URL whose channel saves to a TV folder, an MP3 playlist with a video from such a channel), the MP3 type is downgraded to video for that video, so the episode is saved as video; an MP3 playlist's such videos therefore have no audio file and are left out of its music playlist sync (`unsyncable_count`). ### Video Filename Template @@ -242,6 +242,7 @@ Youtarr must be running at the scheduled time; missed occurrences are not replay ] ``` - **Fallback**: Any subfolder not listed here will fall back to `plexYoutubeLibraryId`. +- **TV folders**: Youtarr adds the entry for a TV subfolder when exactly one Plex TV Shows library holds it: automatically in Channel Settings -> TV Show, or with **Refresh this library** in the library check under Settings -> Core -> File Structure. It never replaces an existing entry. ### Plex IP - **Config Key**: `plexIP` @@ -338,7 +339,7 @@ These fields work like the Jellyfin fields above, with `emby*` names. They're re | `embyWatchStatusAllUsers` | `boolean` | `true` | Sync watch status for every Emby user. When `false`, only the configured `embyUserId`. | | `watchStatusWatchedRule` | `string` | `"any"` | When a video counts as "Watched" in listings: `"any"` (any synced user watched it) or `"primary"` (only the Plex owner / configured Jellyfin/Emby user). | -Sync is one-way (server -> Youtarr). Non-owner Plex users come from the server's play history, which records plays but not in-progress positions: any play marks the video watched for that user. User names are stored in the `media_server_users` table so the video modal can show who watched what. The history pull is incremental via a durable cursor in the `watch_status_sync_cursors` table; deleting that table's `plex` row forces a full history re-scan on the next sync (useful after repairing a path mismatch that had prevented videos from matching). +Sync is one-way (server -> Youtarr), except that Youtarr restores watch state on the servers after it moves files between the Videos and TV layouts (see `watch_status_holds` in [DATABASE.md](DATABASE.md)). Non-owner Plex users come from the server's play history, which records plays but not in-progress positions: any play marks the video watched for that user. User names are stored in the `media_server_users` table so the video modal can show who watched what. The history pull is incremental via a durable cursor in the `watch_status_sync_cursors` table; deleting that table's `plex` row forces a full history re-scan on the next sync (useful after repairing a path mismatch that had prevented videos from matching). ## YouTube Data API (Optional) diff --git a/docs/MEDIA_SERVERS.md b/docs/MEDIA_SERVERS.md index d4d4a866b..25d8d24f7 100644 --- a/docs/MEDIA_SERVERS.md +++ b/docs/MEDIA_SERVERS.md @@ -38,6 +38,27 @@ Optional artwork, both off by default and enabled in the same Download Settings - **Video Fanart**: `<video name>-fanart.jpg` beside each video, from the video thumbnail. Some Plex clients (NVIDIA Shield, for example) use it as the background preview. - **Backdrop Images**: `backdrop.jpg` in each channel folder (from the channel banner) plus `<video name>-backdrop.jpg` beside each video, used by Emby and Jellyfin for background art. +## TV Shows + +A library folder (the main folder or a `__subfolder`) can use the **TV shows** layout instead of Videos. Youtarr then saves each channel in it as a show: year seasons, episodes numbered by upload time (`S2026E09281530` is September 28, 2026, 15:30 UTC), an episode NFO file for every video, and `tvshow.nfo` plus channel art in the show folder. Set a folder's layout under Settings -> Core -> File Structure -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**. See [TV folders](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders) for the layout and [Save Channels as TV Shows](USAGE_GUIDE.md#save-channels-as-tv-shows) for switching channels that already have downloads. + +Give each TV folder its own TV library, pointed at the folder itself: + +| Server | Library type | Settings | Guide | +|--------|--------------|----------|-------| +| **Plex** | TV Shows | Scanner Plex TV Series, agent **Plex NFO Series** (or Plex Personal Media), local assets on | [Plex](media-servers/plex.md#tv-shows) | +| **Jellyfin** | Shows | NFO saver off, metadata downloaders and image fetchers off | [Jellyfin](media-servers/jellyfin.md#tv-shows) | +| **Emby** | TV shows | NFO metadata reader on, NFO saver off, metadata downloaders and image fetchers off | [Emby](media-servers/emby.md#tv-shows) | +| **Kodi** | TV shows source | Local information only | [Kodi](media-servers/kodi.md#tv-shows) | + +Not Jellyfin's `Mixed Movies and Shows` or Emby's `Mixed Content`: those decide per folder whether it holds a movie or a series and behaved inconsistently with Youtarr's files in our testing, so the library check reports them on a TV folder as the wrong type. Use Movies (or Plex's Other Videos) libraries for Videos folders and Shows/TV Shows libraries for TV folders. + +**One library per folder.** Jellyfin and Emby show a folder in only one library: while another library includes your downloads folder, a TV library inside it stays empty. Plex shows the episodes in both libraries instead. Either way, once you use a TV folder, point your video libraries at the Videos folders one by one rather than at the downloads folder, and give channels saved directly in the downloads folder a subfolder. + +**The library check.** Youtarr reads your servers' libraries and shows, under each folder in Settings -> Core -> File Structure, which libraries hold it and what to fix: no library yet, the wrong library type, the Plex Series agent or a legacy Plex agent, a Jellyfin or Emby library that saves NFO files or looks items up online, another library that includes the same folder, or a missing Plex refresh mapping. Channel Settings -> **TV Show** shows the same for the channel's TV folder, and the review of a move shows it for the TV folders the videos move into. + +**Watch state.** Moving a video between a Videos folder and a TV folder makes it a new item on every server. Moving a show to another TV folder keeps its watch state on Plex (with Plex NFO Series) and Jellyfin; Emby keys watch state by file path. Wherever the state didn't carry over, Youtarr restores the played state and resume position once the server has scanned the moved files: for every Jellyfin and Emby user, and for the Plex server owner. + ## Multi-Library Organization Youtarr supports organizing content into separate libraries using subfolders: @@ -133,7 +154,7 @@ Youtarr can mirror subscribed YouTube playlists into Plex, Jellyfin, and Emby as ## Watch Status Sync -Youtarr can pull per-video, per-user watch status from Plex, Jellyfin, and Emby, show a Watched chip on its listing pages, and let you filter your library by watched state. The sync is one-way; nothing is written back to your servers. See [Track Watch Status from Media Servers](USAGE_GUIDE.md#track-watch-status-from-media-servers) for the workflow and the [Configuration Reference](CONFIG.md#watch-status-sync) for the config keys. +Youtarr can pull per-video, per-user watch status from Plex, Jellyfin, and Emby, show a Watched chip on its listing pages, and let you filter your library by watched state. Youtarr writes watch state back to a server only to restore it after Youtarr moves your files (see [TV Shows](#tv-shows)). See [Track Watch Status from Media Servers](USAGE_GUIDE.md#track-watch-status-from-media-servers) for the workflow and the [Configuration Reference](CONFIG.md#watch-status-sync) for the config keys. ## Quick Start Guides @@ -170,6 +191,8 @@ If you already have downloaded videos: | Library not updating | Check API key (Plex) or trigger manual scan | Plex | | Special characters broken | Ensure UTF-8 encoding, update media server | All | | Duplicate entries | Check for overlapping library paths | All | +| TV library is empty | Another library includes the TV folder; point it at your Videos folders instead | Jellyfin, Emby | +| Episodes show twice | Another library includes the TV folder (for example a library at the downloads folder) | Plex | ### Getting Help diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 18f32ff6d..e1ef1899f 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -14,6 +14,7 @@ This guide provides step-by-step instructions for common tasks in Youtarr. After - [Re-download Missing Videos](#re-download-missing-videos) - [Rescan Files on Disk](#rescan-files-on-disk) - [Organize Channels with Multi-Library Support](#organize-channels-with-multi-library-support) +- [Save Channels as TV Shows](#save-channels-as-tv-shows) - [Browse and Filter Channel Videos](#browse-and-filter-channel-videos) - [Find Videos on YouTube](#find-videos-on-youtube) - [Preview and Play Videos](#preview-and-play-videos) @@ -448,12 +449,49 @@ Create separate media server libraries for different content types (e.g., kids c - Library 1: `/path/to/downloads/__kids` - Library 2: `/path/to/downloads/__music` - Library 3: `/path/to/downloads` (for channels without a subfolder) + - Jellyfin and Emby show a folder in only one library, so a library at `/path/to/downloads` leaves libraries for its subfolders empty there; give every channel a subfolder instead. Plex shows those videos in both libraries. 4. **Apply restrictions and sharing** - Configure library-specific access controls in your media server - Set age ratings and content restrictions per library - Share specific libraries with specific users +## Save Channels as TV Shows + +Media servers show Youtarr's videos as movies. A **TV folder** shows them as TV shows instead: each channel is a show, each upload year a season, and each video an episode with its own NFO file. + +### Set up a TV folder + +- **From a channel**: open the channel page, click the settings icon (gear), open **TV Show** and click **TV show**. Without a TV folder yet, Youtarr asks for a name (default `TV Shows`, saved as `__TV Shows`), creates the folder and switches the channel to it. With several TV folders, you pick one. +- **From Settings**: under Settings -> Core -> File Structure -> **Library folders**, set a folder's layout to **TV shows**. Every channel that downloads to that folder becomes a show. + +Then add a TV library for the folder on your media server (see [Media Server Integration](MEDIA_SERVERS.md#tv-shows)). The **Media servers** box under Channel Settings -> **TV Show** shows, per server, whether a library holds the folder and what to fix. For Plex, Youtarr adds the folder's refresh mapping as soon as it finds the one TV Shows library that holds it. + +What changes for a TV channel: + +- Episodes go to `<TV folder>/<channel>/Season <year>/` and are named by upload time in UTC: `S2026E09281530 - Title [id].mp4` was uploaded on September 28 at 15:30. See [TV folders](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders). +- The download type is video only, and the file structure and channel playlist (.m3u) options don't apply. +- The channel page and Subscriptions show a **TV** chip, and videos show their episode code. +- If the default subfolder is a TV folder, a download from a channel you haven't subscribed to becomes a show of its own. + +### Switch a channel or folder that already has downloads + +Switching moves the downloaded files into the other layout, so Youtarr opens **Review the move** first. It lists: + +- every move, with the old and new path and the episode number; +- the shows it creates; +- anything it can't move, such as a file missing from disk or a file already at the destination; +- how many videos have watch state on each media server; +- media server library problems for the TV folders the videos move into. + +Click **Move N videos** to start. While the files move, a banner shows on every page, downloads wait in the queue, and the rescan and other maintenance tasks wait too. Files named by the Plex TV Series preset keep their episode numbers. Switching back to Videos names the files with your current filename template, and switching to TV again later brings back the same episode numbers. + +- **Blocked**: if a download or a sync is running, the review says so and enables the button once it finishes. +- **Videos not moved**: the result lists them with **Retry**. The channel's settings also say how many videos were not moved, with a **Review** link to the result. +- **Restarted**: a move interrupted by a restart resumes when Youtarr starts. + +The same review opens when you change the default subfolder (Settings -> Core) to a folder with the other layout while channels on the default have downloads. Save again afterwards to apply your other changes. + ## Browse and Filter Channel Videos Explore all videos available from your subscribed channels, even if you haven't downloaded them yet. This feature uses yt-dlp to fetch channel information directly from YouTube - no API key required. @@ -557,7 +595,7 @@ Click any thumbnail on the Videos page or a channel page to open a video detail ## Track Watch Status from Media Servers -If you've connected Plex, Jellyfin, or Emby, Youtarr can pull watch status from them: which videos have been played, how far through, and when. The sync is one-way; Youtarr only reads from your servers and never writes anything back. +If you've connected Plex, Jellyfin, or Emby, Youtarr can pull watch status from them: which videos have been played, how far through, and when. Youtarr only reads from your servers, except to restore watch state after it moves your files (see [Restore watch state after a move](#restore-watch-state-after-a-move)). ### How it works @@ -595,6 +633,12 @@ Youtarr doesn't decide this; it shows whatever your media servers report. All th On Emby and Jellyfin the same setting also controls resume: stop after the threshold and the title counts as fully played instead of resumable. If you finished a video and it isn't showing as watched in Youtarr, check this setting on the server you played it on, then run a sync. +### Restore watch state after a move + +When Youtarr moves a video's files (see [Save Channels as TV Shows](#save-channels-as-tv-shows)), a media server can see the moved file as a new, unwatched item. Youtarr keeps its own watched state for those videos while the servers catch up, and restores the played state and resume position on each server once it has scanned the moved files: for every Jellyfin and Emby user, and for the Plex server owner. It tries 1, 5 and 15 minutes after the move and after each sync. Plex (with the Plex NFO Series agent) and Jellyfin keep watch state on their own when a show moves to another TV folder; then nothing needs restoring. + +**Settings -> Watch Status** shows how many restores are pending. A restore that hasn't happened after 14 days shows as failed, with **Retry** (try again now) and **Dismiss** (stop protecting the old state). Other Plex accounts keep their history in Youtarr, but Plex itself loses it for moved videos. + ## Common tasks - Set a per-download override: When downloading manually, use the download/settings dialog to pick a rating or clear it (NR) for that specific download. diff --git a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md index e11664486..461ffe6b0 100644 --- a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md +++ b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md @@ -56,6 +56,39 @@ YouTube Downloads/ └── [videos] ``` +## TV Folders + +A library folder (the main folder or a `__subfolder`) can use the **TV shows** layout instead of Videos: set it under Settings -> Core -> File Structure -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**. Every channel that downloads to a TV folder is saved as a show: + +``` +YouTube Downloads/ +├── __TV Shows/ # A TV folder +│ └── Channel Name/ # The show folder +│ ├── tvshow.nfo # Show metadata (always written) +│ ├── poster.jpg # Channel poster +│ ├── backdrop.jpg # Optional; "Create backdrop images" +│ ├── Season 2025/ +│ │ └── S2025E12011500 - Title [id].mp4 +│ └── Season 2026/ +│ ├── S2026E09281530 - Title [id].mp4 # Video file +│ ├── S2026E09281530 - Title [id].nfo # Episode metadata (always written) +│ ├── S2026E09281530 - Title [id].jpg # Episode thumbnail +│ ├── S2026E09281530 - Title [id].[lang].srt +│ ├── S2026E09281530 - Title [id]-fanart.jpg # Optional; "Create video fanart files" +│ └── S2026E09281530 - Title [id]-backdrop.jpg # Optional; "Create backdrop images" +└── __Kids/ # A Videos folder, laid out as above +``` + +- **Seasons are years, episodes are upload times.** `S2026E09281530` is the video uploaded on September 28, 2026 at 15:30 UTC: the season is the year and the episode is the month, day, hour and minute, so episodes sort in upload order. Two uploads in the same minute get the next free number. Youtarr stores each number and never reassigns it, and a re-download replaces the episode's files under the same name. +- **The show folder name is fixed** when the show is created, from the channel name, so a later channel rename doesn't start a second folder. Two channels with the same name get `Name (channel ID)`. +- **No video folders and no channel `.m3u`**: TV folders are always flat inside season folders, and video-only (MP3 download types aren't offered for them). +- **Titles** in file names are cut to 64 bytes; the full title is in the episode NFO file and the embedded MP4 title (never prefixed with the channel name). +- **Deleting** an episode in Youtarr removes its files, then an empty season folder, then the show folder once it holds only show files. +- **Downloads from channels you haven't subscribed to** that land in a TV folder (a manual download, a playlist, or a TV default subfolder) each become a show of their own channel. +- **Main folder as TV**: when the main folder uses the TV shows layout, Youtarr writes a `.plexignore` containing `__*/*` there so a Plex TV library pointed at it skips your subfolders. Jellyfin and Emby can't skip them, so they'd show each `__subfolder` as an extra show. + +Switching a channel or a folder that already has downloads between Videos and TV shows moves its files into the other layout; you review the move first. See [Save Channels as TV Shows](USAGE_GUIDE.md#save-channels-as-tv-shows). + ## Layout For Channels with Flat File Structure (No Video Subfolders) You can use a flat file structure, where video files are placed directly in the channel folder instead of individual video subfolders. It only affects new downloads. There are three places to set it: diff --git a/docs/YOUTARR_VS_ALTERNATIVES.md b/docs/YOUTARR_VS_ALTERNATIVES.md index 8e7e166ad..5d4fbc3fa 100644 --- a/docs/YOUTARR_VS_ALTERNATIVES.md +++ b/docs/YOUTARR_VS_ALTERNATIVES.md @@ -72,7 +72,7 @@ Legend: ✅ supported, ❌ not supported, ⚠️ partial/caveat, "unclear" = not | Retention / auto-prune | ✅ Age, free-space, and watched-based rules with dry-run; per-video and per-channel "protection" shields; global and per-channel keep-recent guards; Apprise notifications on auto-removal | ✅ Per-source "auto-delete after N days" | ✅ Global + per-channel | | **Playback & viewing** | | | | | In-browser video player | ✅ Detail modal + streaming | ❌ Intentionally out of scope | ✅ Full HTML5 player with SponsorBlock skip, keyboard shortcuts, Cast | -| Watched-state tracking | ✅ Synced from Plex/Jellyfin/Emby for all server users; Watched chips + filters on every listing page; one-way pull, needs a connected media server (the built-in player doesn't track) | ❌ | ✅ With threshold + "Continue watching" | +| Watched-state tracking | ✅ Synced from Plex/Jellyfin/Emby for all server users; Watched chips + filters on every listing page; pulled from the servers (and restored on them after Youtarr moves files), needs a connected media server (the built-in player doesn't track) | ❌ | ✅ With threshold + "Continue watching" | | Full-text search across transcripts | ❌ | ❌ | ✅ Flagship feature via Elasticsearch | | Search across comments | ❌ | ❌ | ⚠️ Comments archived; dedicated comment-search UI still on roadmap | | **Integrations** | | | | diff --git a/docs/media-servers/emby.md b/docs/media-servers/emby.md index c0c3f56f9..45ec1958e 100644 --- a/docs/media-servers/emby.md +++ b/docs/media-servers/emby.md @@ -5,6 +5,7 @@ Complete guide for integrating Youtarr with Emby Media Server. ## Table of Contents - [Overview](#overview) - [Library Setup](#library-setup) +- [TV Shows](#tv-shows) - [Metadata Configuration](#metadata-configuration) - [Native Playlist Sync](#native-playlist-sync) - [Channel Playlist Files (.m3u)](#channel-playlist-files-m3u) @@ -20,6 +21,7 @@ Youtarr provides comprehensive Emby support through: - Optional channel and video backdrop art - Embedded MP4 metadata - Multi-library support for content organization +- TV show folders with season folders and episode NFO files (see [TV Shows](#tv-shows)) - Compatible folder structure - Native playlist sync: subscribed YouTube playlists appear as Emby playlists (see [Native Playlist Sync](#native-playlist-sync)) @@ -39,7 +41,9 @@ Youtarr writes each video as a standalone "movie" with its own NFO metadata, so - **`Movies` (current recommendation)**: the most reliable option. Every video displays as a movie with full metadata and artwork. Limitation: Emby will NOT automatically import Youtarr's optional per-channel `.m3u` playlist files as playlists; that only happens in Mixed Content libraries. See [Channel Playlist Files (.m3u)](#channel-playlist-files-m3u). - **`Mixed Content`**: automatically imports the per-channel `.m3u` files as Emby playlists, but [Emby's own documentation](https://emby.media/support/articles/Library-Setup.html) notes that "support for mixed content is limited", and its TV-detection heuristics can misclassify channel content as TV series: video titles that look episode-like ("Season 3", "Episode 12") can be picked up as episodes and display with wrong metadata. This tends to work on smaller libraries and break as the library grows, since more titles means more chances for a false match. -- **`TV Shows`**: not currently supported. Writing videos and metadata in a way that is compatible with TV Shows libraries is on our roadmap but is not supported yet. +- **`TV Shows`**: for Youtarr TV folders only, where channels are saved as TV shows. See [TV Shows](#tv-shows). + +We recommend against `Mixed Content` for any Youtarr folder, Videos or TV: in our testing it behaved inconsistently with Youtarr's files, and Youtarr's library check reports a Mixed library on a TV folder as the wrong type. ### Step 2: Add Media Folders @@ -49,29 +53,66 @@ Configure folder settings: 3. For specific content types, use subfolders associated to different libraries: - `/path/to/youtube/__kids` - `/path/to/youtube/__music` - - `/path/to/youtube` (all content) + + Emby shows a folder in only one library, so don't add a library at `/path/to/youtube` next to subfolder libraries: the subfolder libraries would stay empty. One library per folder; see [One library per folder](#one-library-per-folder). ### Step 3: Configure Library Settings -In the library configuration: +In the library's settings (Emby 4.10 labels; everything not listed can keep its default): + +| Setting | Value | Why | +|---------|-------|-----| +| Prefer embedded titles over filenames | On | Only used when a video has no NFO file (NFO files turned off in Youtarr): the MP4's embedded title beats a title parsed from the file name | +| Ignore files containing the word sample ... less than (MB) | 0 | A video whose title contains "sample" and is under 300 MB would otherwise be hidden | +| Enable multi-part items | Off | Emby groups files named "part 1", "part 2" into one item; YouTube titles use those words all the time, and Youtarr never produces multi-part files | +| Metadata Readers: Nfo | On | Youtarr's NFO file for every video | +| Movie Metadata Downloaders | All off | An online match can replace a video's title, plot and artwork with an unrelated movie's | +| After the initial import, automatically refresh metadata from the internet | Never | | +| Metadata Savers: Nfo | Off | Emby would rewrite Youtarr's NFO files (see the warning below) | +| Movie Image Fetchers | All internet fetchers off | Youtarr writes `poster.jpg` per channel and a thumbnail per video; Emby reads local images on its own. **Image Capture** can stay on: it only grabs a frame when no thumbnail exists | +| Save artwork into media folders | Off | | +| Generate chapters for videos that don't contain embedded chapter information | Off | Youtarr's files carry no chapter markers, so Emby would add one every 5 minutes to every video and extract a frame for each at scan time | +| Subtitle Downloaders / Automatic Subtitle Downloads | Off / no download languages | OpenSubtitles has nothing for YouTube videos. Youtarr's own subtitle option (Settings -> Core) saves YouTube captions as an `.srt` next to the video, which Emby picks up | -**Metadata downloaders**: -1. **NFO** (enable and move to top) -2. Disable all internet providers (TheMovieDb, etc.) +> **Warning**: Do NOT enable Emby's NFO metadata saver. Youtarr generates and maintains the `.nfo` file for every video it downloads. If the saver is enabled, Emby will update and overwrite those files with its own data (for example, incorrectly guessed season/episode tags), which can cause problems for your library. -**Metadata savers**: -- **Disable**: Nfo ("Save metadata to NFO") +## TV Shows -> **Warning**: Do NOT enable Emby's NFO metadata saver. Youtarr generates and maintains the `.nfo` file for every video it downloads. If the saver is enabled, Emby will update and overwrite those files with its own data (for example, incorrectly guessed season/episode tags), which can cause problems for your library. +Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> Core -> File Structure -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo`, `poster.jpg` and (when enabled) `backdrop.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC, so Emby lists episodes in upload order with large numbers (`S2026:E9281530` is September 28 at 15:30). + +### Library setup + +Add a library with content type **TV shows** for each TV folder and point it at the folder itself, for example `Q:\Youtube\__TV Shows`. Use the TV shows type, not `Mixed Content`: a Mixed library decides per folder whether it holds a movie or a series, and in our testing it was inconsistent with Youtarr's files. In the library's settings (Emby 4.10 labels; everything not listed can keep its default): + +| Setting | Value | Why | +|---------|-------|-----| +| Prefer embedded titles over filenames | Off | Episode titles come from Youtarr's NFO files; for a file without one, the file name beats the MP4's `Channel - Title` tag | +| Ignore files containing the word sample ... less than (MB) | 0 | A video whose title contains "sample" and is under 300 MB would otherwise be hidden | +| Automatically merge series that are spread across multiple folders | Off | Emby merges series by name, and two channels can share one | +| Metadata Readers: Nfo | On | Youtarr's episode and `tvshow.nfo` files | +| Series, Season and Episode Metadata Downloaders | All off | An online match can replace a channel's titles and numbers with an unrelated show's | +| After the initial import, automatically refresh metadata from the internet | Never | | +| Metadata Savers: Nfo | Off | Emby would rewrite Youtarr's NFO files (see the warning above) | +| Series, Season and Episode Image Fetchers | All internet fetchers off | Youtarr writes `poster.jpg`, `backdrop.jpg` and an episode thumbnail; Emby reads local images on its own. Episode **Image Capture** can stay on: it only grabs a frame when no thumbnail exists | +| Save artwork into media folders | Off | | +| Generate chapters for videos that don't contain embedded chapter information | Off | Youtarr's files carry no chapter markers, so Emby would add one every 5 minutes to every episode and extract a frame for each at scan time | +| Subtitle Downloaders / Automatic Subtitle Downloads | Off / no download languages | OpenSubtitles has nothing for YouTube videos. Youtarr's own subtitle option (Settings -> Core) saves YouTube captions as an `.srt` next to the video, which Emby picks up | +| Enable support for .plexignore files as an alias to .embyignore | Off | Youtarr writes a `.plexignore` only in a TV main folder, for Plex; a library at a subfolder never sees one | + +Youtarr's library check (below) reports the NFO saver and the online metadata downloaders when they are on. + +### One library per folder -**Image fetchers**: -- **Local Images** (enable) -- Disable all internet image providers +Emby shows a folder in only one library, so a TV Shows library for `__TV Shows` stays empty while another library includes your downloads folder. Once you use a TV folder: -**Advanced Settings**: -- **Save artwork and metadata into media folders**: No (see the NFO saver warning above) -- **Prefer embedded metadata**: Yes -- **Enable real-time monitoring**: Optional +- Point your Movies library at your Videos folders one by one, not at the downloads folder. +- Channels saved directly in the downloads folder (no subfolder) can only be reached through a library at the downloads folder, which would include the TV folder. Give those channels a subfolder first (Channel Settings -> Subfolder). + +Youtarr's library check points this out: Settings -> Core -> File Structure lists, under each folder, the libraries that hold it and anything to fix (also a library of the wrong type, the NFO saver, or internet metadata providers). Channel Settings -> **TV Show** shows the same for the channel's TV folder. + +### Watch state + +Emby keys watch state by file path, so an episode Youtarr moves (between a Videos folder and a TV folder, or to another TV folder) shows up as a new, unwatched item. Youtarr restores the played state and resume position for every Emby user once Emby has scanned the moved file. ## Metadata Configuration @@ -125,7 +166,7 @@ The library and metadata setup above is all you need for downloaded videos to sh Once connected, open a playlist in Youtarr and turn on its Emby sync chip. See [Media Server Playlists](../MEDIA_SERVER_PLAYLISTS.md) for how syncing, ordering, and updates work. -Connecting Emby also enables watch status sync: Youtarr periodically pulls per-video watch state (played, percent watched, last watched) for every user on the server and shows it as Watched chips and filters on its listing pages. It's one-way; Youtarr never marks anything watched on Emby. Emby decides when a video counts as played: edit the library and set **Max resume percentage**; stop after that point and the title counts as fully played. Settings live under **Settings -> Watch Status**; see [Track Watch Status from Media Servers](../USAGE_GUIDE.md#track-watch-status-from-media-servers). +Connecting Emby also enables watch status sync: Youtarr periodically pulls per-video watch state (played, percent watched, last watched) for every user on the server and shows it as Watched chips and filters on its listing pages. Youtarr writes to Emby only to restore watch state after it moves your files (see [Watch state](#watch-state)). Emby decides when a video counts as played: edit the library and set **Max resume percentage**; stop after that point and the title counts as fully played. Settings live under **Settings -> Watch Status**; see [Track Watch Status from Media Servers](../USAGE_GUIDE.md#track-watch-status-from-media-servers). ### Visibility @@ -153,9 +194,11 @@ Create content-specific libraries: "YouTube - Kids" → /youtube/__kids "YouTube - Music" → /youtube/__music "YouTube - Education" → /youtube/__education - "YouTube - General" → /youtube + "YouTube - General" → /youtube/__news, /youtube/__gaming ``` + Emby shows a folder in only one library, so don't add the downloads folder itself to a library when other libraries use its subfolders: they would stay empty. Channels saved directly in the downloads folder need a subfolder once you split libraries. + 2. **Configure Each Library**: - Kids: Parental controls enabled - Music: Music visualization options @@ -273,6 +316,14 @@ Configure in Advanced settings: 3. For Docker: Verify volume permissions 4. Check SELinux/AppArmor if applicable +### TV Library Is Empty + +**Problem**: A TV Shows library for a Youtarr TV folder shows nothing, while the episodes appear in another library + +**Cause**: Another library includes the TV folder (usually a library at the downloads folder). Emby shows a folder in one library only. + +**Solution**: Point the other library at your Videos folders one by one instead of the downloads folder, then scan. See [One library per folder](#one-library-per-folder). + ### Duplicate Entries **Problem**: Videos appear multiple times diff --git a/docs/media-servers/jellyfin.md b/docs/media-servers/jellyfin.md index ef61acec0..a9ba3bf7b 100644 --- a/docs/media-servers/jellyfin.md +++ b/docs/media-servers/jellyfin.md @@ -5,6 +5,7 @@ Complete guide for integrating Youtarr with Jellyfin Media Server. ## Table of Contents - [Overview](#overview) - [Library Setup](#library-setup) +- [TV Shows](#tv-shows) - [Metadata Configuration](#metadata-configuration) - [Native Playlist Sync](#native-playlist-sync) - [Channel Playlist Files (.m3u)](#channel-playlist-files-m3u) @@ -19,6 +20,7 @@ Youtarr provides full Jellyfin support through: - Optional channel and video backdrop art - Proper folder structure for organization - Multi-library support for content separation +- TV show folders with season folders and episode NFO files (see [TV Shows](#tv-shows)) - Real-time monitoring capability - Native playlist sync: subscribed YouTube playlists appear as Jellyfin playlists (see [Native Playlist Sync](#native-playlist-sync)) @@ -38,7 +40,9 @@ Youtarr writes each video as a standalone "movie" with its own NFO metadata, so - **`Movies` (current recommendation)**: the most reliable option. Every video displays as a movie with full metadata and artwork. Limitation: Jellyfin will NOT automatically import Youtarr's optional per-channel `.m3u` playlist files; Jellyfin only imports playlist files from libraries whose content type is Mixed or Music. See [Channel Playlist Files (.m3u)](#channel-playlist-files-m3u). - **`Mixed Movies and Shows`**: automatically imports the per-channel `.m3u` files as Jellyfin playlists, but comes with real risks. [Jellyfin's own documentation](https://jellyfin.org/docs/general/server/media/mixed-movies-and-shows/) says this library type "is broken and deprecated" and recommends against using it, and its TV-detection heuristics can misclassify channel content as TV series: video titles that look episode-like ("Season 3", "Episode 12") or folder names starting with digits can be picked up as episodes, and a single misdetected video folder can flip an entire channel folder into displaying as a series. This tends to work on smaller libraries and break as the library grows, since more titles means more chances for a false match. -- **`Shows`**: not currently supported. Writing videos and metadata in a way that is compatible with Shows-type libraries is on our roadmap but is not supported yet. +- **`Shows`**: for Youtarr TV folders only, where channels are saved as TV shows. See [TV Shows](#tv-shows). + +We recommend against `Mixed Movies and Shows` for any Youtarr folder, Videos or TV: in our testing it behaved inconsistently with Youtarr's files, and Youtarr's library check reports a Mixed library on a TV folder as the wrong type. ### Step 2: Add Folders @@ -48,30 +52,62 @@ Add your Youtarr download directory: 3. For subfolders, add specific paths: - Kids: `/path/to/youtube/__kids` - Music: `/path/to/youtube/__music` - - All: `/path/to/youtube` + + Jellyfin shows a folder in only one library, so don't add a library at `/path/to/youtube` next to subfolder libraries: the subfolder libraries would stay empty. One library per folder; see [One library per folder](#one-library-per-folder). ### Step 3: Configure Metadata Sources -In the library settings: +In the library's settings (everything not listed can keep its default): -**Top level library settings** -1. **Preferred download language**: Your language -2. **Country**: Your country -3. **Prefer embedded titles over filenames**: Set to enabled -4. **Enable real time monitoring**: Recommended as enabled -5. **Automatically refresh metadata**: Never (metadata is all embedded/included via `.nfo`) +| Setting | Value | Why | +|---------|-------|-----| +| Prefer embedded titles over filenames | On | Only used when a video has no NFO file (NFO files turned off in Youtarr): the MP4's embedded title beats a title parsed from the file name. Jellyfin reads NFO files without a setting | +| Metadata downloaders (Movies) | All off | An online match can replace a video's title, plot and artwork with an unrelated movie's | +| Automatically refresh metadata from the internet | Never | | +| Metadata savers: Nfo | Off | Jellyfin would rewrite Youtarr's NFO files (see the warning below) | +| Image fetchers (Movies) | TheMovieDb and The Open Movie Database off | Youtarr writes `poster.jpg` per channel and a thumbnail per video; Jellyfin reads local images on its own. **Embedded Image Extractor** and **Screen Grabber** can stay on: they only run when no image exists | +| Save artwork into media folders | Off | | +| Trickplay and Chapter Images | Off (the defaults) | Slow and disk-hungry; Youtarr's files carry no chapter markers | -**Metadata downloaders** (in order): -1. Disable **ALL** metadata downloaders since metadata is included! +> **Warning**: Do NOT enable the Nfo metadata saver. Youtarr generates and maintains the `.nfo` file for every video it downloads. If the saver is enabled, Jellyfin will update and overwrite those files with its own data, which can cause problems for your library. -**Metadata savers**: -- **Disable**: Nfo +## TV Shows -> **Warning**: Do NOT enable the Nfo metadata saver. Youtarr generates and maintains the `.nfo` file for every video it downloads. If the saver is enabled, Jellyfin will update and overwrite those files with its own data, which can cause problems for your library. +Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> Core -> File Structure -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo`, `poster.jpg` and (when enabled) `backdrop.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC, so Jellyfin lists episodes in upload order with large numbers (`9281530. Title` is September 28 at 15:30). + +### Library setup + +Add a library with content type **Shows** for each TV folder and point it at the folder itself, for example `/path/to/youtube/__TV Shows`. Use the Shows type, not `Mixed Movies and Shows`: a Mixed library decides per folder whether it holds a movie or a series, and in our testing it was inconsistent with Youtarr's files. In the library's settings (everything not listed can keep its default): + +| Setting | Value | Why | +|---------|-------|-----| +| Prefer embedded titles over filenames | Off | Episode titles come from Youtarr's NFO files (Jellyfin reads NFO files without a setting) | +| Prefer embedded episode information over filenames | Off | Episode numbers come from the file names and NFO files, never from MP4 tags | +| Metadata downloaders (TV Shows), (Seasons) and (Episodes) | All off | An online match can replace a channel's titles and numbers with an unrelated show's | +| Automatically refresh metadata from the internet | Never | | +| Metadata savers: Nfo | Off | Jellyfin would rewrite Youtarr's NFO files (see the warning above) | +| Image fetchers (TV Shows), (Seasons) and (Episodes) | TheMovieDb and The Open Movie Database off | Youtarr writes `poster.jpg`, `backdrop.jpg` and an episode thumbnail; Jellyfin reads local images on its own. **Embedded Image Extractor** and **Screen Grabber** can stay on: they only run when no image exists | +| Save artwork into media folders | Off | | +| Trickplay and Chapter Images | Off (the defaults) | Slow and disk-hungry; Youtarr's files carry no chapter markers | + +Youtarr's library check (below) reports the Nfo saver and the online metadata downloaders when they are on. + +### One library per folder + +Jellyfin shows a folder in only one library. A library whose folder sits inside another library's folder is skipped (the Jellyfin log says `Found duplicate path`), so a Shows library for `__TV Shows` stays empty while another library includes your downloads folder. Once you use a TV folder: -**Image fetchers**: -- Disable all internet fetchers -- Local images will be used automatically +- Point your Movies library at your Videos folders one by one, not at the downloads folder. +- Channels saved directly in the downloads folder (no subfolder) can only be reached through a library at the downloads folder, which would include the TV folder. Give those channels a subfolder first (Channel Settings -> Subfolder). + +Youtarr's library check points this out: Settings -> Core -> File Structure lists, under each folder, the libraries that hold it and anything to fix (also a library of the wrong type, the Nfo saver, or online metadata downloaders). Channel Settings -> **TV Show** shows the same for the channel's TV folder. + +### Watch state + +Jellyfin keeps watch state when Youtarr moves a show's episodes to another TV folder: `tvshow.nfo` carries the channel ID as a custom ID, and Jellyfin keys episode watch state on the show's ID and the episode number. A video that moves between a Videos folder and a TV folder shows up as a new, unwatched item; Youtarr restores the played state and resume position for every Jellyfin user once Jellyfin has scanned the moved file. + +### Replace all metadata + +Prefer **Scan for new and updated files**. On Jellyfin 12.1, **Replace all metadata** on a show kept Youtarr's episode numbers, titles and air dates in our tests; Jellyfin 10.11 was not tested. ## Metadata Configuration @@ -116,7 +152,7 @@ The library and metadata setup above is all you need for downloaded videos to sh Once connected, open a playlist in Youtarr and turn on its Jellyfin sync chip. See [Media Server Playlists](../MEDIA_SERVER_PLAYLISTS.md) for how syncing, ordering, and updates work. -Connecting Jellyfin also enables watch status sync: Youtarr periodically pulls per-video watch state (played, percent watched, last watched) for every user on the server and shows it as Watched chips and filters on its listing pages. It's one-way; Youtarr never marks anything watched on Jellyfin. Jellyfin decides when a video counts as played: **Maximum resume percentage** under Server -> Playback -> Resume. Settings live under **Settings -> Watch Status**; see [Track Watch Status from Media Servers](../USAGE_GUIDE.md#track-watch-status-from-media-servers). +Connecting Jellyfin also enables watch status sync: Youtarr periodically pulls per-video watch state (played, percent watched, last watched) for every user on the server and shows it as Watched chips and filters on its listing pages. Youtarr writes to Jellyfin only to restore watch state after it moves your files (see [Watch state](#watch-state)). Jellyfin decides when a video counts as played: **Maximum resume percentage** under Server -> Playback -> Resume. Settings live under **Settings -> Watch Status**; see [Track Watch Status from Media Servers](../USAGE_GUIDE.md#track-watch-status-from-media-servers). Videos inside Jellyfin Collections remain available for watch status and native playlist sync with **Group movies into collections** enabled. You do not need to change that display setting. @@ -150,9 +186,11 @@ Organize content by type: Path: /path/to/youtube/__music Library: "YouTube - General" - Path: /path/to/youtube + Paths: /path/to/youtube/__news, /path/to/youtube/__gaming ``` + Jellyfin shows a folder in only one library, so don't add the downloads folder itself to a library when other libraries use its subfolders: they would stay empty. Channels saved directly in the downloads folder need a subfolder once you split libraries. + 2. **Configure each library** independently: - Kids: Enable parental ratings - Music: Music-focused display options @@ -215,6 +253,14 @@ Organize content by type: 3. Clear cache and rescan 4. Check image format (JPEG required) +### TV Library Is Empty + +**Problem**: A Shows library for a Youtarr TV folder shows nothing, while the episodes appear in another library + +**Cause**: Another library includes the TV folder (usually a library at the downloads folder). Jellyfin shows a folder in one library only and skips the nested one; its log says `Found duplicate path`. + +**Solution**: Point the other library at your Videos folders one by one instead of the downloads folder, then scan. See [One library per folder](#one-library-per-folder). + ## File Structure See [docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md](../YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md) diff --git a/docs/media-servers/kodi.md b/docs/media-servers/kodi.md index ef5e1b950..6965f41b0 100644 --- a/docs/media-servers/kodi.md +++ b/docs/media-servers/kodi.md @@ -5,6 +5,7 @@ Complete guide for integrating Youtarr with Kodi media center. ## Table of Contents - [Overview](#overview) - [Library Setup](#library-setup) +- [TV Shows](#tv-shows) - [Metadata Configuration](#metadata-configuration) - [Multi-Library Organization](#multi-library-organization) - [Playlist Files (.m3u)](#playlist-files-m3u) @@ -44,6 +45,18 @@ Configure the following settings: - **Selected folder contains a single video**: No - **Exclude path from library updates**: No +## TV Shows + +Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> Core -> File Structure -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo` and `poster.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC. + +To add a TV folder to Kodi: + +1. Add the TV folder itself as a video source, for example `/path/to/youtube/__TV Shows`. +2. Set its content type to **TV shows** with **Local information only**. +3. Keep your Videos folders in a separate **Movies** source, and don't add the downloads folder itself once one of its folders is a TV folder. + +Kodi takes the season and episode from the file name, and it scans the rest of the name for a second `SxxEyy`: a video titled, for example, "Breaking Bad S05E14 Reaction" may show up as an extra, phantom episode. Kodi was not part of Youtarr's media server tests. + ## Metadata Configuration ### NFO Support diff --git a/docs/media-servers/plex.md b/docs/media-servers/plex.md index 796676349..2fce1fa66 100644 --- a/docs/media-servers/plex.md +++ b/docs/media-servers/plex.md @@ -26,7 +26,12 @@ Youtarr provides full Plex integration with: ## Library Setup -There are two ways to add Youtarr content to Plex. The "Other Videos" library is the standard, recommended method: it has been in place since Youtarr's inception, is extensively tested, and works out of the box. The "TV Shows" library is an alternative you can try if you want a more series-style presentation in Plex. It requires a specific file naming convention, so you need to set the file naming before you start downloading, or re-download existing videos after changing it. +Each Youtarr library folder (the main downloads folder and each `__subfolder`) has a layout, set under Settings -> Core -> File Structure -> **Library folders**: + +- **Videos** (the default): give the folder an **Other Videos** library. +- **TV shows**: give the folder a **TV Shows** library. Youtarr saves each channel there as a show, with year seasons and an episode NFO file for every video; see [TV Shows](#tv-shows). + +A Plex library must hold folders of one layout only. ### Other Videos @@ -66,30 +71,49 @@ Point the library to your Youtarr download directory: - Default: `/path/to/youtube` - Or specific subfolder: `/path/to/youtube/__kids` +Once one of your folders is a TV folder, point the Other Videos library at your Videos folders only (one location per folder). A library at the whole download directory would show the TV folder's episodes a second time. + ### TV Shows -#### Step 1: Set Naming Convention -In Youtarr, go to `Settings -> Core -> Video Filename Template` and select the `Plex TV Series` preset. +Use a TV Shows library for each Youtarr **TV folder**: a library folder whose layout is TV shows. To make one, set a folder's layout under Settings -> Core -> File Structure -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**, which can create the folder for you. Files are saved as `__TV Shows/<Show>/Season 2026/S2026E09281530 - Title [id].mp4`, where the episode number is the upload's month, day, hour and minute (UTC); see [TV folders](../YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders). -This will **not** rename previously downloaded videos. It is best to set this before you start downloading; otherwise, re-download videos so they pick up the new naming. +#### Step 1: Create the library -While you are there, turn off `Prefix channel name in embedded video title` under `Settings -> Core -> Download Settings`. Plex reads the embedded MP4 title as the episode title, and in a TV Shows library the channel is already the show name, so the prefix would only repeat it. This also only applies to new downloads. +1. In Plex, go to Settings -> Manage -> Libraries and click **Add Library** +2. **Select type**: TV Shows, and name the library +3. **Add folders**: the TV folder itself, for example `/path/to/youtube/__TV Shows`. Show folders must sit directly inside the library's folder, so don't point it at your downloads folder. -#### Step 2: Create a New Library -1. In Plex, go to Settings → Manage → Libraries -2. Click "Add Library" -3. Configure as follows: - - **Type**: TV Shows - - **Name**: YouTube (or your preference) - - **Language**: Your preferred language +#### Step 2: Advanced settings -#### Step 3: Advanced Settings +- **Scanner**: Plex TV Series +- **Agent**: **Plex NFO Series** (Plex Media Server 1.43.1 or newer, recommended), or Plex Personal Media. With the NFO agent selected, a **Ratings Source** option set to "NFO Default" appears; leave it. +- **Use local assets**: on, so Plex uses Youtarr's `poster.jpg`, `backdrop.jpg` and episode thumbnails +- **Enable video preview thumbnails**: off for a large library (Plex generates them for every episode) +- Turn off intro, credit and voice activity detection. They don't help with this content, and Plex spends time on them. +- The remaining options (season titles, original titles, artwork language, collections, Seasons: Show, ad detection) can keep their defaults. -Choose the appropriate agent: -* **Agent**: Plex Personal Media -* **Use local assets**: this **must** be enabled so Plex uses the local metadata +| | Plex NFO Series | Plex Personal Media | +|---|---|---| +| Episode title, plot and air date | From Youtarr's episode NFO files | From the tags embedded in the MP4 file | +| Show title and summary | From `tvshow.nfo` | Folder name, no summary | +| Watch state when Youtarr moves an episode to another TV folder | Kept: Plex identifies each episode by the YouTube ID in its NFO file | Lost; Youtarr restores the server owner's state | +| Videos Youtarr moved in from a Videos folder | NFO title | The title embedded at download, which can start with the channel name | -It is also recommended to disable the settings that scan shows, like intro detection, credit detection, and voice activity detection. They do not work for this content, but Plex will still spend time scanning for them if left enabled. +Don't use the **Plex Series** agent: it looks shows up online and can match a channel to an unrelated TV series. Plex is removing the legacy agents and scanners. + +#### Step 3: Refresh mapping + +After a download Youtarr refreshes the Plex library mapped to the folder the video landed in (Settings -> Plex -> subfolder library mappings), or the default YouTube library when the folder has no mapping. Youtarr fills this in for TV folders: whenever Channel Settings -> **TV Show** opens for a channel in a TV folder that has no mapping yet, it checks your Plex libraries and adds the mapping as soon as it finds the one TV Shows library that holds the folder. You can also add it from the library check under Settings -> Core -> File Structure. Youtarr never changes a mapping that exists, so to have a TV folder refresh a different library, change its mapping under Settings -> Plex rather than deleting it (a deleted mapping is filled in again the next time the check runs). + +#### Checking your setup + +Under Settings -> Core -> File Structure -> **Library folders**, each folder lists the Plex libraries that hold it and anything to fix: no TV Shows library yet, a library of the wrong type, the Plex Series agent or a legacy agent, another library that shows the same episodes again, or a missing refresh mapping. Channel Settings -> **TV Show** shows the same for the channel's TV folder, and the review of a move shows it for the TV folders the videos move into. + +Avoid libraries that include a TV folder from a parent folder, such as a "YouTube - All" library pointed at your downloads folder: Plex shows every episode there a second time as a plain video, and Youtarr restores watch state to only one of the copies. Two TV Shows libraries pointed at the same folder have the same problem. + +#### Plex TV Series filename preset + +The **Plex TV Series** preset (Settings -> Core -> Video Filename Template) names files like episodes but keeps everything else movie-style: no season folders, no episode NFO files. If you used it and your Plex TV Shows library points at your downloads folder, you can set the main folder's layout to **TV shows**: Youtarr writes a `.plexignore` with `__*/*` there so that library skips your subfolders, and when your channels' files move into season folders they keep the episode numbers the preset gave them. For a new setup, use a TV folder instead. ## Youtarr Settings @@ -142,7 +166,7 @@ For how syncing, ordering, and playlist updates work across all servers, see [Me ## Watch Status Sync -The same Plex connection you set up above also enables watch status sync: on a schedule (every 4 hours by default), Youtarr pulls per-video watch state from Plex and shows it as Watched chips and filters on its listing pages. The sync is one-way; Youtarr never marks anything watched on Plex. +The same Plex connection you set up above also enables watch status sync: on a schedule (every 4 hours by default), Youtarr pulls per-video watch state from Plex and shows it as Watched chips and filters on its listing pages. Youtarr writes to Plex only to restore watch state after it moves your files (see below). A couple of Plex-specific details: @@ -150,6 +174,7 @@ A couple of Plex-specific details: - Other Plex accounts come from the server's play history, which only records completed plays. Those users show as watched or not, with no in-progress positions. - On an unclaimed server (see the playlist visibility scope above), Youtarr reads the anonymous session's watch state instead. - Plex decides when a video counts as played, not Youtarr: the **Video Played Threshold** setting under Settings -> Library (90% by default). +- When Youtarr moves files (switching a channel or folder between Videos and TV shows, or a TV channel to another TV folder), Plex sees the moved files as new, unwatched items. With Plex NFO Series, episodes moved between TV folders keep their watch state on their own. Otherwise Youtarr restores the server owner's played state and resume position once Plex has scanned the moved files. Other Plex accounts keep their history in Youtarr, but not on Plex. Settings live under **Settings -> Watch Status**, including a per-server toggle for syncing all users vs. just the owner. See [Track Watch Status from Media Servers](../USAGE_GUIDE.md#track-watch-status-from-media-servers) for the full workflow. @@ -176,6 +201,8 @@ Separate content by purpose: Library: "YouTube - All" → /path/to/youtube ``` + A "YouTube - All" library at your downloads folder also includes any TV folder, so its episodes show up there a second time. Once you use a TV folder, point your Other Videos libraries at the Videos folders only. + 3. **Configure each library** with appropriate settings: - Kids library: Enable parental controls - Music library: Use music-focused view @@ -194,7 +221,7 @@ See: [docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md](../YOUTARR_DOWNLOADS_FOLDER_ST <img width="1478" height="1248" alt="Plex Video Details" src="https://github.com/user-attachments/assets/f146ba72-abe0-4e4d-93bb-6f34cea8e5e5" /> ### Metadata Display -- **Title**: Video title with channel prefix, from the embedded MP4 title (turn off **Prefix channel name in embedded video title** in Settings -> Core for plain titles, for example in a TV Shows library) +- **Title**: Video title with channel prefix, from the embedded MP4 title (turn off **Prefix channel name in embedded video title** in Settings -> Core for plain titles). Episodes in a TV folder are always tagged with the plain title. - **Description**: Full YouTube description - **Studio**: Channel name for grouping - **Album**: Channel name (alternative grouping) diff --git a/server/__tests__/server.additional-routes.test.js b/server/__tests__/server.additional-routes.test.js index 0f8ad5d40..8d1695f07 100644 --- a/server/__tests__/server.additional-routes.test.js +++ b/server/__tests__/server.additional-routes.test.js @@ -288,6 +288,7 @@ const createServerModule = ({ describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); + jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.apikeys.test.js b/server/__tests__/server.apikeys.test.js index 11308c2b5..9cb9491ba 100644 --- a/server/__tests__/server.apikeys.test.js +++ b/server/__tests__/server.apikeys.test.js @@ -298,6 +298,7 @@ const createServerModule = ({ describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); + jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.auth-sessions.test.js b/server/__tests__/server.auth-sessions.test.js index 3bbf66d62..4e1318b7f 100644 --- a/server/__tests__/server.auth-sessions.test.js +++ b/server/__tests__/server.auth-sessions.test.js @@ -256,6 +256,7 @@ const createServerModule = ({ describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); + jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.core.test.js b/server/__tests__/server.core.test.js index d809024d3..cced5d86a 100644 --- a/server/__tests__/server.core.test.js +++ b/server/__tests__/server.core.test.js @@ -278,6 +278,7 @@ const createServerModule = ({ describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); + jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.plex-routes.test.js b/server/__tests__/server.plex-routes.test.js index 5f820a8e5..5e371d804 100644 --- a/server/__tests__/server.plex-routes.test.js +++ b/server/__tests__/server.plex-routes.test.js @@ -174,6 +174,7 @@ const setupServer = async ({ authEnabled = 'false', passwordHash = null } = {}) describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); + jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 041561fa7..888904f56 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -440,6 +440,7 @@ const createServerModule = ({ describeHolds: jest.fn(), countHolds: jest.fn(), reopenHold: jest.fn(), dismissHold: jest.fn(), applyHolds: jest.fn(), expireHolds: jest.fn(), })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); + jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/modules/mediaServers/__tests__/libraryCheck.test.js b/server/modules/mediaServers/__tests__/libraryCheck.test.js new file mode 100644 index 000000000..0e824f2b6 --- /dev/null +++ b/server/modules/mediaServers/__tests__/libraryCheck.test.js @@ -0,0 +1,307 @@ +const ROOT = '/data'; + +const folder = (name, layout, extra = {}) => ({ name, layout, isDefault: false, hasFiles: true, channels: 1, ...extra }); +const server = (serverType, libraries, samples = []) => ({ serverType, libraries, samples }); +const plexTv = (overrides = {}) => ({ + id: '41', name: 'YouTube TV', type: 'tv', agent: 'tv.plex.agents.nfo.series', scanner: 'Plex TV Series', + locations: ['Q:\\Y\\__TV'], ...overrides, +}); + +describe('libraryCheck', () => { + let libraryCheck; + let configModule; + let libraryFolders; + let serverRegistry; + let config; + + beforeEach(() => { + jest.resetModules(); + config = { plexSubfolderLibraryMappings: [], plexYoutubeLibraryId: '37' }; + jest.doMock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + jest.doMock('../../configModule', () => ({ + getConfig: jest.fn(() => config), + updateConfig: jest.fn((next) => { config = next; }), + directoryPath: ROOT, + })); + jest.doMock('../../tvShows/libraryFolders', () => ({ listLibraryFolders: jest.fn() })); + jest.doMock('../serverRegistry', () => ({ getEnabledAdapters: jest.fn() })); + jest.doMock('../libraryLocator', () => { + const { matchLibraries } = jest.requireActual('../libraryMatcher'); + return { + locate: jest.fn(async (adapter, folders) => { + if (adapter.unreachable) throw Object.assign(new Error('connect ECONNREFUSED'), { isAxiosError: true }); + return { + libraries: adapter.libraries, + match: matchLibraries({ folders, libraries: adapter.libraries, samples: adapter.samples, containerRoot: ROOT }), + }; + }), + }; + }); + libraryCheck = require('../libraryCheck'); + configModule = require('../../configModule'); + libraryFolders = require('../../tvShows/libraryFolders'); + serverRegistry = require('../serverRegistry'); + }); + + const run = async (folders, servers, options) => { + libraryFolders.listLibraryFolders.mockResolvedValue(folders); + serverRegistry.getEnabledAdapters.mockReturnValue(servers); + return libraryCheck.check(options); + }; + const reportOf = (result, folderName, serverType) => result.folders + .find((entry) => entry.name === folderName).servers.find((entry) => entry.serverType === serverType); + const codes = (report) => report.issues.map((issue) => issue.code); + + describe('TV folders', () => { + test('is fine with a Plex TV library mapped for refreshes', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: '41' }]; + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])]); + + const report = reportOf(result, 'TV', 'plex'); + expect(report.status).toBe('ok'); + expect(report.plexMapping).toEqual({ mappedLibraryId: '41', suggestedLibraryId: '41' }); + }); + + test('suggests the Plex refresh mapping when the folder has none', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])]); + + const report = reportOf(result, 'TV', 'plex'); + expect(report.plexMapping).toEqual({ mappedLibraryId: null, suggestedLibraryId: '41' }); + expect(codes(report)).toEqual(['plexMappingMissing']); + }); + + test('reports a refresh mapping to another library', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: '37' }]; + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])]); + + expect(codes(reportOf(result, 'TV', 'plex'))).toEqual(['plexMappingMismatch']); + }); + + test('reports a missing TV library and the Plex root library that shows the episodes a second time', async () => { + const result = await run([folder('', 'videos'), folder('Kids', 'videos'), folder('TV', 'tv')], [ + server('plex', [{ id: 'root', name: 'YouTube', type: 'videos', locations: ['Q:\\Y'] }, { id: 'k', name: 'Kids', type: 'videos', locations: ['Q:\\Y\\__Kids'] }]), + ]); + + const report = reportOf(result, 'TV', 'plex'); + expect(report.status).toBe('missing'); + expect(codes(report)).toEqual(['noLibrary', 'overlap']); + }); + + test('treats a Jellyfin TV library inside another library as missing, since Jellyfin skips it', async () => { + const result = await run([folder('', 'videos'), folder('Kids', 'videos'), folder('TV', 'tv')], [ + server('jellyfin', [ + { id: 'root', name: 'YouTube', type: 'mixed', locations: ['Q:\\Y'] }, + { id: 'tv', name: 'Shows', type: 'tv', locations: ['Q:\\Y\\__TV'] }, + ]), + ]); + + const report = reportOf(result, 'TV', 'jellyfin'); + expect(report.status).toBe('missing'); + expect(codes(report)).toEqual(['nestedLibrary']); + expect(report.issues[0].message).toContain('the TV library for it stays empty'); + }); + + test('names the TV libraries that seem to hold the folder from different places', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [ + server('plex', [ + plexTv(), + plexTv({ id: '42', name: 'Old TV', locations: ['D:\\Old\\__TV'] }), + { id: '2', name: 'YouTube', type: 'videos', locations: ['Q:\\Y\\__TV'] }, + ]), + ]); + + const issue = reportOf(result, 'TV', 'plex').issues.find((entry) => entry.code === 'ambiguous'); + expect(issue.message).toContain('(YouTube TV, Old TV)'); + }); + + test('reports two TV libraries pointed at the same folder', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: '41' }]; + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [ + server('plex', [plexTv(), plexTv({ id: '42', name: 'YouTube TV (Personal Media)', agent: 'tv.plex.agents.none' })]), + ]); + + const report = reportOf(result, 'TV', 'plex'); + expect(codes(report)).toContain('duplicateLibrary'); + expect(report.plexMapping.suggestedLibraryId).toBeNull(); + }); + + test('reports a TV folder in a Movies library', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [ + server('plex', [plexTv({ type: 'videos', agent: 'tv.plex.agents.none', scanner: 'Plex Movie' })]), + ]); + + expect(codes(reportOf(result, 'TV', 'plex'))).toContain('wrongType'); + }); + + test.each([ + [{ agent: 'tv.plex.agents.series' }, 'plexSeriesAgent'], + [{ agent: 'com.plexapp.agents.none' }, 'plexLegacyAgent'], + [{ scanner: 'Plex Series Scanner' }, 'plexLegacyAgent'], + ])('reports Plex agent %o as %s', async (overrides, code) => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: '41' }]; + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv(overrides)])]); + + expect(codes(reportOf(result, 'TV', 'plex'))).toEqual([code]); + }); + + test('reports a Jellyfin library that saves NFO files and looks episodes up online', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [ + server('jellyfin', [{ id: 'tv', name: 'Shows', type: 'tv', locations: ['/media/__TV'], nfoSaver: true, onlineFetchers: true }]), + ]); + + expect(codes(reportOf(result, 'TV', 'jellyfin'))).toEqual(['nfoSaver', 'onlineFetchers']); + }); + + test('warns about a library mounted at the folder under another name', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [ + server('emby', [{ id: 'tv', name: 'Shows', type: 'tv', locations: ['/tvshows'] }], [{ + serverPath: '/tvshows/Chan/Season 2024/S2024E01011200 - T [abcDEF12345].mp4', + containerPath: `${ROOT}/__TV/Chan/Season 2024/S2024E01011200 - T [abcDEF12345].mp4`, + }]), + ]); + + expect(codes(reportOf(result, 'TV', 'emby'))).toEqual(['folderNameMissing']); + }); + + test('accepts a Plex TV library at a TV main folder that also spans the subfolders', async () => { + const result = await run([folder('', 'tv'), folder('Kids', 'videos')], [ + server('plex', [plexTv({ locations: ['Q:\\Y'] }), { id: 'k', name: 'Kids', type: 'videos', locations: ['Q:\\Y\\__Kids'] }]), + ]); + + expect(reportOf(result, 'Kids', 'plex').status).toBe('ok'); + }); + }); + + describe('Videos folders', () => { + test('is fine inside a root library', async () => { + const result = await run([folder('', 'videos'), folder('Kids', 'videos'), folder('TV', 'tv')], [ + server('emby', [{ id: 'root', name: 'YouTube', type: 'videos', locations: ['Q:\\Y'] }, { id: 'tv', name: 'TV', type: 'tv', locations: ['Q:\\Y\\__TV'] }]), + ]); + + const report = reportOf(result, 'Kids', 'emby'); + expect(report.status).toBe('ok'); + expect(report.libraries).toEqual([expect.objectContaining({ id: 'root', relation: 'covers' })]); + }); + + test('reports a Videos folder in a TV library', async () => { + const result = await run([folder('', 'videos'), folder('Kids', 'videos')], [ + server('jellyfin', [{ id: 'k', name: 'Kids', type: 'tv', locations: ['Q:\\Y\\__Kids'] }]), + ]); + + expect(codes(reportOf(result, 'Kids', 'jellyfin'))).toEqual(['wrongType']); + }); + + test('reports a Jellyfin TV library at a TV main folder that also spans a Videos subfolder', async () => { + const result = await run([folder('', 'tv'), folder('Kids', 'videos')], [ + server('jellyfin', [{ id: 'root', name: 'Shows', type: 'tv', locations: ['Q:\\Y'] }, { id: 'k', name: 'Kids', type: 'videos', locations: ['Q:\\Y\\__Kids'] }]), + ]); + + expect(codes(reportOf(result, 'Kids', 'jellyfin'))).toEqual(['overlapTv']); + }); + + test('says when no library holds the folder', async () => { + const result = await run([folder('', 'videos'), folder('Kids', 'videos'), folder('TV', 'tv')], [ + server('plex', [plexTv({ locations: ['Q:\\Y\\__TV'] })]), + ]); + + expect(reportOf(result, 'Kids', 'plex').status).toBe('missing'); + }); + + test('counts a music library as holding the folder (MP3 channels)', async () => { + const result = await run([folder('', 'videos'), folder('Music', 'videos'), folder('TV', 'tv')], [ + server('plex', [plexTv(), { id: '40', name: 'YouTube Music', type: 'music', locations: ['Q:\\Y\\__Music'], nfoSaver: true }]), + ]); + + const report = reportOf(result, 'Music', 'plex'); + expect(report.status).toBe('ok'); + expect(report.libraries).toEqual([expect.objectContaining({ id: '40', relation: 'exact' })]); + }); + + test('does not count a music library at a parent folder: it says nothing about the videos', async () => { + const result = await run([folder('', 'videos'), folder('Kids', 'videos'), folder('TV', 'tv')], [ + server('plex', [plexTv(), { id: '40', name: 'YouTube Music', type: 'music', locations: ['Q:\\Y'] }]), + ]); + + expect(reportOf(result, 'Kids', 'plex').status).toBe('missing'); + }); + + test('does not count a music library as holding a TV folder', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [ + server('plex', [{ id: '40', name: 'YouTube Music', type: 'music', locations: ['Q:\\Y\\__TV'] }]), + ]); + + expect(reportOf(result, 'TV', 'plex').status).toBe('missing'); + }); + }); + + test('reports a server it could not read', async () => { + const result = await run([folder('', 'videos')], [{ serverType: 'emby', unreachable: true }]); + + expect(result.servers).toEqual([expect.objectContaining({ serverType: 'emby', reachable: false })]); + expect(reportOf(result, '', 'emby').status).toBe('unreachable'); + }); + + test('reports only the folders asked for', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])], { folders: ['tv'] }); + + expect(result.folders.map((entry) => entry.name)).toEqual(['TV']); + }); + + describe('checked as another layout (a reorganize preview)', () => { + test('reports a Videos folder about to become a TV folder as a TV folder', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'videos')], [server('plex', [plexTv()])], + { folders: ['TV'], layout: 'tv' }); + + const entry = result.folders[0]; + expect(entry.layout).toBe('tv'); + expect(reportOf(result, 'TV', 'plex').plexMapping).toEqual({ mappedLibraryId: null, suggestedLibraryId: '41' }); + expect(codes(reportOf(result, 'TV', 'plex'))).toEqual(['plexMappingMissing']); + }); + + test('applies the layout only to the folders asked for', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'videos')], [server('plex', [plexTv()])], { layout: 'tv' }); + + expect(result.folders.map((entry) => [entry.name, entry.layout])).toEqual([['', 'videos'], ['TV', 'videos']]); + }); + }); + + describe('applyPlexMapping', () => { + beforeEach(() => { + libraryFolders.listLibraryFolders.mockResolvedValue([folder('', 'videos'), folder('TV', 'tv')]); + serverRegistry.getEnabledAdapters.mockReturnValue([server('plex', [plexTv()])]); + }); + + test('maps the folder to the one Plex TV library that holds it', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'Kids', libraryId: '12' }]; + + expect(await libraryCheck.applyPlexMapping('TV', '41')).toEqual({ + mappedLibraryId: '41', + plexSubfolderLibraryMappings: [{ subfolder: 'Kids', libraryId: '12' }, { subfolder: 'TV', libraryId: '41' }], + }); + expect(configModule.updateConfig).toHaveBeenCalledWith(expect.objectContaining({ + plexSubfolderLibraryMappings: [{ subfolder: 'Kids', libraryId: '12' }, { subfolder: 'TV', libraryId: '41' }], + })); + }); + + test('answers with the saved mappings when the folder already maps to that library', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: '41' }]; + + expect(await libraryCheck.applyPlexMapping('TV', '41')).toEqual({ + mappedLibraryId: '41', + plexSubfolderLibraryMappings: [{ subfolder: 'TV', libraryId: '41' }], + }); + expect(configModule.updateConfig).not.toHaveBeenCalled(); + }); + + test('never replaces an existing mapping', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: '37' }]; + + await expect(libraryCheck.applyPlexMapping('TV', '41')).rejects.toMatchObject({ status: 409 }); + expect(configModule.updateConfig).not.toHaveBeenCalled(); + }); + + test('refuses a library that does not hold the folder', async () => { + await expect(libraryCheck.applyPlexMapping('TV', '99')).rejects.toMatchObject({ status: 409 }); + }); + }); +}); diff --git a/server/modules/mediaServers/__tests__/libraryLocator.test.js b/server/modules/mediaServers/__tests__/libraryLocator.test.js new file mode 100644 index 000000000..a63a470ec --- /dev/null +++ b/server/modules/mediaServers/__tests__/libraryLocator.test.js @@ -0,0 +1,106 @@ +describe('libraryLocator', () => { + let libraryLocator; + let Video; + let subfolderModule; + + const adapter = (libraries, samples = {}) => ({ + serverType: 'plex', + listLibraries: jest.fn().mockResolvedValue(libraries), + sampleItemPaths: jest.fn(async (library) => samples[library.id] || []), + }); + + beforeEach(() => { + jest.resetModules(); + jest.doMock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + jest.doMock('../../configModule', () => ({ directoryPath: '/usr/src/app/data' })); + jest.doMock('../../subfolderModule', () => ({ getAll: jest.fn().mockResolvedValue(['__TV Shows', '__Kids']) })); + jest.doMock('../../../models', () => ({ Video: { findAll: jest.fn().mockResolvedValue([]) } })); + libraryLocator = require('../libraryLocator'); + ({ Video } = require('../../../models')); + subfolderModule = require('../../subfolderModule'); + }); + + test('lists the main folder and every registered subfolder', async () => { + expect(await libraryLocator.folderNames()).toEqual(['', 'TV Shows', 'Kids']); + expect(subfolderModule.getAll).toHaveBeenCalled(); + }); + + test('finds the main folder from a sampled file of one of Youtarr\'s downloads', async () => { + const server = adapter([{ id: '1', type: 'videos', locations: ['/media/youtube'] }], { + 1: ['/media/youtube/Chan/Chan - T - abcDEF12345/Chan - T [abcDEF12345].mp4'], + }); + Video.findAll.mockResolvedValue([{ + youtubeId: 'abcDEF12345', + filePath: '/usr/src/app/data/Chan/Chan - T - abcDEF12345/Chan - T [abcDEF12345].mp4', + audioFilePath: null, + }]); + + const { match } = await libraryLocator.locate(server); + + expect(match.mainKnown).toBe(true); + expect(match.scope).toEqual(new Set(['1'])); + }); + + test('does not sample libraries of other kinds', async () => { + const server = adapter([{ id: '9', type: 'other', locations: ['/photos'] }]); + + await libraryLocator.locate(server, ['']); + + expect(server.sampleItemPaths).not.toHaveBeenCalled(); + }); + + test('has no scope when the server cannot be read', async () => { + const server = adapter([]); + server.listLibraries.mockRejectedValue(new Error('ECONNREFUSED')); + + expect(await libraryLocator.scopeFor(server)).toBeNull(); + }); + + test('scopes to the libraries named after a subfolder and the main folder above them', async () => { + const server = adapter([ + { id: '41', type: 'tv', locations: ['Q:\\Y\\__TV Shows'] }, + { id: '7', type: 'tv', locations: ['D:\\TV'] }, + ], { 7: ['D:\\TV\\Some Show\\Season 01\\Some Show - S01E01 - Pilot.mkv'] }); + + expect(await libraryLocator.scopeFor(server)).toEqual(new Set(['41'])); + }); + + describe('a library whose sample says nothing about its content', () => { + const named = { id: '41', type: 'tv', locations: ['Q:\\Y\\__TV Shows'] }; + + // Adapters resolve [] for an empty library and for one they could not read. + test('stays in scope while its sample shows no files', async () => { + const server = adapter([named, { id: '7', type: 'tv', locations: ['/kids'] }], { 7: [] }); + + expect(await libraryLocator.scopeFor(server)).toEqual(new Set(['41', '7'])); + }); + + test('stays in scope when it holds YouTube downloads Youtarr does not know', async () => { + const server = adapter([named, { id: '7', type: 'tv', locations: ['/kids'] }], { + 7: ['/kids/Chan/Season 2024/S2024E01011200 - Title [abcDEF12345].mp4'], + }); + + expect(await libraryLocator.scopeFor(server)).toEqual(new Set(['41', '7'])); + }); + + test('leaves out a library whose files are not YouTube downloads', async () => { + const server = adapter([named, { id: '7', type: 'tv', locations: ['/kids'] }], { + 7: ['/kids/Some Show/Season 01/Some Show - S01E01 - Pilot.mkv'], + }); + + expect(await libraryLocator.scopeFor(server)).toEqual(new Set(['41'])); + }); + + test('leaves out libraries of other kinds', async () => { + const server = adapter([named, { id: '9', type: 'other', locations: ['/photos'] }]); + + expect(await libraryLocator.scopeFor(server)).toEqual(new Set(['41'])); + }); + + test('has no scope at all while the main folder is unknown', async () => { + const server = adapter([{ id: '7', type: 'tv', locations: ['/kids'] }], { 7: [] }); + + expect(await libraryLocator.scopeFor(server)).toBeNull(); + }); + }); +}); diff --git a/server/modules/mediaServers/__tests__/libraryMatcher.test.js b/server/modules/mediaServers/__tests__/libraryMatcher.test.js new file mode 100644 index 000000000..8230cde9e --- /dev/null +++ b/server/modules/mediaServers/__tests__/libraryMatcher.test.js @@ -0,0 +1,159 @@ +const { + matchLibraries, mappingFromSample, youtubeIdOf, RELATION_EXACT, RELATION_COVERS, RELATION_INSIDE, +} = require('../libraryMatcher'); + +const ROOT = '/usr/src/app/data'; +const FOLDERS = ['', 'TV Shows', 'Kids']; + +const library = (id, ...locations) => ({ id, locations }); +const relationsFor = (result, folder) => result.relations.filter((relation) => relation.folder === folder); + +describe('libraryMatcher', () => { + describe('youtubeIdOf', () => { + test('reads the id in brackets before the extension', () => { + expect(youtubeIdOf('Q:\\Media\\Show\\Season 2024\\S2024E01 - Title [abcDEF12345].mp4')).toBe('abcDEF12345'); + }); + + test('returns null for a file without an id', () => { + expect(youtubeIdOf('/media/movies/Some Movie (2020).mkv')).toBeNull(); + }); + }); + + describe('matchLibraries', () => { + test('finds a subfolder by its __name and the main folder as its parent', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('41', 'Q:\\Youtube_test\\__TV Shows'), library('37', 'Q:\\Youtube_test')], + containerRoot: ROOT, + }); + + expect(relationsFor(result, 'TV Shows')).toEqual(expect.arrayContaining([ + expect.objectContaining({ libraryId: '41', relation: RELATION_EXACT, folderSegmentMissing: false }), + expect.objectContaining({ libraryId: '37', relation: RELATION_COVERS }), + ])); + expect(relationsFor(result, '')).toEqual([expect.objectContaining({ libraryId: '37', relation: RELATION_EXACT })]); + }); + + test('compares folder names ignoring case', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('41', '/mnt/media/__tv shows')], + containerRoot: ROOT, + }); + + expect(relationsFor(result, 'TV Shows')).toEqual([expect.objectContaining({ libraryId: '41', relation: RELATION_EXACT })]); + }); + + test('finds the main folder through a sampled download when no library names a subfolder', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('1', '/media/youtube')], + samples: [{ + serverPath: '/media/youtube/Some Channel/Some Channel - Title - abcDEF12345/Some Channel - Title [abcDEF12345].mp4', + containerPath: `${ROOT}/Some Channel/Some Channel - Title - abcDEF12345/Some Channel - Title [abcDEF12345].mp4`, + }], + containerRoot: ROOT, + }); + + expect(result.mainKnown).toBe(true); + expect(relationsFor(result, '')).toEqual([expect.objectContaining({ libraryId: '1', relation: RELATION_EXACT })]); + expect(relationsFor(result, 'Kids')).toEqual([expect.objectContaining({ libraryId: '1', relation: RELATION_COVERS })]); + }); + + test('flags a library mounted at the folder itself under another name', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('tv', '/tvshows')], + samples: [{ + serverPath: '/tvshows/Chan/Season 2024/S2024E01011200 - Title [abcDEF12345].mp4', + containerPath: `${ROOT}/__TV Shows/Chan/Season 2024/S2024E01011200 - Title [abcDEF12345].mp4`, + }], + containerRoot: ROOT, + }); + + expect(relationsFor(result, 'TV Shows')).toEqual([ + expect.objectContaining({ libraryId: 'tv', relation: RELATION_EXACT, folderSegmentMissing: true }), + ]); + expect(result.mainKnown).toBe(false); + }); + + test('reports a library pointed inside a folder', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('41', 'Q:\\Youtube_test\\__TV Shows'), library('9', 'Q:\\Youtube_test\\__TV Shows\\One Show')], + containerRoot: ROOT, + }); + + expect(relationsFor(result, 'TV Shows')).toEqual(expect.arrayContaining([ + expect.objectContaining({ libraryId: '9', relation: RELATION_INSIDE }), + ])); + }); + + test('does not count a library below a subfolder as part of the main folder', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('41', 'Q:\\Youtube_test\\__TV Shows'), library('9', 'Q:\\Youtube_test\\__TV Shows\\One Show')], + containerRoot: ROOT, + }); + + expect(relationsFor(result, '').map((relation) => relation.libraryId)).not.toContain('9'); + }); + + test('limits the scope to libraries related to a folder once the main folder is known', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('41', 'Q:\\Youtube_test\\__TV Shows'), library('2', 'D:\\TV'), library('3', 'Q:\\')], + containerRoot: ROOT, + }); + + expect(result.scope).toEqual(new Set(['41', '3'])); + }); + + test('has no scope when the main folder cannot be found', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('2', 'D:\\TV')], + containerRoot: ROOT, + }); + + expect(result.scope).toBeNull(); + }); + + test('ignores a stale copy the server still lists in another subfolder', () => { + const result = matchLibraries({ + folders: FOLDERS, + libraries: [library('41', '/media/__Kids')], + samples: [{ + serverPath: '/media/__Kids/Chan/Season 2024/S2024E01011200 - Title [abcDEF12345].mp4', + containerPath: `${ROOT}/__TV Shows/Chan/Season 2024/S2024E01011200 - Title [abcDEF12345].mp4`, + }], + containerRoot: ROOT, + }); + + expect(relationsFor(result, 'TV Shows').filter((relation) => relation.relation === RELATION_EXACT)).toEqual([]); + }); + }); + + describe('mappingFromSample', () => { + const subfolderKeys = new Map([['tv shows', 'TV Shows']]); + const rootSegments = ROOT.split('/').filter(Boolean); + + test('stops the shared tail at the downloads folder even when the folder names match', () => { + const mapping = mappingFromSample({ + serverPath: '/srv/data/Chan/Chan - T - abcDEF12345/Chan - T [abcDEF12345].mp4', + containerPath: `${ROOT}/Chan/Chan - T - abcDEF12345/Chan - T [abcDEF12345].mp4`, + }, rootSegments, subfolderKeys); + + expect(mapping).toEqual({ folder: '', segments: ['srv', 'data'] }); + }); + + test('needs at least the file name and its folder in common', () => { + const mapping = mappingFromSample({ + serverPath: '/elsewhere/Chan - T [abcDEF12345].mp4', + containerPath: `${ROOT}/Chan/Chan - T [abcDEF12345].mp4`, + }, rootSegments, subfolderKeys); + + expect(mapping).toBeNull(); + }); + }); +}); diff --git a/server/modules/mediaServers/__tests__/mediaServerSync.test.js b/server/modules/mediaServers/__tests__/mediaServerSync.test.js index fa1c3cc85..36e07ec1c 100644 --- a/server/modules/mediaServers/__tests__/mediaServerSync.test.js +++ b/server/modules/mediaServers/__tests__/mediaServerSync.test.js @@ -14,6 +14,7 @@ describe('mediaServerSync', () => { Video: { findAll: jest.fn() }, })); jest.doMock('../../configModule', () => ({ getConfig: () => ({}) })); + jest.doMock('../libraryLocator', () => ({ scopeFor: jest.fn().mockResolvedValue(null) })); jest.doMock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), })); @@ -120,11 +121,42 @@ describe('mediaServerSync', () => { } expect(batchResolve).toHaveBeenCalledTimes(2); - expect(batchResolve).toHaveBeenNthCalledWith(1, ['/youtube/A/v1.mp4', '/youtube/B/v2.mp4']); - expect(batchResolve).toHaveBeenNthCalledWith(2, ['/youtube/B/v2.mp4']); + expect(batchResolve).toHaveBeenNthCalledWith(1, ['/youtube/A/v1.mp4', '/youtube/B/v2.mp4'], { libraryIds: null }); + expect(batchResolve).toHaveBeenNthCalledWith(2, ['/youtube/B/v2.mp4'], { libraryIds: null }); expect(plexAdapter.createPlaylist).toHaveBeenCalledWith('YT: PL', ['rk1', 'rk2'], { public: false, mediaType: 'video' }); }); + test('resolves Plex items only in the libraries that hold Youtarr\'s folders', async () => { + const scope = new Set(['41']); + require('../libraryLocator').scopeFor.mockResolvedValue(scope); + Playlist.findByPk.mockResolvedValue({ + id: 1, playlist_id: 'PL1', title: 'PL', + sync_to_plex: true, sync_to_jellyfin: true, sync_to_emby: false, + public_on_servers: false, + }); + PlaylistVideo.findAll.mockResolvedValue([{ youtube_id: 'v1', position: 1, ignored: false }]); + Video.findAll.mockResolvedValue([{ youtubeId: 'v1', filePath: '/youtube/A/v1.mp4' }]); + PlaylistSyncState.findOne.mockResolvedValue(null); + PlaylistSyncState.create.mockResolvedValue({ id: 1 }); + const plexResolve = jest.fn().mockResolvedValue(new Map([['/youtube/A/v1.mp4', 'rk1']])); + const jellyfinResolve = jest.fn().mockResolvedValue(new Map([['/youtube/A/v1.mp4', 'jf1']])); + const plexAdapter = makeAdapter('PlexAdapter', { + resolveItemIdsByFilepaths: plexResolve, + createPlaylist: jest.fn().mockResolvedValue({ id: 'pid' }), + }); + const jellyfinAdapter = makeAdapter('JellyfinAdapter', { + resolveItemIdsByFilepaths: jellyfinResolve, + createPlaylist: jest.fn().mockResolvedValue({ id: 'jid' }), + }); + serverRegistry.getEnabledAdapters.mockReturnValue([plexAdapter, jellyfinAdapter]); + + await mediaServerSync.syncPlaylist(1); + + expect(plexResolve).toHaveBeenCalledWith(['/youtube/A/v1.mp4'], { libraryIds: scope }); + expect(jellyfinResolve).toHaveBeenCalledWith(['/youtube/A/v1.mp4'], { libraryIds: null }); + expect(require('../libraryLocator').scopeFor).toHaveBeenCalledTimes(1); + }); + test('replaces items when sync state already exists', async () => { Playlist.findByPk.mockResolvedValue({ id: 1, playlist_id: 'PL1', title: 'PL', diff --git a/server/modules/mediaServers/__tests__/watchStatusPushBack.test.js b/server/modules/mediaServers/__tests__/watchStatusPushBack.test.js index 69fc00df3..a4afadf0d 100644 --- a/server/modules/mediaServers/__tests__/watchStatusPushBack.test.js +++ b/server/modules/mediaServers/__tests__/watchStatusPushBack.test.js @@ -25,6 +25,7 @@ describe('watchStatusPushBack', () => { jest.doMock('../../configModule', () => ({ getConfig: jest.fn(() => ({})) })); jest.doMock('../serverRegistry', () => ({ getEnabledAdapters: jest.fn(() => []) })); jest.doMock('../watchStatusHolds', () => ({ HOLD_STATE: { PENDING: 'pending' } })); + jest.doMock('../libraryLocator', () => ({ scopeFor: jest.fn().mockResolvedValue(null) })); WatchStatusHold = require('../../../models/watchstatushold'); Video = require('../../../models/video'); serverRegistry = require('../serverRegistry'); @@ -46,12 +47,22 @@ describe('watchStatusPushBack', () => { const result = await pushBack.pushPendingHolds(); - expect(adapter.resolveItemMatchesByPaths).toHaveBeenCalledWith(expect.arrayContaining([NEW_PATH, FROM_PATH])); + expect(adapter.resolveItemMatchesByPaths).toHaveBeenCalledWith(expect.arrayContaining([NEW_PATH, FROM_PATH]), { libraryIds: null }); expect(adapter.setWatchState).toHaveBeenCalledWith('item-1', 'u1', expect.objectContaining({ played: true, positionMs: null })); expect(result).toEqual({ pushed: 1, notIndexed: 0, failed: 0 }); expect(pending.update).toHaveBeenCalledWith(expect.objectContaining({ attempts: 1, last_error: null, last_pushed_at: expect.any(Date) })); }); + it('looks moved files up only in the libraries that hold Youtarr\'s folders', async () => { + const scope = new Set(['lib-tv']); + require('../libraryLocator').scopeFor.mockResolvedValue(scope); + WatchStatusHold.findAll.mockResolvedValue([hold()]); + + await pushBack.pushPendingHolds(); + + expect(adapter.resolveItemMatchesByPaths).toHaveBeenCalledWith(expect.any(Array), { libraryIds: scope }); + }); + it('records that the server has not indexed the moved file yet', async () => { const pending = hold(); WatchStatusHold.findAll.mockResolvedValue([pending]); diff --git a/server/modules/mediaServers/__tests__/watchStatusSync.test.js b/server/modules/mediaServers/__tests__/watchStatusSync.test.js index 267d7bbd5..6ca90799a 100644 --- a/server/modules/mediaServers/__tests__/watchStatusSync.test.js +++ b/server/modules/mediaServers/__tests__/watchStatusSync.test.js @@ -23,6 +23,7 @@ describe('watchStatusSync', () => { expireHolds: jest.fn().mockResolvedValue(0), })); jest.doMock('../watchStatusPushBack', () => ({ pushPendingHolds: jest.fn().mockResolvedValue({}) })); + jest.doMock('../libraryLocator', () => ({ scopeFor: jest.fn().mockResolvedValue(null) })); jest.doMock('../../../models', () => ({ Video: { findAll: jest.fn(), findOne: jest.fn() }, VideoWatchStatus: { @@ -264,10 +265,23 @@ describe('watchStatusSync', () => { expect(plex.fetchWatchStates).toHaveBeenCalledWith({ since: new Date(stored.getTime() - 60_000), knownUserIds: ['55'], + libraryIds: null, }); expect(jellyfin.fetchWatchStates).toHaveBeenCalledWith({}); }); + test('limits plex\'s listings to the libraries that hold Youtarr\'s folders', async () => { + const scope = new Set(['37', '41']); + require('../libraryLocator').scopeFor.mockResolvedValue(scope); + const plex = fakeAdapter('plex', resolvedFetch([])); + serverRegistry.getEnabledAdapters.mockReturnValue([plex]); + Video.findAll.mockResolvedValue([]); + + await watchStatusSync.syncAll(); + + expect(plex.fetchWatchStates).toHaveBeenCalledWith(expect.objectContaining({ libraryIds: scope })); + }); + test('persists the history cursor the adapter reports after rows are written', async () => { const cursor = new Date('2026-07-17T11:00:00Z'); const plex = fakeAdapter('plex', jest.fn().mockResolvedValue({ diff --git a/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js index 1dc586122..eb1c9cf65 100644 --- a/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js @@ -486,4 +486,39 @@ describe('EmbyAdapter', () => { expect(matches.get('/data/__TV/Chan/Season 2024/E [id1].mp4')).toEqual({ id: 'HIT', score: 4 }); }); }); + + describe('library check', () => { + test('lists libraries from /Library/VirtualFolders/Query', async () => { + axios.get.mockResolvedValueOnce({ + data: { + Items: [{ + Name: 'TV', ItemId: '8112', CollectionType: 'tvshows', Locations: ['Q:\\Media\\__TV'], + LibraryOptions: { SaveLocalMetadata: true, MetadataSavers: ['Nfo'], TypeOptions: [{ Type: 'Episode', MetadataFetchers: ['TheTVDB'] }] }, + }], + }, + }); + + const libraries = await new EmbyAdapter(cfg).listLibraries(); + + expect(axios.get).toHaveBeenCalledWith('http://emby:8096/Library/VirtualFolders/Query', expect.any(Object)); + expect(libraries).toEqual([{ + id: '8112', name: 'TV', type: 'tv', locations: ['Q:\\Media\\__TV'], nfoSaver: true, onlineFetchers: true, + }]); + }); + + test('returns no samples when a library cannot be read', async () => { + axios.get.mockRejectedValueOnce(new Error('boom')); + + expect(await new EmbyAdapter(cfg).sampleItemPaths({ id: '8112', type: 'tv' }, 10)).toEqual([]); + }); + + test('lists only the scoped libraries when looking up moved files', async () => { + axios.get.mockResolvedValueOnce({ data: { Items: [] } }); + + await new EmbyAdapter(cfg).resolveItemMatchesByPaths(['/data/x [id1].mp4'], { libraryIds: new Set(['8112']) }); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get.mock.calls[0][1].params.parentId).toBe('8112'); + }); + }); }); diff --git a/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js index c8b98dd85..b3bbb39cf 100644 --- a/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js @@ -520,4 +520,65 @@ describe('JellyfinAdapter', () => { expect(matches.get('/data/__TV/Chan/Season 2024/S2024E01 - T [id1].mp4')).toEqual({ id: 'NEW', score: 4 }); }); }); + + describe('library check', () => { + const virtualFolder = (overrides = {}) => ({ + Name: 'TV', ItemId: 'lib1', CollectionType: 'tvshows', Locations: ['/media/__TV Shows'], + LibraryOptions: { SaveLocalMetadata: false, MetadataSavers: [], TypeOptions: [{ Type: 'Series', MetadataFetchers: [] }] }, + ...overrides, + }); + + test('lists libraries with their type, locations and metadata settings', async () => { + axios.get.mockResolvedValueOnce({ data: [virtualFolder()] }); + + const libraries = await new JellyfinAdapter(cfg).listLibraries(); + + expect(libraries).toEqual([{ + id: 'lib1', name: 'TV', type: 'tv', locations: ['/media/__TV Shows'], nfoSaver: false, onlineFetchers: false, + }]); + }); + + test('reads a library without CollectionType as Mixed', async () => { + axios.get.mockResolvedValueOnce({ data: [virtualFolder({ CollectionType: undefined })] }); + + const [library] = await new JellyfinAdapter(cfg).listLibraries(); + + expect(library.type).toBe('mixed'); + }); + + test('reads the server default when a library leaves its savers unset', async () => { + axios.get + .mockResolvedValueOnce({ data: [virtualFolder({ LibraryOptions: { SaveLocalMetadata: true, MetadataSavers: null } })] }) + .mockResolvedValueOnce({ data: { MetadataOptions: [{ ItemType: 'Series', DisabledMetadataSavers: [] }] } }); + + const [library] = await new JellyfinAdapter(cfg).listLibraries(); + + expect(axios.get).toHaveBeenLastCalledWith('http://jf:8096/System/Configuration', expect.any(Object)); + expect(library.nfoSaver).toBe(true); + }); + + test('samples a few file paths from one library', async () => { + axios.get.mockResolvedValueOnce({ data: { Items: [{ Path: '/media/a.mp4' }, { Path: null }, { Path: '/media/b.mp4' }] } }); + + const paths = await new JellyfinAdapter(cfg).sampleItemPaths({ id: 'lib1', type: 'tv' }, 10); + + expect(paths).toEqual(['/media/a.mp4', '/media/b.mp4']); + expect(axios.get).toHaveBeenCalledWith('http://jf:8096/Items', expect.objectContaining({ + params: expect.objectContaining({ parentId: 'lib1', limit: 10 }), + })); + }); + + test('lists only the scoped libraries when looking up moved files', async () => { + axios.get + .mockResolvedValueOnce({ data: { Items: [{ Id: 'A', Path: '/media/__TV Shows/Chan/Season 2024/E [id1].mp4' }] } }) + .mockResolvedValueOnce({ data: { Items: [] } }); + + await new JellyfinAdapter(cfg).resolveItemMatchesByPaths(['/data/__TV Shows/Chan/Season 2024/E [id1].mp4'], { + libraryIds: new Set(['lib1', 'lib2']), + }); + + const parents = axios.get.mock.calls.map(([, options]) => options.params.parentId); + expect(parents).toEqual(['lib1', 'lib2']); + }); + }); }); diff --git a/server/modules/mediaServers/adapters/__tests__/libraryOptions.test.js b/server/modules/mediaServers/adapters/__tests__/libraryOptions.test.js new file mode 100644 index 000000000..81d1ec119 --- /dev/null +++ b/server/modules/mediaServers/adapters/__tests__/libraryOptions.test.js @@ -0,0 +1,66 @@ +const { libraryTypeOf, nfoSaverOf, onlineFetchersOf, needsServerDefaults } = require('../libraryOptions'); + +describe('libraryOptions', () => { + describe('libraryTypeOf', () => { + test.each([ + ['tvshows', 'tv'], + ['movies', 'videos'], + ['homevideos', 'videos'], + ['mixed', 'mixed'], + [undefined, 'mixed'], + ['music', 'music'], + ['playlists', 'other'], + ])('reads %s as %s', (collectionType, type) => { + expect(libraryTypeOf(collectionType)).toBe(type); + }); + }); + + describe('nfoSaverOf', () => { + test('is on when the library lists the NFO saver', () => { + expect(nfoSaverOf({ MetadataSavers: ['Nfo'] }, 'tv', null)).toBe(true); + }); + + test('is off for an empty saver list', () => { + expect(nfoSaverOf({ MetadataSavers: [] }, 'tv', null)).toBe(false); + }); + + test('is off for unset savers when the library does not save metadata locally', () => { + expect(nfoSaverOf({ MetadataSavers: null, SaveLocalMetadata: false }, 'tv', null)).toBe(false); + }); + + test('follows the server default for unset savers', () => { + const disabled = [ + { ItemType: 'Series', DisabledMetadataSavers: ['Nfo'] }, + { ItemType: 'Episode', DisabledMetadataSavers: ['Nfo'] }, + ]; + expect(nfoSaverOf({ MetadataSavers: null, SaveLocalMetadata: true }, 'tv', disabled)).toBe(false); + expect(nfoSaverOf({ MetadataSavers: null, SaveLocalMetadata: true }, 'tv', [])).toBe(true); + }); + + test('is unknown for unset savers when the server default could not be read', () => { + expect(nfoSaverOf({ MetadataSavers: null, SaveLocalMetadata: true }, 'tv', null)).toBeNull(); + }); + }); + + describe('onlineFetchersOf', () => { + test('is on when a show or episode fetcher is enabled', () => { + const options = { TypeOptions: [{ Type: 'Series', MetadataFetchers: [] }, { Type: 'Episode', MetadataFetchers: ['TheMovieDb'] }] }; + expect(onlineFetchersOf(options, 'tv')).toBe(true); + }); + + test('ignores fetchers of other item types', () => { + const options = { TypeOptions: [{ Type: 'Movie', MetadataFetchers: ['TheMovieDb'] }, { Type: 'Series', MetadataFetchers: [] }] }; + expect(onlineFetchersOf(options, 'tv')).toBe(false); + }); + + test('is unknown when the library has no settings for these item types', () => { + expect(onlineFetchersOf({ TypeOptions: [] }, 'tv')).toBeNull(); + }); + }); + + test('needs the server defaults only for a library with unset savers that saves locally', () => { + expect(needsServerDefaults([{ LibraryOptions: { MetadataSavers: [] } }])).toBe(false); + expect(needsServerDefaults([{ LibraryOptions: { MetadataSavers: null, SaveLocalMetadata: false } }])).toBe(false); + expect(needsServerDefaults([{ LibraryOptions: { MetadataSavers: null, SaveLocalMetadata: true } }])).toBe(true); + }); +}); diff --git a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js index de01ec7a9..cb7f0561c 100644 --- a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js @@ -1171,4 +1171,75 @@ describe('PlexAdapter', () => { expect(axios.get).not.toHaveBeenCalled(); }); }); + + describe('library check', () => { + test('lists sections with their type, agent, scanner and locations', async () => { + mockSections([ + { key: '41', type: 'show', title: 'TV', agent: 'tv.plex.agents.nfo.series', scanner: 'Plex TV Series', Location: [{ path: 'Q:\\Y\\__TV' }] }, + { key: '40', type: 'artist', title: 'Music', agent: 'tv.plex.agents.music', scanner: 'Plex Music', Location: [{ path: 'Q:\\Y' }] }, + { key: '9', type: 'photo', title: 'Photos', Location: [] }, + ]); + + const libraries = await new PlexAdapter(cfg).listLibraries(); + + expect(libraries.map(({ id, type, agent, locations }) => ({ id, type, agent, locations }))).toEqual([ + { id: '41', type: 'tv', agent: 'tv.plex.agents.nfo.series', locations: ['Q:\\Y\\__TV'] }, + { id: '40', type: 'music', agent: 'tv.plex.agents.music', locations: ['Q:\\Y'] }, + { id: '9', type: 'other', agent: null, locations: [] }, + ]); + }); + + test('samples episodes from a TV section', async () => { + axios.get.mockResolvedValueOnce({ + data: { MediaContainer: { Metadata: [{ Media: [{ Part: [{ file: 'Q:\\Y\\__TV\\Chan\\Season 2024\\E [id1].mp4' }] }] }] } }, + }); + + const paths = await new PlexAdapter(cfg).sampleItemPaths({ id: '41', type: 'tv' }, 10); + + expect(paths).toEqual(['Q:\\Y\\__TV\\Chan\\Season 2024\\E [id1].mp4']); + expect(axios.get.mock.calls[0][1].params).toEqual(expect.objectContaining({ type: 4, 'X-Plex-Container-Size': 10 })); + }); + + test('lists only scoped sections, and the configured library, when resolving files', async () => { + mockSections([ + { key: '2', type: 'movie' }, + { key: '41', type: 'show' }, + { key: '50', type: 'show' }, + ]); + axios.get.mockResolvedValue({ data: { MediaContainer: { Metadata: [] } } }); + + await new PlexAdapter(cfg).resolveItemMatchesByPaths(['/data/__TV/Chan/Season 2024/S2024E01 - T [id1].mp4'], { + libraryIds: new Set(['41']), + }); + + const listed = axios.get.mock.calls.slice(1).map(([url]) => url); + expect(listed).toEqual(['http://plex:32400/library/sections/2/all', 'http://plex:32400/library/sections/41/all']); + }); + + test('lists only scoped sections for watch state', async () => { + const adapter = new PlexAdapter({ ...cfg, plexWatchStatusAllUsers: false }); + mockSections([{ key: '2', type: 'movie' }, { key: '50', type: 'show' }]); + axios.get.mockResolvedValue({ data: { MediaContainer: { Metadata: [] } } }); + + await adapter.fetchWatchStates({ libraryIds: new Set(['2']) }); + + const listed = axios.get.mock.calls.slice(1).map(([url]) => url); + expect(listed).toEqual(['http://plex:32400/library/sections/2/all']); + }); + + test('always lists a section a subfolder is mapped to', async () => { + const adapter = new PlexAdapter({ + ...cfg, + plexWatchStatusAllUsers: false, + plexSubfolderLibraryMappings: [{ subfolder: 'Kids', libraryId: 50 }], + }); + mockSections([{ key: '2', type: 'movie' }, { key: '50', type: 'show' }, { key: '60', type: 'show' }]); + axios.get.mockResolvedValue({ data: { MediaContainer: { Metadata: [] } } }); + + await adapter.fetchWatchStates({ libraryIds: new Set(['2']) }); + + const listed = axios.get.mock.calls.slice(1).map(([url]) => url); + expect(listed).toEqual(['http://plex:32400/library/sections/2/all', 'http://plex:32400/library/sections/50/all']); + }); + }); }); diff --git a/server/modules/mediaServers/adapters/baseAdapter.js b/server/modules/mediaServers/adapters/baseAdapter.js index 8a1263b44..e1cb60a13 100644 --- a/server/modules/mediaServers/adapters/baseAdapter.js +++ b/server/modules/mediaServers/adapters/baseAdapter.js @@ -44,10 +44,12 @@ class BaseAdapter { * them has been cleared on the server (Jellyfin/Emby list only such * items). Adapters accept an * opts object; `opts.since` is an incremental watermark only Plex uses (its - * non-owner data comes from the server's play history). Throws + * non-owner data comes from the server's play history), and + * `opts.libraryIds` (a Set, or null for every library) limits Plex's section + * listings to the libraries that hold Youtarr's folders. Throws * MediaServerUnavailableError when the server is unreachable. */ - async fetchWatchStates(/* opts: { since } */) { throw new Error('not implemented'); } + async fetchWatchStates(/* opts: { since, libraryIds } */) { throw new Error('not implemented'); } /** * Items for files Youtarr moved: for each file, the item whose path shares @@ -55,9 +57,11 @@ class BaseAdapter { * The caller compares the scores of a video's new and old paths, because * until the server rescans, the stale item at the old path still shares the * file name (and, between two TV folders, the show and season folders too). + * `opts.libraryIds` (a Set, or null for every library) limits the search to + * the libraries that hold Youtarr's folders. * Returns Map<filepath, {id, score}|null>. */ - async resolveItemMatchesByPaths(/* filepaths */) { throw new Error('not implemented'); } + async resolveItemMatchesByPaths(/* filepaths, opts: { libraryIds } */) { throw new Error('not implemented'); } /** * One server user's current watch state of an item, read before a push so @@ -77,8 +81,35 @@ class BaseAdapter { * @param {{played: boolean, positionMs: number|null}} state */ async setWatchState(/* itemId, serverUserId, state */) { throw new Error('not implemented'); } + + /** + * The server's libraries, for the library check and listing scopes: + * Array<{ id, name, type, locations, agent?, scanner?, nfoSaver?, onlineFetchers? }> + * where type is one of LIBRARY_TYPES, locations are the server's own paths, + * agent/scanner are Plex's, and nfoSaver/onlineFetchers are Jellyfin's and + * Emby's (true, false, or null when unknown). Throws on a request failure. + */ + async listLibraries() { throw new Error('not implemented'); } + + /** + * A few file paths from one library (the first items it lists), so the + * caller can find which server path holds which of Youtarr's folders. + * Resolves to [] when the library can't be read. + * @param {Object} library - One of listLibraries()'s entries + * @param {number} limit + */ + async sampleItemPaths(/* library, limit */) { return []; } } +// Library kinds, from each server's own type names. +const LIBRARY_TYPES = Object.freeze({ + VIDEOS: 'videos', // Plex Movies/Other Videos, Jellyfin/Emby Movies and Home Videos + TV: 'tv', + MIXED: 'mixed', // Jellyfin/Emby Mixed Movies and Shows + MUSIC: 'music', + OTHER: 'other', +}); + /** * Pick, for each file path, the item whose path shares the most trailing * segments with it (at least the file name), with that count. @@ -211,6 +242,7 @@ module.exports.extractBasename = extractBasename; module.exports.pathSegments = pathSegments; module.exports.trailingSegmentMatch = trailingSegmentMatch; module.exports.bestItemMatchesByPath = bestItemMatchesByPath; +module.exports.LIBRARY_TYPES = LIBRARY_TYPES; module.exports.normalizeBaseUrl = normalizeBaseUrl; module.exports.REQUEST_TIMEOUT_MS = REQUEST_TIMEOUT_MS; module.exports.MediaServerUnavailableError = MediaServerUnavailableError; diff --git a/server/modules/mediaServers/adapters/embyAdapter.js b/server/modules/mediaServers/adapters/embyAdapter.js index 62f8a7b0e..d4ab21a8c 100644 --- a/server/modules/mediaServers/adapters/embyAdapter.js +++ b/server/modules/mediaServers/adapters/embyAdapter.js @@ -9,6 +9,7 @@ const { describeHttpError, MediaServerUnavailableError, } = require('./baseAdapter'); +const { libraryTypeOf, nfoSaverOf, onlineFetchersOf, needsServerDefaults } = require('./libraryOptions'); const logger = require('../../../logger'); // Jellyfin/Emby report playback position in ticks (100ns units). @@ -234,24 +235,29 @@ class EmbyAdapter extends BaseAdapter { } // Every video item's path, paged, so moved files resolve to the item at - // their new path rather than a stale one with the same file name. - async resolveItemMatchesByPaths(filepaths) { + // their new path rather than a stale one with the same file name. With a + // scope, only the libraries that hold Youtarr's folders are listed. + async resolveItemMatchesByPaths(filepaths, { libraryIds = null } = {}) { const items = []; try { - for (let startIndex = 0; ; startIndex += WATCH_STATE_PAGE_SIZE) { - const params = { - userId: this.userId, - includeItemTypes: 'Video,Movie,Episode', - recursive: true, - fields: 'Path', - sortBy: 'SortName', - startIndex, - limit: WATCH_STATE_PAGE_SIZE, - }; - const res = await axios.get(`${this.url}/Items`, { headers: this._headers(), params, timeout: REQUEST_TIMEOUT_MS }); - const page = res.data?.Items || []; - for (const item of page) items.push({ id: item.Id, path: item.Path }); - if (page.length !== WATCH_STATE_PAGE_SIZE) break; + const parents = libraryIds ? [...libraryIds] : [undefined]; + for (const parentId of parents) { + for (let startIndex = 0; ; startIndex += WATCH_STATE_PAGE_SIZE) { + const params = { + userId: this.userId, + parentId, + includeItemTypes: 'Video,Movie,Episode', + recursive: true, + fields: 'Path', + sortBy: 'SortName', + startIndex, + limit: WATCH_STATE_PAGE_SIZE, + }; + const res = await axios.get(`${this.url}/Items`, { headers: this._headers(), params, timeout: REQUEST_TIMEOUT_MS }); + const page = res.data?.Items || []; + for (const item of page) items.push({ id: item.Id, path: item.Path }); + if (page.length !== WATCH_STATE_PAGE_SIZE) break; + } } } catch (err) { if (isServerUnavailableError(err)) throw new MediaServerUnavailableError(describeHttpError(err)); @@ -260,6 +266,54 @@ class EmbyAdapter extends BaseAdapter { return bestItemMatchesByPath(items, filepaths); } + async listLibraries() { + const options = { headers: this._headers(), timeout: REQUEST_TIMEOUT_MS }; + // The current API lists libraries at /Query; plain /Library/VirtualFolders is only in an old spec. + const res = await axios.get(`${this.url}/Library/VirtualFolders/Query`, options); + const folders = Array.isArray(res.data?.Items) ? res.data.Items : []; + let serverMetadataOptions = null; + if (needsServerDefaults(folders)) { + try { + const config = await axios.get(`${this.url}/System/Configuration`, options); + serverMetadataOptions = config.data?.MetadataOptions || null; + } catch (err) { + logger.debug({ ...describeHttpError(err) }, 'emby: could not read the server metadata defaults'); + } + } + return folders.map((folder) => { + const type = libraryTypeOf(folder.CollectionType); + return { + id: String(folder.ItemId), + name: folder.Name || String(folder.ItemId), + type, + locations: (folder.Locations || []).filter(Boolean), + nfoSaver: nfoSaverOf(folder.LibraryOptions, type, serverMetadataOptions), + onlineFetchers: onlineFetchersOf(folder.LibraryOptions, type), + }; + }); + } + + async sampleItemPaths(library, limit) { + try { + const res = await axios.get(`${this.url}/Items`, { + headers: this._headers(), + params: { + userId: this.userId, + parentId: library.id, + includeItemTypes: 'Video,Movie,Episode,Audio', + recursive: true, + fields: 'Path', + limit, + }, + timeout: REQUEST_TIMEOUT_MS, + }); + return (res.data?.Items || []).map((item) => item.Path).filter(Boolean).slice(0, limit); + } catch (err) { + logger.debug({ ...describeHttpError(err), libraryId: library.id }, 'emby: could not sample a library'); + return []; + } + } + async getWatchState(itemId, serverUserId) { const userId = serverUserId || this.userId; try { diff --git a/server/modules/mediaServers/adapters/jellyfinAdapter.js b/server/modules/mediaServers/adapters/jellyfinAdapter.js index 880a15d58..21f65a5c8 100644 --- a/server/modules/mediaServers/adapters/jellyfinAdapter.js +++ b/server/modules/mediaServers/adapters/jellyfinAdapter.js @@ -9,6 +9,7 @@ const { describeHttpError, MediaServerUnavailableError, } = require('./baseAdapter'); +const { libraryTypeOf, nfoSaverOf, onlineFetchersOf, needsServerDefaults } = require('./libraryOptions'); const logger = require('../../../logger'); // Jellyfin/Emby report playback position in ticks (100ns units). @@ -228,25 +229,30 @@ class JellyfinAdapter extends BaseAdapter { } // Every video item's path, paged, so moved files resolve to the item at - // their new path rather than a stale one with the same file name. - async resolveItemMatchesByPaths(filepaths) { + // their new path rather than a stale one with the same file name. With a + // scope, only the libraries that hold Youtarr's folders are listed. + async resolveItemMatchesByPaths(filepaths, { libraryIds = null } = {}) { const items = []; try { - for (let startIndex = 0; ; startIndex += WATCH_STATE_PAGE_SIZE) { - const params = { - userId: this.userId, - includeItemTypes: 'Video,Movie,Episode', - collapseBoxSetItems: false, - recursive: true, - fields: 'Path', - sortBy: 'SortName', - startIndex, - limit: WATCH_STATE_PAGE_SIZE, - }; - const res = await axios.get(`${this.url}/Items`, { headers: this._headers(), params, timeout: REQUEST_TIMEOUT_MS }); - const page = res.data?.Items || []; - for (const item of page) items.push({ id: item.Id, path: item.Path }); - if (page.length !== WATCH_STATE_PAGE_SIZE) break; + const parents = libraryIds ? [...libraryIds] : [undefined]; + for (const parentId of parents) { + for (let startIndex = 0; ; startIndex += WATCH_STATE_PAGE_SIZE) { + const params = { + userId: this.userId, + parentId, + includeItemTypes: 'Video,Movie,Episode', + collapseBoxSetItems: false, + recursive: true, + fields: 'Path', + sortBy: 'SortName', + startIndex, + limit: WATCH_STATE_PAGE_SIZE, + }; + const res = await axios.get(`${this.url}/Items`, { headers: this._headers(), params, timeout: REQUEST_TIMEOUT_MS }); + const page = res.data?.Items || []; + for (const item of page) items.push({ id: item.Id, path: item.Path }); + if (page.length !== WATCH_STATE_PAGE_SIZE) break; + } } } catch (err) { if (isServerUnavailableError(err)) throw new MediaServerUnavailableError(describeHttpError(err)); @@ -255,8 +261,53 @@ class JellyfinAdapter extends BaseAdapter { return bestItemMatchesByPath(items, filepaths); } - // Jellyfin 10.9+ takes the user as a query parameter; older versions only - // have the /Users/{id}/... routes. + async listLibraries() { + const options = { headers: this._headers(), timeout: REQUEST_TIMEOUT_MS }; + const res = await axios.get(`${this.url}/Library/VirtualFolders`, options); + const folders = Array.isArray(res.data) ? res.data : []; + let serverMetadataOptions = null; + if (needsServerDefaults(folders)) { + try { + const config = await axios.get(`${this.url}/System/Configuration`, options); + serverMetadataOptions = config.data?.MetadataOptions || null; + } catch (err) { + logger.debug({ ...describeHttpError(err) }, 'jellyfin: could not read the server metadata defaults'); + } + } + return folders.map((folder) => { + const type = libraryTypeOf(folder.CollectionType); + return { + id: String(folder.ItemId), + name: folder.Name || String(folder.ItemId), + type, + locations: (folder.Locations || []).filter(Boolean), + nfoSaver: nfoSaverOf(folder.LibraryOptions, type, serverMetadataOptions), + onlineFetchers: onlineFetchersOf(folder.LibraryOptions, type), + }; + }); + } + + async sampleItemPaths(library, limit) { + try { + const res = await axios.get(`${this.url}/Items`, { + headers: this._headers(), + params: { + userId: this.userId, + parentId: library.id, + includeItemTypes: 'Video,Movie,Episode,Audio', + recursive: true, + fields: 'Path', + limit, + }, + timeout: REQUEST_TIMEOUT_MS, + }); + return (res.data?.Items || []).map((item) => item.Path).filter(Boolean).slice(0, limit); + } catch (err) { + logger.debug({ ...describeHttpError(err), libraryId: library.id }, 'jellyfin: could not sample a library'); + return []; + } + } + async getWatchState(itemId, serverUserId) { const userId = serverUserId || this.userId; try { diff --git a/server/modules/mediaServers/adapters/libraryOptions.js b/server/modules/mediaServers/adapters/libraryOptions.js new file mode 100644 index 000000000..f03bd6549 --- /dev/null +++ b/server/modules/mediaServers/adapters/libraryOptions.js @@ -0,0 +1,82 @@ +/** + * Jellyfin's and Emby's library settings as the library check needs them. + * Both servers describe a library (GET /Library/VirtualFolders) with a + * CollectionType and LibraryOptions in the same shape. + */ + +const { LIBRARY_TYPES } = require('./baseAdapter'); + +const NFO_SAVER = 'nfo'; + +const COLLECTION_LIBRARY_TYPES = { + tvshows: LIBRARY_TYPES.TV, + movies: LIBRARY_TYPES.VIDEOS, + homevideos: LIBRARY_TYPES.VIDEOS, + music: LIBRARY_TYPES.MUSIC, + mixed: LIBRARY_TYPES.MIXED, +}; + +// Item types whose metadata fetchers and savers matter per library type. +const ITEM_TYPES = { + [LIBRARY_TYPES.TV]: ['Series', 'Episode'], + [LIBRARY_TYPES.VIDEOS]: ['Movie', 'Video'], + [LIBRARY_TYPES.MIXED]: ['Series', 'Episode', 'Movie', 'Video'], +}; + +/** A Mixed library has no CollectionType (Jellyfin) or 'mixed' (Emby). */ +function libraryTypeOf(collectionType) { + if (!collectionType) return LIBRARY_TYPES.MIXED; + return COLLECTION_LIBRARY_TYPES[String(collectionType).toLowerCase()] || LIBRARY_TYPES.OTHER; +} + +/** + * Whether the server writes NFO files into this library's folders. A null + * MetadataSavers means the server-wide default applies: savers write only + * when the library saves metadata locally, unless the server disables the + * NFO saver for these item types. + * + * @param {Object} options - LibraryOptions + * @param {string} type - LIBRARY_TYPES value + * @param {Array<Object>|null} serverMetadataOptions - ServerConfiguration.MetadataOptions, null if unread + * @returns {boolean|null} + */ +function nfoSaverOf(options, type, serverMetadataOptions) { + const savers = options?.MetadataSavers; + if (Array.isArray(savers)) return savers.some((saver) => String(saver).toLowerCase() === NFO_SAVER); + if (options?.SaveLocalMetadata === false) return false; + if (!Array.isArray(serverMetadataOptions)) return null; + const itemTypes = ITEM_TYPES[type] || []; + const disabledEverywhere = itemTypes.length > 0 && itemTypes.every((itemType) => { + const entry = serverMetadataOptions.find((candidate) => candidate.ItemType === itemType); + return (entry?.DisabledMetadataSavers || []).some((saver) => String(saver).toLowerCase() === NFO_SAVER); + }); + return !disabledEverywhere; +} + +/** + * Whether online metadata providers fill in this library's items. Null when + * the library has no settings for these item types (the server default applies). + * + * @param {Object} options - LibraryOptions + * @param {string} type - LIBRARY_TYPES value + * @returns {boolean|null} + */ +function onlineFetchersOf(options, type) { + const itemTypes = ITEM_TYPES[type] || []; + const entries = (options?.TypeOptions || []).filter((entry) => itemTypes.includes(entry.Type)); + if (entries.length === 0) return null; + return entries.some((entry) => (entry.MetadataFetchers || []).length > 0); +} + +/** Whether any library needs the server's default metadata options to tell its NFO saver state. */ +function needsServerDefaults(virtualFolders) { + return virtualFolders.some((folder) => !Array.isArray(folder.LibraryOptions?.MetadataSavers) + && folder.LibraryOptions?.SaveLocalMetadata !== false); +} + +module.exports = { + libraryTypeOf, + nfoSaverOf, + onlineFetchersOf, + needsServerDefaults +}; diff --git a/server/modules/mediaServers/adapters/plexAdapter.js b/server/modules/mediaServers/adapters/plexAdapter.js index c2c474932..924772409 100644 --- a/server/modules/mediaServers/adapters/plexAdapter.js +++ b/server/modules/mediaServers/adapters/plexAdapter.js @@ -9,6 +9,7 @@ const { describeHttpError, MediaServerUnavailableError, WatchStateFetchError, + LIBRARY_TYPES, } = require('./baseAdapter'); const logger = require('../../../logger'); const plexModule = require('../../plexModule'); @@ -34,6 +35,16 @@ const PLEX_OWNER_ACCOUNT_ID = '1'; // Library items' identifier for the scrobble and progress endpoints. const PLEX_LIBRARY_IDENTIFIER = 'com.plexapp.plugins.library'; +// Plex metadata type for music tracks (an artist section's file-backed leaves). +const PLEX_TYPE_TRACK = 10; + +// Section types as the library check names them. +const SECTION_LIBRARY_TYPES = { + movie: LIBRARY_TYPES.VIDEOS, + show: LIBRARY_TYPES.TV, + artist: LIBRARY_TYPES.MUSIC, +}; + // Play-history pagination. The page cap bounds a single sync on servers with // enormous history; anything past it is picked up by later incremental syncs. const HISTORY_PAGE_SIZE = 1000; @@ -46,6 +57,11 @@ class PlexAdapter extends BaseAdapter { this.url = config.plexUrl; this.token = config.plexApiKey; this.libraryId = config.plexYoutubeLibraryId; + // Sections the user mapped subfolders to (Settings > Plex): known to hold + // Youtarr's files whatever the library check finds. + this.mappedLibraryIds = new Set((Array.isArray(config.plexSubfolderLibraryMappings) ? config.plexSubfolderLibraryMappings : []) + .filter((mapping) => mapping && typeof mapping === 'object' && mapping.libraryId != null) + .map((mapping) => String(mapping.libraryId).trim())); // Section ids to search when resolving files, split { video, music }, keyed // by scope ('playlist' | 'admin', see _getSectionIds). Populated lazily // per scope on first use. @@ -136,6 +152,19 @@ class PlexAdapter extends BaseAdapter { return params; } + // Auth for reads as the owner (anonymous on unclaimed servers, see anonymousScope). + _ownerParams(extra = {}) { + return this.anonymousScope ? this._plParams(extra) : { ...extra, 'X-Plex-Token': this.token }; + } + + // Whether a section may hold Youtarr's files: every section without a + // scope, and the configured YouTube library and mapped sections always. + _inScope(sectionId, libraryIds) { + if (!libraryIds) return true; + const id = String(sectionId); + return libraryIds.has(id) || this.mappedLibraryIds.has(id) || (this.libraryId != null && String(this.libraryId).trim() === id); + } + async testConnection() { try { await axios.get(`${this.url}/identity`, { params: { 'X-Plex-Token': this.token }, timeout: REQUEST_TIMEOUT_MS }); @@ -180,12 +209,12 @@ class PlexAdapter extends BaseAdapter { // in two sections when a stale item lingers after a file moved between // libraries, so every section is scanned and the best-scoring candidate wins; // see trailingSegmentMatch in baseAdapter. - async resolveItemIdsByFilepaths(filepaths) { - const matches = await this.resolveItemMatchesByPaths(filepaths); + async resolveItemIdsByFilepaths(filepaths, opts = {}) { + const matches = await this.resolveItemMatchesByPaths(filepaths, opts); return new Map([...matches].map(([filepath, match]) => [filepath, match ? match.id : null])); } - async resolveItemMatchesByPaths(filepaths) { + async resolveItemMatchesByPaths(filepaths, { libraryIds = null } = {}) { const results = new Map(); const targets = [...new Set((filepaths || []).filter(Boolean))]; if (targets.length === 0) return results; @@ -204,12 +233,15 @@ class PlexAdapter extends BaseAdapter { const mayBeEpisode = targets.some((p) => EPISODE_NAME_PATTERN.test(extractBasename(p))); const sources = [ ...sections.video + .filter((id) => this._inScope(id, libraryIds)) .filter((id) => mayBeEpisode || !sections.shows.includes(id)) .map((id) => ({ id, params: sections.shows.includes(id) ? { type: PLEX_TYPE_EPISODE } : {}, })), - ...(hasAudio ? sections.music.map((id) => ({ id, params: { type: 10 } })) : []), + ...(hasAudio + ? sections.music.filter((id) => this._inScope(id, libraryIds)).map((id) => ({ id, params: { type: PLEX_TYPE_TRACK } })) + : []), ]; for (const { id: libraryId, params } of sources) { try { @@ -387,9 +419,12 @@ class PlexAdapter extends BaseAdapter { const entries = []; const ratingKeyPaths = new Map(); // ratingKey -> [file paths] for history mapping const sections = await this._getSectionIds(this.anonymousScope ? 'playlist' : 'admin'); + // Sections that can't hold Youtarr's files are skipped (a large TV + // library of other shows is listed episode by episode otherwise). + const videoSections = sections.video.filter((id) => this._inScope(id, opts.libraryIds || null)); let sectionsListed = 0; let lastError = null; - for (const libraryId of sections.video) { + for (const libraryId of videoSections) { // Show sections need type=4 to list episode leaves, which carry both the // file paths and the per-episode watch state; the default /all would // return file-less show items and nothing could match. @@ -427,7 +462,7 @@ class PlexAdapter extends BaseAdapter { // "0 updated" and hide the problem from the sync summary. This includes // enumeration itself failing with no configured library to fall back on // (sections.video is empty then, but the fetch still didn't succeed). - if (sectionsListed === 0 && (sections.video.length > 0 || sections.enumerationError)) { + if (sectionsListed === 0 && (videoSections.length > 0 || sections.enumerationError)) { const failure = lastError || sections.enumerationError; if (isServerUnavailableError(failure)) throw new MediaServerUnavailableError(describeHttpError(failure)); const info = describeHttpError(failure); @@ -453,7 +488,7 @@ class PlexAdapter extends BaseAdapter { // Advance the stored cursor only when every section listed successfully: // with a partial ratingKey map, events skipped as "unknown item" may // belong to the failed section and must be rescanned next sync. - const listingComplete = sectionsListed === sections.video.length; + const listingComplete = sectionsListed === videoSections.length; historyCursor = listingComplete ? history.scannedThrough : null; // On an incomplete scan, withhold not-yet-known accounts from the user // list: reporting one would mark it "known" and consume its one-time @@ -594,6 +629,43 @@ class PlexAdapter extends BaseAdapter { }; } + async listLibraries() { + const res = await axios.get(`${this.url}/library/sections`, { params: this._ownerParams(), timeout: REQUEST_TIMEOUT_MS }); + return (res.data?.MediaContainer?.Directory || []).map((dir) => ({ + id: String(dir.key), + name: dir.title || String(dir.key), + type: SECTION_LIBRARY_TYPES[dir.type] || LIBRARY_TYPES.OTHER, + agent: dir.agent || null, + scanner: dir.scanner || null, + locations: (dir.Location || []).map((location) => location.path).filter(Boolean), + })); + } + + async sampleItemPaths(library, limit) { + const typeParams = { + [LIBRARY_TYPES.TV]: { type: PLEX_TYPE_EPISODE }, + [LIBRARY_TYPES.MUSIC]: { type: PLEX_TYPE_TRACK }, + }[library.type] || {}; + try { + const res = await axios.get(`${this.url}/library/sections/${library.id}/all`, { + params: this._ownerParams({ ...typeParams, 'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': limit }), + timeout: REQUEST_TIMEOUT_MS, + }); + const paths = []; + for (const item of (res.data?.MediaContainer?.Metadata || []).slice(0, limit)) { + for (const media of item.Media || []) { + for (const part of media.Part || []) { + if (part.file) paths.push(part.file); + } + } + } + return paths; + } catch (err) { + logger.debug({ ...describeHttpError(err), libraryId: library.id }, 'plex: could not sample a library section'); + return []; + } + } + // The owner's state of one item (the admin token reads as the owner; other // accounts can't be read this way, so they report unknown). async getWatchState(itemId, serverUserId) { diff --git a/server/modules/mediaServers/libraryCheck.js b/server/modules/mediaServers/libraryCheck.js new file mode 100644 index 000000000..801bd6334 --- /dev/null +++ b/server/modules/mediaServers/libraryCheck.js @@ -0,0 +1,394 @@ +/** + * The library check (GET /api/library-folders/check): for each library + * folder and each configured media server, whether a library of the right + * kind holds the folder, and what in that library's setup works against + * Youtarr's files (wrong type, an agent that matches unrelated shows, a + * server rewriting Youtarr's NFO files, another library showing the same + * files again). + * + * Also adds a TV folder's Plex refresh mapping when exactly one Plex TV + * library holds it (applyPlexMapping). + */ + +const configModule = require('../configModule'); +const logger = require('../../logger'); +const libraryFolders = require('../tvShows/libraryFolders'); +const { LAYOUT_TV, folderKey } = require('../tvShows/constants'); +const serverRegistry = require('./serverRegistry'); +const libraryLocator = require('./libraryLocator'); +const { LIBRARY_TYPES, describeHttpError } = require('./adapters/baseAdapter'); +const { RELATION_EXACT, RELATION_COVERS, RELATION_INSIDE } = require('./libraryMatcher'); + +const SERVER_NAMES = { plex: 'Plex', jellyfin: 'Jellyfin', emby: 'Emby' }; +const TYPE_NAMES = { + plex: { videos: 'Movies or Other Videos', tv: 'TV Shows', music: 'Music' }, + jellyfin: { videos: 'Movies or Home Videos', tv: 'Shows', mixed: 'Mixed Movies and Shows', music: 'Music' }, + emby: { videos: 'Movies or Home Videos', tv: 'TV Shows', mixed: 'Mixed Content', music: 'Music' }, +}; +// Libraries that show video files; photo and other libraries don't matter here. +const VIDEO_LIBRARY_TYPES = new Set([LIBRARY_TYPES.VIDEOS, LIBRARY_TYPES.TV, LIBRARY_TYPES.MIXED]); +// A Videos folder of MP3 channels is served by a music library pointed at it. +// One at a parent folder only says where the MP3s go, nothing about the videos. +const VIDEOS_FOLDER_EXACT_LIBRARY_TYPES = new Set([...VIDEO_LIBRARY_TYPES, LIBRARY_TYPES.MUSIC]); + +const PLEX_SERIES_AGENT = 'tv.plex.agents.series'; +const PLEX_LEGACY_AGENT_PREFIX = 'com.plexapp.agents.'; +const PLEX_TV_SCANNER = 'Plex TV Series'; + +const STATUS = Object.freeze({ OK: 'ok', WARNING: 'warning', MISSING: 'missing', UNREACHABLE: 'unreachable' }); +// Jellyfin and Emby show a folder in one library only: a library whose +// folder sits inside another library's folder is skipped (Jellyfin logs +// "Found duplicate path"), so a root library leaves a TV library inside it empty. +const ONE_LIBRARY_PER_FOLDER = new Set(['jellyfin', 'emby']); + +function folderLabel(name) { + return name ? `__${name}` : 'the main folder'; +} + +function typeName(serverType, type) { + return TYPE_NAMES[serverType]?.[type] || type; +} + +function libraryView(library, relation) { + return { + id: library.id, + name: library.name, + type: library.type, + location: relation.location, + relation: relation.relation, + }; +} + +class LibraryCheck { + /** + * @param {Object} [options] + * @param {string[]} [options.folders] - Only report these library folders ('' = main folder) + * @param {string} [options.layout] - Check the folders asked for as this layout instead of their + * saved one (a reorganize preview checks the folders videos are about to move into) + * @returns {Promise<{servers: Array<Object>, folders: Array<Object>}>} + */ + async check({ folders: onlyFolders, layout: asLayout } = {}) { + const config = configModule.getConfig(); + const adapters = serverRegistry.getEnabledAdapters(config); + const wanted = onlyFolders ? new Set(onlyFolders.map(folderKey)) : null; + const folders = (await libraryFolders.listLibraryFolders()).map((folder) => ( + asLayout && wanted?.has(folderKey(folder.name)) ? { ...folder, layout: asLayout } : folder + )); + const names = folders.map((folder) => folder.name); + const servers = await Promise.all(adapters.map(async (adapter) => { + try { + return { adapter, ...await libraryLocator.locate(adapter, names) }; + } catch (err) { + logger.warn({ ...describeHttpError(err), serverType: adapter.serverType }, 'Library check could not read a media server'); + return { adapter, error: describeHttpError(err).message }; + } + })); + const mainFolder = folders.find((folder) => !folder.name); + return { + servers: servers.map((server) => ({ + serverType: server.adapter.serverType, + name: SERVER_NAMES[server.adapter.serverType] || server.adapter.serverType, + reachable: !server.error, + error: server.error || null, + })), + folders: folders + .filter((folder) => !wanted || wanted.has(folderKey(folder.name))) + .map((folder) => ({ + name: folder.name, + layout: folder.layout, + hasFiles: folder.hasFiles, + channels: folder.channels, + servers: servers.map((server) => this._report(folder, server, { config, mainLayout: mainFolder?.layout })), + })), + }; + } + + _report(folder, server, { config, mainLayout }) { + const serverType = server.adapter.serverType; + const serverName = SERVER_NAMES[serverType] || serverType; + if (server.error) { + return { + serverType, + status: STATUS.UNREACHABLE, + libraries: [], + issues: [{ code: 'unreachable', message: `Couldn't read ${serverName}'s libraries: ${server.error}` }], + }; + } + const byId = new Map(server.libraries.map((library) => [library.id, library])); + const relationsOf = (kind, types = VIDEO_LIBRARY_TYPES) => server.match.relations + .filter((relation) => relation.relation === kind && folderKey(relation.folder) === folderKey(folder.name)) + .filter((relation) => types.has(byId.get(relation.libraryId)?.type)); + const exact = relationsOf(RELATION_EXACT, folder.layout === LAYOUT_TV ? VIDEO_LIBRARY_TYPES : VIDEOS_FOLDER_EXACT_LIBRARY_TYPES); + const exactIds = new Set(exact.map((relation) => relation.libraryId)); + const covering = relationsOf(RELATION_COVERS).filter((relation) => !exactIds.has(relation.libraryId)); + const inside = relationsOf(RELATION_INSIDE).filter((relation) => !exactIds.has(relation.libraryId)); + const isMainFolderLibrary = (libraryId) => server.match.relations.some((relation) => relation.libraryId === libraryId + && relation.relation === RELATION_EXACT && !relation.folder); + + const context = { folder, serverType, serverName, byId, mainLayout, isMainFolderLibrary }; + const issues = folder.layout === LAYOUT_TV + ? this._tvIssues(exact, covering, inside, context) + : this._videoIssues(exact, covering, context); + const libraries = [...exact, ...covering, ...(folder.layout === LAYOUT_TV ? inside : [])] + .map((relation) => libraryView(byId.get(relation.libraryId), relation)); + + const report = { serverType, status: STATUS.OK, libraries, issues }; + if (serverType === 'plex' && folder.layout === LAYOUT_TV && folder.name) { + report.plexMapping = this._plexMapping(folder, exact, byId, config); + const mappingIssue = this._plexMappingIssue(folder, report.plexMapping, byId, config); + if (mappingIssue) issues.push(mappingIssue); + } + const holdsFolder = folder.layout === LAYOUT_TV + ? exact.length > 0 && !(ONE_LIBRARY_PER_FOLDER.has(serverType) && covering.length > 0) + : exact.length > 0 || covering.length > 0; + if (!holdsFolder) report.status = STATUS.MISSING; + else if (issues.length > 0) report.status = STATUS.WARNING; + return report; + } + + _tvIssues(exact, covering, inside, { folder, serverType, serverName, byId, mainLayout, isMainFolderLibrary }) { + const issues = []; + const label = folderLabel(folder.name); + const tvName = typeName(serverType, LIBRARY_TYPES.TV); + if (exact.length === 0) { + issues.push({ code: 'noLibrary', message: `No ${serverName} ${tvName} library holds ${label}.` }); + } + const exactTv = exact.filter((relation) => byId.get(relation.libraryId).type === LIBRARY_TYPES.TV); + const exactTvLocations = new Set(exactTv.map((relation) => relation.location.toLowerCase())); + if (exactTvLocations.size > 1) { + const names = [...new Set(exactTv.map((relation) => byId.get(relation.libraryId).name))].join(', '); + issues.push({ code: 'ambiguous', message: `More than one ${serverName} library seems to hold ${label} (${names}), from different folders on the server.` }); + } + issues.push(...this._duplicateIssues(exact, { label, serverType, serverName, byId })); + for (const relation of exact) { + const library = byId.get(relation.libraryId); + if (library.type !== LIBRARY_TYPES.TV) { + issues.push({ + code: 'wrongType', + libraryId: library.id, + message: `${library.name} is a ${typeName(serverType, library.type)} library. A TV folder needs a ${tvName} library.`, + }); + continue; + } + issues.push(...this._tvLibraryIssues(library, serverType)); + if (relation.folderSegmentMissing) { + issues.push({ + code: 'folderNameMissing', + libraryId: library.id, + message: `${library.name} points at ${relation.location}, which is ${label} under another name. Watch state ` + + 'restored after a show moves between two TV folders can\'t tell the old copy from the new one there; those ' + + 'restores wait 14 days, then Retry pushes them. Mount the downloads folder (or keep the folder name) to avoid this.', + }); + } + } + for (const relation of covering) { + const library = byId.get(relation.libraryId); + // Youtarr's .plexignore in a TV main folder hides the __ subfolders from a Plex library there. + if (serverType === 'plex' && library.type === LIBRARY_TYPES.TV && mainLayout === LAYOUT_TV + && isMainFolderLibrary(library.id)) continue; + if (ONE_LIBRARY_PER_FOLDER.has(serverType)) { + issues.push({ + code: 'nestedLibrary', + libraryId: library.id, + message: `${library.name} (at ${relation.location}) includes ${label}. ${serverName} shows a folder in only one ` + + `library, so ${label}'s episodes appear in ${library.name} and ${exact.length > 0 ? 'the TV library for it stays empty' : 'a TV library for it would stay empty'}. ` + + `Point ${library.name} at its own folders instead of a folder that contains ${label}.`, + }); + continue; + } + issues.push(library.type === LIBRARY_TYPES.TV + ? { + code: 'overlapTv', + libraryId: library.id, + message: `${library.name} (at ${relation.location}) also includes ${label}, one level too high: it shows ${label} itself as a show.`, + } + : { + code: 'overlap', + libraryId: library.id, + message: `${library.name} (at ${relation.location}) also includes ${label}, so its episodes show up there a second time.`, + }); + } + for (const relation of inside) { + const library = byId.get(relation.libraryId); + issues.push({ + code: 'insideFolder', + libraryId: library.id, + message: `${library.name} points at ${relation.location}, a folder inside ${label}. Point it at ${label} itself: each show folder must sit directly in the library's folder.`, + }); + } + return issues; + } + + // Several TV libraries pointed at the same folder: each holds every episode + // (on Plex), and watch state is restored to only one of the copies. + _duplicateIssues(exact, { label, serverType, serverName, byId }) { + const byLocation = new Map(); + for (const relation of exact) { + if (byId.get(relation.libraryId).type !== LIBRARY_TYPES.TV) continue; + const key = relation.location.toLowerCase(); + if (!byLocation.has(key)) byLocation.set(key, new Set()); + byLocation.get(key).add(relation.libraryId); + } + return [...byLocation.values()].filter((ids) => ids.size > 1).map((ids) => { + const names = [...ids].map((id) => byId.get(id).name).join(', '); + const shown = ONE_LIBRARY_PER_FOLDER.has(serverType) + ? `${serverName} shows the folder in only one of them` + : 'each shows every episode'; + return { + code: 'duplicateLibrary', + message: `${names} all point at ${label}. Keep one: ${shown}, and Youtarr restores watch state to only one copy.`, + }; + }); + } + + _tvLibraryIssues(library, serverType) { + const issues = []; + if (serverType === 'plex') { + if (library.agent === PLEX_SERIES_AGENT) { + issues.push({ + code: 'plexSeriesAgent', + libraryId: library.id, + message: `${library.name} uses the Plex Series agent, which can match a channel to an unrelated TV show online. ` + + 'Use Plex Personal Media or Plex NFO Series.', + }); + } else if ((library.agent || '').startsWith(PLEX_LEGACY_AGENT_PREFIX) || (library.scanner && library.scanner !== PLEX_TV_SCANNER)) { + issues.push({ + code: 'plexLegacyAgent', + libraryId: library.id, + message: `${library.name} uses a legacy agent or scanner, which Plex is removing. Use the ${PLEX_TV_SCANNER} scanner ` + + 'with Plex Personal Media or Plex NFO Series.', + }); + } + return issues; + } + if (library.nfoSaver === true) issues.push(this._nfoSaverIssue(library)); + if (library.onlineFetchers === true) { + issues.push({ + code: 'onlineFetchers', + libraryId: library.id, + message: `${library.name} looks shows and episodes up online, which can replace Youtarr's titles and numbers ` + + 'with an unrelated show\'s. Turn off the online metadata downloaders for shows and episodes.', + }); + } + return issues; + } + + _nfoSaverIssue(library) { + return { + code: 'nfoSaver', + libraryId: library.id, + message: `${library.name} saves NFO files, so the server rewrites the NFO files Youtarr writes. Turn off the NFO metadata saver for this library.`, + }; + } + + _videoIssues(exact, covering, { folder, serverType, byId, mainLayout, isMainFolderLibrary }) { + const issues = []; + const label = folderLabel(folder.name); + for (const relation of exact) { + const library = byId.get(relation.libraryId); + if (library.type === LIBRARY_TYPES.TV) { + issues.push({ + code: 'wrongType', + libraryId: library.id, + message: `${library.name} is a ${typeName(serverType, library.type)} library, but ${label} uses the Videos layout.`, + }); + } + } + for (const relation of covering) { + const library = byId.get(relation.libraryId); + if (library.type !== LIBRARY_TYPES.TV) continue; + if (serverType === 'plex' && mainLayout === LAYOUT_TV && isMainFolderLibrary(library.id)) continue; + issues.push({ + code: 'overlapTv', + libraryId: library.id, + message: `${library.name} (a ${typeName(serverType, library.type)} library at ${relation.location}) also includes ` + + `${label}, where its channels show up as extra shows.`, + }); + } + // Youtarr writes no music NFO files, so a music library's saver is no concern. + const savers = [...exact, ...covering] + .map((relation) => byId.get(relation.libraryId)) + .filter((library) => library.nfoSaver === true && library.type !== LIBRARY_TYPES.MUSIC); + for (const library of new Map(savers.map((entry) => [entry.id, entry])).values()) { + issues.push(this._nfoSaverIssue(library)); + } + return issues; + } + + _mappedLibraryId(folder, config) { + const mappings = Array.isArray(config.plexSubfolderLibraryMappings) ? config.plexSubfolderLibraryMappings : []; + const mapping = mappings.find((entry) => entry && typeof entry === 'object' + && entry.subfolder && folderKey(entry.subfolder) === folderKey(folder.name)); + return mapping?.libraryId ? String(mapping.libraryId) : null; + } + + _plexMapping(folder, exact, byId, config) { + const tvLibraries = [...new Set(exact + .filter((relation) => byId.get(relation.libraryId).type === LIBRARY_TYPES.TV) + .map((relation) => relation.libraryId))]; + return { + mappedLibraryId: this._mappedLibraryId(folder, config), + suggestedLibraryId: tvLibraries.length === 1 ? tvLibraries[0] : null, + }; + } + + _plexMappingIssue(folder, { mappedLibraryId, suggestedLibraryId }, byId, config) { + if (!suggestedLibraryId || mappedLibraryId === suggestedLibraryId) return null; + const suggested = byId.get(suggestedLibraryId); + if (mappedLibraryId) { + const mapped = byId.get(mappedLibraryId); + return { + code: 'plexMappingMismatch', + libraryId: suggestedLibraryId, + message: `New episodes in ${folderLabel(folder.name)} refresh ${mapped ? mapped.name : `library ${mappedLibraryId}`}, ` + + `not ${suggested.name}. Change the subfolder mapping in Settings > Plex.`, + }; + } + const fallback = config.plexYoutubeLibraryId ? byId.get(String(config.plexYoutubeLibraryId)) : null; + return { + code: 'plexMappingMissing', + libraryId: suggestedLibraryId, + message: `New episodes in ${folderLabel(folder.name)} don't refresh ${suggested.name}` + + `${fallback ? `: Youtarr refreshes ${fallback.name} instead` : ''}.`, + }; + } + + /** + * Map a TV subfolder to the one Plex TV library that holds it, so new + * episodes refresh that library. Never replaces an existing mapping. + * @param {string} folder - Subfolder name without __ + * @param {string} libraryId + * @returns {Promise<{mappedLibraryId: string, plexSubfolderLibraryMappings: Array<Object>}>} + * with the saved mappings, so the client can take the change into its + * copy of the config without reloading it + */ + async applyPlexMapping(folder, libraryId) { + const result = await this.check({ folders: [folder] }); + const entry = result.folders[0]; + const plex = entry?.servers.find((server) => server.serverType === 'plex'); + if (!entry || !entry.name) throw this._error('Choose a TV subfolder to map.', 400); + if (!plex || plex.status === STATUS.UNREACHABLE) throw this._error('Plex isn\'t configured or can\'t be reached.', 409); + if (entry.layout !== LAYOUT_TV) throw this._error(`${folderLabel(entry.name)} isn't a TV folder.`, 400); + const { mappedLibraryId, suggestedLibraryId } = plex.plexMapping; + const config = configModule.getConfig(); + const mappings = Array.isArray(config.plexSubfolderLibraryMappings) ? config.plexSubfolderLibraryMappings : []; + if (mappedLibraryId === String(libraryId)) return { mappedLibraryId, plexSubfolderLibraryMappings: mappings }; + if (mappedLibraryId) throw this._error(`${folderLabel(entry.name)} already refreshes another Plex library. Change it in Settings > Plex.`, 409); + if (suggestedLibraryId !== String(libraryId)) { + throw this._error(`That Plex library isn't the one TV Shows library that holds ${folderLabel(entry.name)}.`, 409); + } + const plexSubfolderLibraryMappings = [...mappings, { subfolder: entry.name, libraryId: String(libraryId) }]; + configModule.updateConfig({ ...config, plexSubfolderLibraryMappings }); + logger.info({ libraryFolder: entry.name, libraryId }, 'Mapped a TV folder to its Plex library for refreshes'); + return { mappedLibraryId: String(libraryId), plexSubfolderLibraryMappings }; + } + + _error(message, status) { + const error = new Error(message); + error.status = status; + return error; + } +} + +module.exports = new LibraryCheck(); +module.exports.STATUS = STATUS; diff --git a/server/modules/mediaServers/libraryLocator.js b/server/modules/mediaServers/libraryLocator.js new file mode 100644 index 000000000..953589d48 --- /dev/null +++ b/server/modules/mediaServers/libraryLocator.js @@ -0,0 +1,99 @@ +/** + * Finds Youtarr's library folders among one media server's libraries: lists + * the libraries, samples a few files from each, pairs the samples with the + * downloads they are, and hands everything to libraryMatcher. + * + * scopeFor() is the cheap form for listings: the ids of the libraries that + * may hold Youtarr's files, or null when that can't be told (list everything). + */ + +const configModule = require('../configModule'); +const subfolderModule = require('../subfolderModule'); +const logger = require('../../logger'); +const { Video } = require('../../models'); +const { LIBRARY_TYPES, describeHttpError } = require('./adapters/baseAdapter'); +const { matchLibraries, mappingFromSample, youtubeIdOf, segmentsOf } = require('./libraryMatcher'); +const { folderKey } = require('../tvShows/constants'); + +// Files sampled per library: enough to land on a download in a library that +// mostly holds Youtarr's, few enough to stay one small request. +const SAMPLE_SIZE = 10; +const SUBFOLDER_PREFIX = /^__/; + +class LibraryLocator { + /** Youtarr's library folders: '' for the main folder, then every subfolder. */ + async folderNames() { + const subfolders = await subfolderModule.getAll(); + return ['', ...subfolders.map((display) => display.replace(SUBFOLDER_PREFIX, ''))]; + } + + async _pairSamples(serverPaths, folders) { + const ids = [...new Set(serverPaths.map(youtubeIdOf).filter(Boolean))]; + if (ids.length === 0) return []; + const videos = await Video.findAll({ where: { youtubeId: ids }, attributes: ['youtubeId', 'filePath', 'audioFilePath'] }); + const byId = new Map(videos.map((video) => [video.youtubeId, video])); + const subfolderKeys = new Map(folders.filter(Boolean).map((folder) => [folderKey(folder), folder])); + const rootSegments = segmentsOf(configModule.directoryPath); + const samples = []; + for (const serverPath of serverPaths) { + const video = byId.get(youtubeIdOf(serverPath)); + if (!video) continue; + // The copy with the same file name (a video and its MP3 share an id). + const containerPath = [video.filePath, video.audioFilePath] + .find((candidate) => candidate && mappingFromSample({ serverPath, containerPath: candidate }, rootSegments, subfolderKeys)); + if (containerPath) samples.push({ serverPath, containerPath }); + } + return samples; + } + + /** + * @param {Object} adapter - A media server adapter + * @param {string[]} [folders] - Library folder names; read from the registry when omitted + * @returns {Promise<{libraries: Array<Object>, match: Object, scope: Set<string>|null}>} + * libraryMatcher's result as match; scope is its scope widened by the + * libraries whose sample said nothing about their content + */ + async locate(adapter, folders) { + const names = folders || await this.folderNames(); + const libraries = await adapter.listLibraries(); + const serverPaths = []; + const inconclusive = []; + for (const library of libraries) { + if (library.type === LIBRARY_TYPES.OTHER) continue; + const sampled = await adapter.sampleItemPaths(library, SAMPLE_SIZE); + // An empty or unreadable library, or one holding YouTube downloads Youtarr + // can't place, may still be one of Youtarr's: only a library whose files + // are plainly something else is ruled out by its sample. + if (sampled.length === 0 || sampled.some((serverPath) => youtubeIdOf(serverPath))) inconclusive.push(String(library.id)); + serverPaths.push(...sampled); + } + const samples = await this._pairSamples(serverPaths, names); + const match = matchLibraries({ folders: names, libraries, samples, containerRoot: configModule.directoryPath }); + return { + libraries, + match, + scope: match.scope ? new Set([...match.scope, ...inconclusive]) : null, + }; + } + + /** + * The libraries of a server that may hold Youtarr's files, for listings + * that would otherwise read every library. Null (no limit) when the main + * folder can't be found on the server or the server can't be read. + * @param {Object} adapter + * @returns {Promise<Set<string>|null>} + */ + async scopeFor(adapter) { + try { + const { scope } = await this.locate(adapter); + return scope; + } catch (err) { + logger.debug({ ...describeHttpError(err), serverType: adapter.serverType }, + 'Could not find Youtarr\'s libraries on a media server; listing every library'); + return null; + } + } +} + +module.exports = new LibraryLocator(); +module.exports.SAMPLE_SIZE = SAMPLE_SIZE; diff --git a/server/modules/mediaServers/libraryMatcher.js b/server/modules/mediaServers/libraryMatcher.js new file mode 100644 index 000000000..3e34d9cf9 --- /dev/null +++ b/server/modules/mediaServers/libraryMatcher.js @@ -0,0 +1,226 @@ +/** + * Ties a media server's library locations to Youtarr's library folders. + * + * Server paths differ from Youtarr's (`Q:\Youtube_test\__TV Shows` on the + * server, `/usr/src/app/data/__TV Shows` in the container), so a folder is + * found on a server two ways: + * - by name: a location ending in `__<subfolder>` is that subfolder, and its + * parent is the main folder; + * - by content: a sampled item whose file Youtarr downloaded ([id] in its + * name) shows which server path holds which library folder, including a + * main folder whose name differs and a library mounted at the folder + * itself (`/tvshows` for `__TV Shows`). + * Once the main folder's server path is known, each subfolder is assumed to + * sit beneath it as `__<name>`. + * + * Pure: callers read the libraries, samples and Youtarr's files. + */ + +const { folderKey } = require('../tvShows/constants'); + +const SUBFOLDER_PREFIX = '__'; +const YOUTUBE_ID_IN_NAME = /\[([A-Za-z0-9_-]{11})\]\.[A-Za-z0-9]+$/; + +const RELATION_EXACT = 'exact'; +const RELATION_COVERS = 'covers'; +const RELATION_INSIDE = 'inside'; + +const SOURCE_NAME = 'name'; +const SOURCE_SAMPLE = 'sample'; +const SOURCE_DERIVED = 'derived'; + +function segmentsOf(p) { + return String(p || '').split(/[\\/]+/).filter(Boolean); +} + +function sameSegment(a, b) { + return a.toLowerCase() === b.toLowerCase(); +} + +function isPrefix(prefix, segments) { + return prefix.length <= segments.length && prefix.every((segment, i) => sameSegment(segment, segments[i])); +} + +function pathKey(segments) { + return segments.map((segment) => segment.toLowerCase()).join('/'); +} + +/** The YouTube id in a downloaded file's name, or null. */ +function youtubeIdOf(filePath) { + const base = segmentsOf(filePath).pop() || ''; + const match = YOUTUBE_ID_IN_NAME.exec(base); + return match ? match[1] : null; +} + +/** The library folder a subfolder segment names ('' for none). */ +function subfolderOfSegment(segment, subfolderKeys) { + if (!segment || !segment.startsWith(SUBFOLDER_PREFIX)) return null; + const name = segment.slice(SUBFOLDER_PREFIX.length); + return subfolderKeys.get(folderKey(name)) ?? null; +} + +class FolderPaths { + constructor() { + this.byFolder = new Map(); // folderKey -> Map(pathKey -> { segments, source }) + } + + add(folder, segments, source) { + if (segments.length === 0) return; + const key = folderKey(folder); + if (!this.byFolder.has(key)) this.byFolder.set(key, new Map()); + const paths = this.byFolder.get(key); + const existing = paths.get(pathKey(segments)); + // A path found by name or content outranks one only assumed. + if (!existing || existing.source === SOURCE_DERIVED) paths.set(pathKey(segments), { segments, source }); + } + + of(folder) { + return [...(this.byFolder.get(folderKey(folder))?.values() || [])]; + } +} + +/** + * Where a sampled server file and Youtarr's copy of it agree: the shared + * tail below Youtarr's downloads folder. The rest of each path is the folder + * that holds it on each side. + */ +function mappingFromSample({ serverPath, containerPath }, rootSegments, subfolderKeys) { + const server = segmentsOf(serverPath); + const container = segmentsOf(containerPath); + if (!isPrefix(rootSegments, container)) return null; + const below = container.length - rootSegments.length; + let shared = 0; + while (shared < below && shared < server.length + && sameSegment(server[server.length - 1 - shared], container[container.length - 1 - shared])) { + shared += 1; + } + // The file name at least, and one folder (the channel or show folder). + if (shared < 2) return null; + const containerPrefix = container.slice(0, container.length - shared); + const serverPrefix = server.slice(0, server.length - shared); + if (serverPrefix.length === 0) return null; + const extra = containerPrefix.slice(rootSegments.length); + const folder = extra.length === 0 ? '' : extra.length === 1 ? subfolderOfSegment(extra[0], subfolderKeys) : null; + if (folder === null) return null; + // The server still lists a copy in another of Youtarr's subfolders (a moved + // video before the server rescans): it says nothing about this folder. + const serverFolder = subfolderOfSegment(serverPrefix[serverPrefix.length - 1], subfolderKeys); + if (serverFolder !== null && folderKey(serverFolder) !== folderKey(folder)) return null; + return { folder, segments: serverPrefix }; +} + +/** + * @param {Object} params + * @param {string[]} params.folders - Youtarr's library folders ('' = main folder, else the subfolder name) + * @param {Array<{id: string, locations: string[]}>} params.libraries - The server's libraries + * @param {Array<{serverPath: string, containerPath: string}>} [params.samples] - Sampled server files + * paired with Youtarr's path of the same download + * @param {string} params.containerRoot - Youtarr's downloads folder + * @returns {{ + * relations: Array<{libraryId: string, location: string, folder: string, relation: string, source: string, + * folderSegmentMissing: boolean}>, + * folderPaths: (folder: string) => Array<{path: string, source: string}>, + * mainKnown: boolean, + * scope: Set<string>|null + * }} + * scope: ids of the libraries that may hold Youtarr's files, or null when + * the main folder can't be found on the server (then nothing can be ruled out) + */ +function matchLibraries({ folders, libraries, samples = [], containerRoot }) { + const subfolderKeys = new Map(); + for (const folder of folders) { + if (folder) subfolderKeys.set(folderKey(folder), folder); + } + const rootSegments = segmentsOf(containerRoot); + const paths = new FolderPaths(); + + for (const library of libraries) { + for (const location of library.locations || []) { + const segments = segmentsOf(location); + const folder = subfolderOfSegment(segments[segments.length - 1], subfolderKeys); + if (folder === null) continue; + paths.add(folder, segments, SOURCE_NAME); + paths.add('', segments.slice(0, -1), SOURCE_NAME); + } + } + for (const sample of samples) { + const mapping = mappingFromSample(sample, rootSegments, subfolderKeys); + if (mapping) paths.add(mapping.folder, mapping.segments, SOURCE_SAMPLE); + } + const mains = paths.of(''); + for (const folder of subfolderKeys.values()) { + for (const main of mains) { + paths.add(folder, [...main.segments, `${SUBFOLDER_PREFIX}${folder}`], SOURCE_DERIVED); + } + } + + const allFolders = ['', ...subfolderKeys.values()]; + const relations = []; + for (const library of libraries) { + for (const location of library.locations || []) { + const segments = segmentsOf(location); + for (const folder of allFolders) { + for (const known of paths.of(folder)) { + const relation = relationOf(segments, known.segments, folder); + if (!relation) continue; + relations.push({ + libraryId: String(library.id), + location, + folder, + relation, + source: known.source, + folderSegmentMissing: relation === RELATION_EXACT && Boolean(folder) + && !sameSegment(segments[segments.length - 1] || '', `${SUBFOLDER_PREFIX}${folder}`), + }); + } + } + } + } + + const mainKnown = mains.length > 0; + const scope = mainKnown ? new Set(relations.map((relation) => relation.libraryId)) : null; + return { + relations: dedupeRelations(relations), + folderPaths: (folder) => paths.of(folder).map((known) => ({ path: known.segments.join('/'), source: known.source })), + mainKnown, + scope, + }; +} + +function relationOf(location, folderPath, folder) { + if (location.length === folderPath.length && isPrefix(folderPath, location)) return RELATION_EXACT; + if (location.length < folderPath.length && isPrefix(location, folderPath)) return RELATION_COVERS; + if (location.length > folderPath.length && isPrefix(folderPath, location)) { + // Inside the main folder's own content only: a library below a __subfolder belongs to that subfolder. + if (!folder && location[folderPath.length].startsWith(SUBFOLDER_PREFIX)) return null; + return RELATION_INSIDE; + } + return null; +} + +// One relation of each kind per library, location and folder: the one found most directly. +function dedupeRelations(relations) { + const rank = { [SOURCE_NAME]: 0, [SOURCE_SAMPLE]: 1, [SOURCE_DERIVED]: 2 }; + const kept = new Map(); + for (const relation of relations) { + const key = JSON.stringify([ + relation.libraryId, relation.location.toLowerCase(), folderKey(relation.folder), relation.relation, + ]); + const existing = kept.get(key); + if (!existing || rank[relation.source] < rank[existing.source]) kept.set(key, relation); + } + return [...kept.values()]; +} + +module.exports = { + RELATION_EXACT, + RELATION_COVERS, + RELATION_INSIDE, + SOURCE_NAME, + SOURCE_SAMPLE, + SOURCE_DERIVED, + youtubeIdOf, + segmentsOf, + mappingFromSample, + matchLibraries +}; diff --git a/server/modules/mediaServers/mediaServerSync.js b/server/modules/mediaServers/mediaServerSync.js index c3f3a392e..51005bada 100644 --- a/server/modules/mediaServers/mediaServerSync.js +++ b/server/modules/mediaServers/mediaServerSync.js @@ -4,6 +4,7 @@ const serverRegistry = require('./serverRegistry'); const { MediaServerUnavailableError, describeHttpError } = require('./adapters/baseAdapter'); const { Playlist, PlaylistVideo, PlaylistSyncState, Video } = require('../../models'); const reorganizeLock = require('../reorganize/reorganizeLock'); +const libraryLocator = require('./libraryLocator'); // Backoff retry for resolving items after library scan. Tuned for typical Plex/Jellyfin // scan completion times — short initial delays, then longer as more time passes. @@ -153,9 +154,12 @@ class MediaServerSync { // ignore the hint (their refresh already covers every library). await adapter.triggerLibraryScan(null, { mediaType }); + // Plex lists whole sections each round, so only the ones that hold Youtarr's folders. + const libraryIds = serverType === 'plex' ? await libraryLocator.scopeFor(adapter) : null; const resolvedByPath = await this._resolveAllWithBackoff( adapter, - entries.map((entry) => entry.filePath) + entries.map((entry) => entry.filePath), + { libraryIds } ); const itemIds = []; for (const entry of entries) { @@ -235,7 +239,7 @@ class MediaServerSync { // (Plex) this costs (sections x rounds) listing fetches instead of // (sections x files x rounds). Returns Map<filePath, itemId> for resolved // paths only. - async _resolveAllWithBackoff(adapter, filepaths) { + async _resolveAllWithBackoff(adapter, filepaths, { libraryIds = null } = {}) { const resolved = new Map(); let pending = [...new Set(filepaths)]; const collect = (results) => { @@ -246,11 +250,11 @@ class MediaServerSync { }; if (!pending.length) return resolved; - collect(await adapter.resolveItemIdsByFilepaths(pending)); + collect(await adapter.resolveItemIdsByFilepaths(pending, { libraryIds })); for (const delay of POLL_BACKOFFS_MS) { if (!pending.length) break; await new Promise((r) => setTimeout(r, delay)); - collect(await adapter.resolveItemIdsByFilepaths(pending)); + collect(await adapter.resolveItemIdsByFilepaths(pending, { libraryIds })); } return resolved; } diff --git a/server/modules/mediaServers/watchStatusPushBack.js b/server/modules/mediaServers/watchStatusPushBack.js index 2dacc39e1..b3e596a42 100644 --- a/server/modules/mediaServers/watchStatusPushBack.js +++ b/server/modules/mediaServers/watchStatusPushBack.js @@ -24,6 +24,7 @@ const logger = require('../../logger'); const { describeHttpError } = require('./adapters/baseAdapter'); const { HOLD_STATE } = require('./watchStatusHolds'); const { isAtLeast, isLaterWatch } = require('./watchStateCompare'); +const libraryLocator = require('./libraryLocator'); // A sync retries a hold whose last push did not take at most this often. const PUSH_RETRY_INTERVAL_MS = 60 * 60 * 1000; @@ -123,7 +124,8 @@ async function pushPendingHolds({ holdIds = null, onlyDue = true, now = new Date let matches; try { const paths = [...new Set(serverHolds.flatMap((hold) => [pathOf.get(hold.video_id), fromPathOf(hold)]).filter(Boolean))]; - matches = await adapter.resolveItemMatchesByPaths(paths); + // Only the libraries that hold Youtarr's folders, not every item on the server. + matches = await adapter.resolveItemMatchesByPaths(paths, { libraryIds: await libraryLocator.scopeFor(adapter) }); } catch (err) { logger.warn({ err: err && err.isAxiosError ? describeHttpError(err) : err, serverType }, 'Could not look up moved files on the media server'); for (const hold of serverHolds) await recordAttempt(hold, errorMessage(err), now); diff --git a/server/modules/mediaServers/watchStatusSync.js b/server/modules/mediaServers/watchStatusSync.js index 6407dce4e..08ceb72e5 100644 --- a/server/modules/mediaServers/watchStatusSync.js +++ b/server/modules/mediaServers/watchStatusSync.js @@ -13,6 +13,7 @@ const { const { Video, VideoWatchStatus, MediaServerUser, WatchStatusSyncCursor } = require('../../models'); const watchStatusHolds = require('./watchStatusHolds'); const watchStatusPushBack = require('./watchStatusPushBack'); +const libraryLocator = require('./libraryLocator'); // Rows per bulk upsert statement; keeps a 10k-video library from producing one // giant INSERT. @@ -143,7 +144,7 @@ class WatchStatusSync { for (const adapter of adapters) { const serverType = adapter.serverType; try { - const opts = serverType === 'plex' ? await this._plexFetchOpts() : {}; + const opts = serverType === 'plex' ? await this._plexFetchOpts(adapter) : {}; const { entries, users, historyCursor, completeUserIds } = await adapter.fetchWatchStates(opts); const listed = this._matchVideos(videos, entries, { requireCurrentCopy: !!completeUserIds }); const cleared = await this._clearedMatches(serverType, completeUserIds, videos, listed); @@ -206,7 +207,9 @@ class WatchStatusSync { // account ids so the adapter can detect a new account and backfill it with // a full pull. since is null on the first run (full history pull); deleting // the cursor row forces a full re-scan. - async _plexFetchOpts() { + // Plex lists whole sections (every episode of a TV section), so its + // listings are limited to the sections that hold Youtarr's folders. + async _plexFetchOpts(adapter) { const row = await WatchStatusSyncCursor.findOne({ where: { server_type: 'plex' } }); const since = row && row.cursor ? new Date(new Date(row.cursor).getTime() - WATERMARK_OVERLAP_MS) @@ -216,7 +219,8 @@ class WatchStatusSync { attributes: ['server_user_id'], raw: true, }); - return { since, knownUserIds: knownUsers.map((u) => u.server_user_id) }; + const libraryIds = await libraryLocator.scopeFor(adapter); + return { since, knownUserIds: knownUsers.map((u) => u.server_user_id), libraryIds }; } // Account directory upsert; adapters return [] in single-user mode so diff --git a/server/modules/reorganize/__tests__/followUp.test.js b/server/modules/reorganize/__tests__/followUp.test.js index c62133d80..63f38ee01 100644 --- a/server/modules/reorganize/__tests__/followUp.test.js +++ b/server/modules/reorganize/__tests__/followUp.test.js @@ -75,6 +75,49 @@ describe('reorganize followUp', () => { expect(require('../../filesystem/showFolderCleanup').cleanupOrphanShowFolder).toHaveBeenCalledWith('/data/__TV/Other'); }); + describe('a show folder that is a channel folder again', () => { + const sameFolder = { + ...toVideos, + plan: { + ...toVideos.plan, + oldVideoPath: '/data/__Kids/Other/Season 2024/S2024E02 - U [bbbbbbbbbbb].mp4', + newVideoPath: '/data/__Kids/Other/Other - U - bbbbbbbbbbb/Other - U [bbbbbbbbbbb].mp4', + fromLibraryFolder: 'Kids', + libraryFolder: 'Kids', + }, + }; + let readFile; + let unlink; + + beforeEach(() => { + const fs = require('fs'); + readFile = jest.spyOn(fs.promises, 'readFile'); + unlink = jest.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined); + }); + + afterEach(() => { + readFile.mockRestore(); + unlink.mockRestore(); + }); + + it('loses Youtarr\'s tvshow.nfo', async () => { + readFile.mockResolvedValue('<tvshow>\n <uniqueid type="youtube" default="true">UC2</uniqueid>\n</tvshow>\n'); + + await followUp.finishFiles({ items: [sameFolder], shows: [] }); + + expect(readFile).toHaveBeenCalledWith('/data/__Kids/Other/tvshow.nfo', 'utf8'); + expect(unlink).toHaveBeenCalledWith('/data/__Kids/Other/tvshow.nfo'); + }); + + it('keeps a tvshow.nfo Youtarr didn\'t write', async () => { + readFile.mockResolvedValue('<tvshow><title>My own show'); + + await followUp.finishFiles({ items: [sameFolder], shows: [] }); + + expect(unlink).not.toHaveBeenCalled(); + }); + }); + it('regenerates the .m3u of channels now saving to a Videos folder', async () => { await followUp.finishFiles({ items: [toVideos], shows: [] }); @@ -103,6 +146,20 @@ describe('reorganize followUp', () => { expect(require('../../mediaServers/watchStatusPushBack').scheduleFollowUps).toHaveBeenCalled(); }); + it('syncs the playlists again once the servers have had time to index the moved files', async () => { + const scheduled = []; + const schedule = jest.fn((fn, ms) => { scheduled.push({ fn, ms }); return { unref: jest.fn() }; }); + const { syncPlaylist } = require('../../mediaServers/mediaServerSync'); + + await followUp.finishServers({ items: [toTv], resync: { schedule } }); + expect(scheduled.map((entry) => entry.ms)).toEqual(followUp.PLAYLIST_RESYNC_DELAYS_MS); + syncPlaylist.mockClear(); + scheduled[0].fn(); + + await new Promise((resolve) => setImmediate(resolve)); + expect(syncPlaylist).toHaveBeenCalledWith(4); + }); + it('schedules push-back after a resumed run too, whose holds predate the restart', async () => { await followUp.finishServers({ items: [toTv] }); diff --git a/server/modules/reorganize/__tests__/planner.test.js b/server/modules/reorganize/__tests__/planner.test.js index 5425ba4e0..fa61c5d1c 100644 --- a/server/modules/reorganize/__tests__/planner.test.js +++ b/server/modules/reorganize/__tests__/planner.test.js @@ -72,6 +72,19 @@ describe('reorganize planner', () => { }); }); + it('names the TV folders videos move into', async () => { + require('../destinationPlanner').planDestinations.mockResolvedValue({ + items: [item({ libraryFolder: 'TV' }), item({ youtubeId: 'ccccccccccc', libraryFolder: '', layout: 'tv' }), item({ youtubeId: 'ddddddddddd', layout: 'videos', libraryFolder: 'Kids' })], + problems: [], + unchanged: 0, + }); + const plan = await planner.buildPlan({ type: 'channel' }); + + const preview = await planner.summarizePlan(plan); + + expect(preview.tvFolders).toEqual(['TV', '']); + }); + it('counts videos whose watch state the servers will lose, leaving out other Plex accounts', async () => { VideoWatchStatus.findAll.mockResolvedValue([ { video_id: 1, server_type: 'jellyfin', server_user_id: 'u1', played: true }, diff --git a/server/modules/reorganize/followUp.js b/server/modules/reorganize/followUp.js index 9957eecc6..820d104e4 100644 --- a/server/modules/reorganize/followUp.js +++ b/server/modules/reorganize/followUp.js @@ -10,11 +10,13 @@ * * After the lock is released (servers and playlists, slow and network-bound): * - the __playlists__ .m3u files and media-server playlists that contain a - * moved video are regenerated and re-synced; + * moved video are regenerated and re-synced, and the playlists again 5 and + * 15 minutes later, once the servers have indexed the moved files; * - the old and new libraries are refreshed; * - held watch state is pushed back to the servers. */ +const fs = require('fs'); const path = require('path'); const configModule = require('../configModule'); const plexModule = require('../plexModule'); @@ -33,6 +35,31 @@ const { cleanupOrphanShowFolder, resolveLibraryFolder } = require('../filesystem const { LAYOUT_TV } = require('../tvShows/constants'); const { showDirectory } = require('../tvShows/channelFolders'); +// Playlists are synced again once the servers have had time to index the +// moved files: a move that keeps file names (between two TV folders) first +// resolves to the stale item at the old path, which the server then drops. +const PLAYLIST_RESYNC_DELAYS_MS = [5 * 60 * 1000, 15 * 60 * 1000]; + +const TVSHOW_NFO = 'tvshow.nfo'; +// How Youtarr's tvshow.nfo names the channel (tvNfo.buildTvShowNfo). +const YOUTARR_SHOW_NFO_MARKER = ' removeShowNfo(folderPath)); } for (const [folderPath, layout] of leftFolders) { await safely('Could not remove a folder a reorganize emptied', { folderPath }, async () => { @@ -118,12 +146,27 @@ async function refreshLibraries(libraryFolders) { } } +function schedulePlaylistResyncs(youtubeIds, { delaysMs = PLAYLIST_RESYNC_DELAYS_MS, schedule = setTimeout } = {}) { + if (youtubeIds.length === 0) return; + for (const delay of delaysMs) { + const timer = schedule(() => { + refreshPlaylists(youtubeIds).catch((err) => { + logger.error({ err }, 'Could not re-sync playlists after a reorganize'); + }); + }, delay); + if (timer && typeof timer.unref === 'function') timer.unref(); + } +} + /** * @param {Object} params * @param {Array} params.items - Done items, each { youtubeId, plan } + * @param {Object} [params.resync] - schedulePlaylistResyncs options (tests) */ -async function finishServers({ items }) { - await refreshPlaylists(items.map((item) => item.youtubeId)); +async function finishServers({ items, resync }) { + const youtubeIds = items.map((item) => item.youtubeId); + await refreshPlaylists(youtubeIds); + schedulePlaylistResyncs(youtubeIds, resync); const folders = new Set(); for (const { plan } of items) { folders.add(plan.fromLibraryFolder || ''); @@ -136,6 +179,7 @@ async function finishServers({ items }) { } module.exports = { + PLAYLIST_RESYNC_DELAYS_MS, finishFiles, finishServers, rootFolderOf diff --git a/server/modules/reorganize/planner.js b/server/modules/reorganize/planner.js index 6bacfbd8d..b4dff713f 100644 --- a/server/modules/reorganize/planner.js +++ b/server/modules/reorganize/planner.js @@ -144,6 +144,8 @@ async function summarizePlan(plan, { blocked = null } = {}) { shows: plan.shows.map((show) => ({ name: show.name, libraryFolder: show.libraryFolder, folderName: show.folderName, action: show.action, })), + // The TV folders videos move into: the preview shows their media server libraries. + tvFolders: [...new Set(items.filter((item) => item.layout === LAYOUT_TV).map((item) => item.libraryFolder || ''))], items: items.slice(0, PREVIEW_ITEM_LIMIT).map(describeItem), problems: problems.slice(0, PREVIEW_ITEM_LIMIT).map((problem) => ({ ...problem, detail: relative(problem.detail) })), watchState: await watchStateAtRisk(items), diff --git a/server/routes/__tests__/tvShows.test.js b/server/routes/__tests__/tvShows.test.js index 41ace4292..0bf4e8b3b 100644 --- a/server/routes/__tests__/tvShows.test.js +++ b/server/routes/__tests__/tvShows.test.js @@ -16,6 +16,7 @@ describe('TV show routes', () => { let jobModule; let models; let reorganize; + let libraryCheck; const channel = { channel_id: 'UC1', sub_folder: 'Kids' }; const REORGANIZE_STATE = { running: false, unmoved: null }; @@ -35,6 +36,10 @@ describe('TV show routes', () => { jobModule = { getInProgressJobId: jest.fn().mockReturnValue(null) }; models = { Channel: { findOne: jest.fn().mockResolvedValue(channel) } }; reorganize = { channelState: jest.fn().mockResolvedValue(REORGANIZE_STATE) }; + libraryCheck = { + check: jest.fn().mockResolvedValue({ servers: [], folders: [] }), + applyPlexMapping: jest.fn().mockResolvedValue({ mappedLibraryId: '41' }), + }; const createTvShowRoutes = require('../tvShows'); app = express(); app.use(express.json()); @@ -52,9 +57,86 @@ describe('TV show routes', () => { channelSettingsModule, jobModule, models, + libraryCheck, })); }); + describe('GET /api/library-folders/check', () => { + test('checks every folder without a folder parameter', async () => { + const res = await request(app).get('/api/library-folders/check'); + + expect(res.status).toBe(200); + expect(libraryCheck.check).toHaveBeenCalledWith({}); + }); + + test('checks only the folders asked for, including the main folder', async () => { + await request(app).get('/api/library-folders/check?folder=TV%20Shows&folder='); + + expect(libraryCheck.check).toHaveBeenCalledWith({ folders: ['TV Shows', ''] }); + }); + + test('rejects an overlong folder name', async () => { + const res = await request(app).get(`/api/library-folders/check?folder=${'x'.repeat(101)}`); + + expect(res.status).toBe(400); + }); + + test('checks the folders asked for as another layout', async () => { + await request(app).get('/api/library-folders/check?folder=TV%20Shows&layout=tv'); + + expect(libraryCheck.check).toHaveBeenCalledWith({ folders: ['TV Shows'], layout: 'tv' }); + }); + + test.each([ + ['an unknown layout', '?folder=TV&layout=movies'], + ['a layout without folders', '?layout=tv'], + ])('rejects %s', async (_label, query) => { + const res = await request(app).get(`/api/library-folders/check${query}`); + + expect(res.status).toBe(400); + expect(libraryCheck.check).not.toHaveBeenCalled(); + }); + + test('answers 500 when the check fails unexpectedly', async () => { + libraryCheck.check.mockRejectedValue(new Error('boom')); + + const res = await request(app).get('/api/library-folders/check'); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to check the media server libraries' }); + }); + }); + + describe('PUT /api/library-folders/plex-mapping', () => { + test('maps the folder to the library', async () => { + const res = await request(app).put('/api/library-folders/plex-mapping').send({ folder: 'TV Shows', libraryId: '41' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ mappedLibraryId: '41' }); + expect(libraryCheck.applyPlexMapping).toHaveBeenCalledWith('TV Shows', '41'); + }); + + test.each([ + [{ folder: '', libraryId: '41' }], + [{ folder: 'TV', libraryId: 'abc' }], + [{ folder: 'TV' }], + ])('rejects %o', async (body) => { + const res = await request(app).put('/api/library-folders/plex-mapping').send(body); + + expect(res.status).toBe(400); + expect(libraryCheck.applyPlexMapping).not.toHaveBeenCalled(); + }); + + test('passes the check\'s refusal through', async () => { + libraryCheck.applyPlexMapping.mockRejectedValue(refusal('__TV already refreshes another Plex library.', 409)); + + const res = await request(app).put('/api/library-folders/plex-mapping').send({ folder: 'TV', libraryId: '41' }); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: '__TV already refreshes another Plex library.' }); + }); + }); + describe('GET /api/library-folders', () => { test('returns the folder list', async () => { const res = await request(app).get('/api/library-folders'); diff --git a/server/routes/index.js b/server/routes/index.js index b897fa6c7..2a662155a 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -51,6 +51,7 @@ const layoutGuards = require('../modules/tvShows/layoutGuards'); const reorganize = require('../modules/reorganize'); const watchStatusHolds = require('../modules/mediaServers/watchStatusHolds'); const watchStatusPushBack = require('../modules/mediaServers/watchStatusPushBack'); +const libraryCheck = require('../modules/mediaServers/libraryCheck'); /** * Registers all route modules with the Express app @@ -161,6 +162,7 @@ function registerRoutes(app, deps) { // TV show layouts (library folders and channel layouts) app.use(createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, layoutGuards, reorganize, channelSettingsModule, jobModule, models, + libraryCheck, })); // Reorganize (moving downloads between the Videos and TV layouts) and watch-state restores diff --git a/server/routes/tvShows.js b/server/routes/tvShows.js index e2b997d39..ec3379c22 100644 --- a/server/routes/tvShows.js +++ b/server/routes/tvShows.js @@ -2,6 +2,7 @@ const express = require('express'); const logger = require('../logger'); const MAX_FOLDER_NAME_LENGTH = 100; +const LIBRARY_LAYOUTS = new Set(['videos', 'tv']); /** * TV show routes: library folder layouts and per-channel TV layout. @@ -14,10 +15,12 @@ const MAX_FOLDER_NAME_LENGTH = 100; * @param {Object} deps.channelSettingsModule * @param {Object} deps.jobModule - Its running job blocks layout switches * @param {Object} deps.models + * @param {Object} deps.libraryCheck - mediaServers/libraryCheck * @returns {express.Router} */ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, layoutGuards, reorganize, channelSettingsModule, jobModule, models, + libraryCheck, }) { const router = express.Router(); const isDownloadRunning = () => Boolean(jobModule.getInProgressJobId()); @@ -109,6 +112,143 @@ function createTvShowRoutes({ } }); + /** + * @swagger + * /api/library-folders/check: + * get: + * summary: Check the media server libraries that hold each library folder + * description: For each library folder and each configured media server (Plex, Jellyfin, Emby), the libraries that hold the folder and what works against Youtarr's files there - no library of the right kind, a library of the wrong type, the Plex Series agent or a legacy Plex agent, a Jellyfin/Emby library that saves NFO files or looks items up online, another library showing the same files again, or a library mounted at the folder under another name. Server paths differ from Youtarr's, so a folder is found by its __name and by matching a few files from each library to Youtarr's downloads. A TV subfolder held by exactly one Plex TV library also reports its Plex refresh mapping. + * tags: [TV Shows] + * parameters: + * - in: query + * name: folder + * required: false + * schema: + * type: array + * items: { type: string } + * description: Only report these folders ("" for the main folder); repeat for more than one + * - in: query + * name: layout + * required: false + * schema: + * type: string + * enum: [videos, tv] + * description: Check the folders given as this layout instead of their saved one (the reorganize preview checks the folders videos are about to move into as TV folders). Needs folder. + * responses: + * 200: + * description: Per-server reachability and a report per folder + * content: + * application/json: + * schema: + * type: object + * properties: + * servers: + * type: array + * items: + * type: object + * properties: + * serverType: { type: string, enum: [plex, jellyfin, emby] } + * name: { type: string } + * reachable: { type: boolean } + * error: { type: string, nullable: true } + * folders: + * type: array + * items: + * type: object + * properties: + * name: { type: string } + * layout: { type: string, enum: [videos, tv] } + * hasFiles: { type: boolean } + * channels: { type: integer } + * servers: + * type: array + * items: + * type: object + * properties: + * serverType: { type: string } + * status: { type: string, enum: [ok, warning, missing, unreachable] } + * libraries: + * type: array + * items: + * type: object + * properties: + * id: { type: string } + * name: { type: string } + * type: { type: string, enum: [videos, tv, mixed, music, other] } + * location: { type: string } + * relation: { type: string, enum: [exact, covers, inside] } + * issues: + * type: array + * items: + * type: object + * properties: + * code: { type: string } + * message: { type: string } + * libraryId: { type: string } + * plexMapping: + * type: object + * description: Plex only, TV subfolders only + * properties: + * mappedLibraryId: { type: string, nullable: true } + * suggestedLibraryId: { type: string, nullable: true } + * 400: { description: Invalid folder parameter } + * 500: { description: Failed to check the media server libraries } + */ + router.get('/api/library-folders/check', verifyToken, async (req, res) => { + const raw = req.query.folder; + const folders = raw === undefined ? null : [].concat(raw); + if (folders && folders.some((name) => typeof name !== 'string' || name.length > MAX_FOLDER_NAME_LENGTH)) { + return res.status(400).json({ error: 'folder must be a folder name ("" for the main folder)' }); + } + const layout = req.query.layout; + if (layout !== undefined && (!folders || !LIBRARY_LAYOUTS.has(layout))) { + return res.status(400).json({ error: 'layout must be "videos" or "tv", for the folders given' }); + } + try { + return res.json(await libraryCheck.check(folders ? { folders, ...(layout ? { layout } : {}) } : {})); + } catch (error) { + return sendError(res, error, 'Failed to check the media server libraries'); + } + }); + + /** + * @swagger + * /api/library-folders/plex-mapping: + * put: + * summary: Map a TV folder to its Plex library for refreshes + * description: Adds a Plex subfolder library mapping so new episodes in a TV subfolder refresh the one Plex TV Shows library that holds it (as the library check reports it). An existing mapping for the folder is never replaced. + * tags: [TV Shows] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [folder, libraryId] + * properties: + * folder: { type: string, description: Subfolder name without __ } + * libraryId: { type: string } + * responses: + * 200: { description: "The mapping, as { mappedLibraryId, plexSubfolderLibraryMappings } with the saved mappings" } + * 400: { description: Invalid folder or library id, or the folder isn't a TV subfolder } + * 409: { description: "Plex can't be reached, the folder already has another mapping, or that library isn't the one Plex TV library holding the folder" } + * 500: { description: Failed to save the mapping } + */ + router.put('/api/library-folders/plex-mapping', verifyToken, async (req, res) => { + const { folder, libraryId } = req.body || {}; + if (typeof folder !== 'string' || !folder.trim() || folder.length > MAX_FOLDER_NAME_LENGTH) { + return res.status(400).json({ error: 'folder must be a subfolder name' }); + } + if (typeof libraryId !== 'string' || !/^\d+$/.test(libraryId)) { + return res.status(400).json({ error: 'libraryId must be a Plex library id' }); + } + try { + return res.json(await libraryCheck.applyPlexMapping(folder.trim(), libraryId)); + } catch (error) { + return sendError(res, error, 'Failed to save the Plex library mapping', { folder, libraryId }); + } + }); + /** * @swagger * /api/channels/{channelId}/tv: From d04afbd5fea73d9ab141d4c9aeb51819a3a4287e Mon Sep 17 00:00:00 2001 From: dialmaster Date: Mon, 5 Oct 2026 07:17:33 -0700 Subject: [PATCH 06/12] feat(tv): add title shows for series in a channel A channel that uploads several series (Hermitcraft seasons, one cartoon in four languages) could only be saved as a single show numbered by upload date. Title shows are series inside a channel, defined by title patterns and numbered from the titles: season and episode taken from the title, a fixed season, the next number in the season, or the upload year. Each show has its own folder in a TV folder; videos no show takes follow the channel's layout. - Edit shows in Channel Settings > TV Show: patterns in a simple syntax ({season}, {episode}, {title}, *) or as Python regular expressions, matched by Python's re as yt-dlp does, exclude terms, season names and the TV folder, with a live preview of episodes, duplicates, gaps, unmatched titles and compilations or parts not supported yet. - When two uploads claim one number, the stored holder keeps it, else the earliest available upload. A duplicate that isn't downloaded is ignored and its complete.list line written, deferred while a download runs; episode_conflicts records what Youtarr did, so a release undoes only that. A downloaded duplicate stays where it is. "Use this copy instead", a manual episode and "Not an episode" override the classification. - A save that changes a downloaded episode's show, number or file name opens the move review and runs as a reorganize, which restores the previous shows and rows when no video moves. Channel and folder layout switches leave title show episodes in place, and a folder holding title shows can't switch to Videos. - Classify new downloads at finalize and new uploads on listing refresh. An optional switch limits channel downloads and Download All to show episodes, with one --match-filter per show. - Write a youtarr uniqueid and to tvshow.nfo, and season.nfo to named season folders. - Add a show filter, a Missing episodes view and planned episode chips to the channel page, and an "N shows" chip to Subscriptions and the channel header. - Show a file sitting where a destination folder belongs as a collision in the move review instead of failing the preview, and say when a video's files moved but finishing it failed. - Add tv_show_patterns, tv_show_seasons, episode_conflicts and channels.tv_show_only_downloads; the routes under /api/channels/:channelId/tv/shows, preview, show-only, conflicts and recheck; and GET/PUT /api/videos/:youtubeId/episode. Document title shows in the usage and folder structure guides. Refs: #175 --- CLAUDE.md | 11 +- client/src/components/ChannelPage.tsx | 27 +- .../ChannelPage/ChannelSettingsDialog.tsx | 36 +- .../components/ChannelPage/ChannelVideos.tsx | 41 +- .../src/components/ChannelPage/VideoCard.tsx | 2 +- .../components/ChannelPage/VideoListItem.tsx | 2 +- .../components/ChannelPage/VideoTableView.tsx | 2 +- .../__tests__/ChannelSettingsDialog.test.tsx | 22 +- .../__tests__/ChannelVideos.test.tsx | 86 ++++ .../ChannelPage/__tests__/VideoCard.test.tsx | 6 + .../__tests__/VideoListItem.test.tsx | 6 + .../__tests__/VideoTableView.test.tsx | 6 + .../TitleShows/ChannelShowFilter.tsx | 40 ++ .../components/TitleShows/ConflictList.tsx | 65 +++ .../TitleShows/MissingEpisodesDialog.tsx | 67 +++ .../components/TitleShows/PatternRow.tsx | 127 +++++ .../TitleShows/SeasonNamesEditor.tsx | 100 ++++ .../TitleShows/TitleShowEditorDialog.tsx | 206 ++++++++ .../components/TitleShows/TitleShowList.tsx | 86 ++++ .../TitleShows/TitleShowPreviewTabs.tsx | 185 +++++++ .../TitleShows/TitleShowsSection.tsx | 199 ++++++++ .../__tests__/ChannelShowFilter.test.tsx | 25 + .../__tests__/ConflictList.test.tsx | 57 +++ .../__tests__/MissingEpisodesDialog.test.tsx | 52 ++ .../TitleShows/__tests__/PatternRow.test.tsx | 112 +++++ .../__tests__/SeasonNamesEditor.test.tsx | 53 +++ .../__tests__/TitleShowEditorDialog.test.tsx | 145 ++++++ .../__tests__/TitleShowList.test.tsx | 59 +++ .../__tests__/TitleShowPreviewTabs.test.tsx | 135 ++++++ .../__tests__/TitleShowsSection.test.tsx | 216 +++++++++ .../__tests__/useTitleShowForm.test.ts | 43 ++ .../components/TitleShows/useTitleShowForm.ts | 72 +++ .../hooks/__tests__/useChannelVideos.test.ts | 11 + .../__tests__/useMissingEpisodes.test.ts | 36 ++ .../__tests__/useTitleShowPreview.test.ts | 100 ++++ .../hooks/__tests__/useTitleShows.test.ts | 144 ++++++ .../ChannelPage/hooks/useChannelVideos.ts | 8 +- .../ChannelPage/hooks/useMissingEpisodes.ts | 37 ++ .../ChannelPage/hooks/useTitleShowPreview.ts | 74 +++ .../ChannelPage/hooks/useTitleShows.ts | 152 ++++++ .../Subscriptions/components/ChannelCard.tsx | 3 +- .../components/ChannelListRow.tsx | 4 +- .../components/__tests__/ChannelCard.test.tsx | 9 + .../__tests__/ChannelListRow.test.tsx | 9 + .../components/chips/ShowsChip.tsx | 27 ++ .../chips/__tests__/ShowsChip.test.tsx | 21 + .../Subscriptions/components/chips/index.ts | 1 + .../components/__tests__/ChannelPage.test.tsx | 95 ++++ .../EpisodeAssign/EpisodeAssignDialog.tsx | 204 ++++++++ .../__tests__/EpisodeAssignDialog.test.tsx | 224 +++++++++ .../__tests__/useVideoEpisode.test.ts | 59 +++ .../shared/EpisodeAssign/useVideoEpisode.ts | 61 +++ client/src/components/shared/EpisodeChip.tsx | 13 +- .../Reorganize/ReorganizeOperationBody.tsx | 16 +- .../Reorganize/ReorganizePreviewBody.tsx | 5 + .../ReorganizeOperationBody.test.tsx | 23 + .../__tests__/ReorganizePreviewBody.test.tsx | 14 + .../VideoModal/__tests__/VideoModal.test.tsx | 19 + .../components/VideoEpisodeSection.tsx | 48 ++ .../__tests__/VideoEpisodeSection.test.tsx | 66 +++ .../components/shared/VideoModal/index.tsx | 9 + .../src/components/shared/VideoModal/types.ts | 1 + .../shared/__tests__/EpisodeChip.test.tsx | 5 + .../useDownloadListingsRefresh.test.tsx | 20 + .../src/hooks/useDownloadListingsRefresh.ts | 19 +- client/src/types/Channel.ts | 2 + client/src/types/ChannelVideo.ts | 3 + client/src/types/reorganize.ts | 16 +- client/src/types/titleShows.ts | 219 +++++++++ .../src/utils/__tests__/seasonNumbers.test.ts | 19 + client/src/utils/seasonNumbers.ts | 16 + docs/DATABASE.md | 9 +- docs/USAGE_GUIDE.md | 24 + docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md | 20 + .../20261004184140-add-tv-title-shows.js | 127 +++++ .../server.additional-routes.test.js | 3 + server/__tests__/server.apikeys.test.js | 5 +- server/__tests__/server.auth-sessions.test.js | 5 +- server/__tests__/server.core.test.js | 3 + server/__tests__/server.plex-routes.test.js | 5 +- server/__tests__/server.routes.test.js | 44 +- server/models/channel.js | 6 + server/models/episodeconflict.js | 31 ++ server/models/index.js | 10 + server/models/tvshowpattern.js | 31 ++ server/models/tvshowseason.js | 24 + .../channelDownloadAllModule.test.js | 34 ++ .../__tests__/channelDownloadGrouper.test.js | 55 ++- .../modules/__tests__/channelModule.test.js | 8 +- .../modules/__tests__/downloadModule.test.js | 12 + server/modules/__tests__/jobModule.test.js | 20 + .../modules/__tests__/sidecarWriter.test.js | 20 +- .../__tests__/titleFilterRegex.test.js | 80 ++++ .../channel/__tests__/channelCatalog.test.js | 22 + .../__tests__/channelVideoFetcher.test.js | 1 + .../__tests__/channelVideoQuery.test.js | 27 ++ .../__tests__/channelVideoWriter.test.js | 39 ++ .../__tests__/channelVideosService.test.js | 33 ++ server/modules/channel/channelCatalog.js | 9 + server/modules/channel/channelVideoQuery.js | 28 +- server/modules/channel/channelVideoWriter.js | 23 + .../modules/channel/channelVideosService.js | 13 +- server/modules/channelDownloadAllModule.js | 24 +- server/modules/channelDownloadGrouper.js | 44 +- server/modules/channelModule.js | 4 +- .../__tests__/ytdlpCommandBuilder.test.js | 54 +++ .../modules/download/ytdlpCommandBuilder.js | 48 +- server/modules/downloadModule.js | 4 + server/modules/jobModule.js | 25 + .../__tests__/changeContext.test.js | 42 ++ .../reorganize/__tests__/changeScope.test.js | 18 + .../__tests__/destinationPlanner.test.js | 106 +++++ .../reorganize/__tests__/followUp.test.js | 18 +- .../reorganize/__tests__/itemExecutor.test.js | 32 ++ .../__tests__/operationRunner.test.js | 42 ++ .../__tests__/operationStore.test.js | 21 + .../reorganize/__tests__/planner.test.js | 53 +++ .../__tests__/settingsApplier.test.js | 40 ++ .../reorganize/__tests__/showPlanner.test.js | 35 ++ .../__tests__/titleAssignments.test.js | 108 +++++ .../__tests__/titleSnapshot.test.js | 171 +++++++ .../reorganize/__tests__/titleTargets.test.js | 128 +++++ server/modules/reorganize/changeContext.js | 32 ++ server/modules/reorganize/changeScope.js | 16 +- server/modules/reorganize/constants.js | 10 + .../modules/reorganize/destinationPlanner.js | 88 +++- server/modules/reorganize/followUp.js | 22 +- server/modules/reorganize/index.js | 6 +- server/modules/reorganize/itemExecutor.js | 17 +- server/modules/reorganize/operationRunner.js | 20 +- server/modules/reorganize/operationStore.js | 10 +- server/modules/reorganize/planner.js | 38 +- server/modules/reorganize/revision.js | 2 +- server/modules/reorganize/settingsApplier.js | 31 +- server/modules/reorganize/showPlanner.js | 27 +- server/modules/reorganize/titleAssignments.js | 111 +++++ server/modules/reorganize/titleSnapshot.js | 148 ++++++ server/modules/reorganize/titleTargets.js | 115 +++++ server/modules/sidecarWriter.js | 36 +- server/modules/titleFilterRegex.js | 142 ++++-- .../__tests__/archiveSuppressor.test.js | 128 +++++ .../__tests__/episodeConflicts.test.js | 220 +++++++++ .../__tests__/episodePlacement.test.js | 51 ++ .../tvShows/__tests__/layoutGuards.test.js | 18 + .../tvShows/__tests__/libraryFolders.test.js | 7 + .../tvShows/__tests__/patternCompiler.test.js | 199 ++++++++ .../tvShows/__tests__/showFolderNames.test.js | 33 ++ .../titleCorpusClassification.test.js | 83 ++++ .../__tests__/titleEpisodeAssigner.test.js | 181 +++++++ .../tvShows/__tests__/titleMatcher.test.js | 150 ++++++ .../tvShows/__tests__/titleNumbering.test.js | 384 +++++++++++++++ .../tvShows/__tests__/titlePlanner.test.js | 304 ++++++++++++ .../tvShows/__tests__/titlePreview.test.js | 125 +++++ .../tvShows/__tests__/titleRowWriter.test.js | 240 ++++++++++ .../tvShows/__tests__/titleShowDrafts.test.js | 153 ++++++ .../__tests__/titleShowQueries.test.js | 147 ++++++ .../tvShows/__tests__/titleShowSaver.test.js | 212 +++++++++ .../__tests__/titleShowService.test.js | 265 +++++++++++ .../tvShows/__tests__/titleShowStore.test.js | 219 +++++++++ .../modules/tvShows/__tests__/tvNfo.test.js | 41 ++ server/modules/tvShows/archiveSuppressor.js | 99 ++++ server/modules/tvShows/episodeConflicts.js | 203 ++++++++ server/modules/tvShows/episodePlacement.js | 73 ++- server/modules/tvShows/index.js | 19 +- server/modules/tvShows/layoutGuards.js | 21 +- server/modules/tvShows/libraryFolders.js | 1 + server/modules/tvShows/patternCompiler.js | 290 +++++++++++ server/modules/tvShows/showFolderNames.js | 51 ++ server/modules/tvShows/showStore.js | 47 +- .../modules/tvShows/titleEpisodeAssigner.js | 200 ++++++++ server/modules/tvShows/titleMatcher.js | 138 ++++++ server/modules/tvShows/titleNumbering.js | 307 ++++++++++++ server/modules/tvShows/titlePlanner.js | 280 +++++++++++ server/modules/tvShows/titlePreview.js | 139 ++++++ server/modules/tvShows/titleRowWriter.js | 227 +++++++++ server/modules/tvShows/titleShowDrafts.js | 217 +++++++++ server/modules/tvShows/titleShowQueries.js | 202 ++++++++ server/modules/tvShows/titleShowSaver.js | 205 ++++++++ server/modules/tvShows/titleShowService.js | 275 +++++++++++ server/modules/tvShows/titleShowStore.js | 311 ++++++++++++ server/modules/tvShows/tvNfo.js | 57 ++- server/routes/__tests__/tvTitleShows.test.js | 203 ++++++++ server/routes/channels.js | 14 +- server/routes/index.js | 5 + server/routes/tvTitleShows.js | 450 ++++++++++++++++++ server/server.js | 8 + server/utils/title-filter-regex.py | 90 +++- 187 files changed, 13772 insertions(+), 282 deletions(-) create mode 100644 client/src/components/ChannelPage/components/TitleShows/ChannelShowFilter.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/ConflictList.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/MissingEpisodesDialog.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/PatternRow.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/SeasonNamesEditor.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/TitleShowList.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/TitleShowPreviewTabs.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/TitleShowsSection.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/ChannelShowFilter.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/ConflictList.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/MissingEpisodesDialog.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/PatternRow.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/SeasonNamesEditor.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowEditorDialog.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowList.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowPreviewTabs.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowsSection.test.tsx create mode 100644 client/src/components/ChannelPage/components/TitleShows/__tests__/useTitleShowForm.test.ts create mode 100644 client/src/components/ChannelPage/components/TitleShows/useTitleShowForm.ts create mode 100644 client/src/components/ChannelPage/hooks/__tests__/useMissingEpisodes.test.ts create mode 100644 client/src/components/ChannelPage/hooks/__tests__/useTitleShowPreview.test.ts create mode 100644 client/src/components/ChannelPage/hooks/__tests__/useTitleShows.test.ts create mode 100644 client/src/components/ChannelPage/hooks/useMissingEpisodes.ts create mode 100644 client/src/components/ChannelPage/hooks/useTitleShowPreview.ts create mode 100644 client/src/components/ChannelPage/hooks/useTitleShows.ts create mode 100644 client/src/components/Subscriptions/components/chips/ShowsChip.tsx create mode 100644 client/src/components/Subscriptions/components/chips/__tests__/ShowsChip.test.tsx create mode 100644 client/src/components/shared/EpisodeAssign/EpisodeAssignDialog.tsx create mode 100644 client/src/components/shared/EpisodeAssign/__tests__/EpisodeAssignDialog.test.tsx create mode 100644 client/src/components/shared/EpisodeAssign/__tests__/useVideoEpisode.test.ts create mode 100644 client/src/components/shared/EpisodeAssign/useVideoEpisode.ts create mode 100644 client/src/components/shared/VideoModal/components/VideoEpisodeSection.tsx create mode 100644 client/src/components/shared/VideoModal/components/__tests__/VideoEpisodeSection.test.tsx create mode 100644 client/src/types/titleShows.ts create mode 100644 client/src/utils/__tests__/seasonNumbers.test.ts create mode 100644 client/src/utils/seasonNumbers.ts create mode 100644 migrations/20261004184140-add-tv-title-shows.js create mode 100644 server/models/episodeconflict.js create mode 100644 server/models/tvshowpattern.js create mode 100644 server/models/tvshowseason.js create mode 100644 server/modules/reorganize/__tests__/titleAssignments.test.js create mode 100644 server/modules/reorganize/__tests__/titleSnapshot.test.js create mode 100644 server/modules/reorganize/__tests__/titleTargets.test.js create mode 100644 server/modules/reorganize/titleAssignments.js create mode 100644 server/modules/reorganize/titleSnapshot.js create mode 100644 server/modules/reorganize/titleTargets.js create mode 100644 server/modules/tvShows/__tests__/archiveSuppressor.test.js create mode 100644 server/modules/tvShows/__tests__/episodeConflicts.test.js create mode 100644 server/modules/tvShows/__tests__/patternCompiler.test.js create mode 100644 server/modules/tvShows/__tests__/showFolderNames.test.js create mode 100644 server/modules/tvShows/__tests__/titleCorpusClassification.test.js create mode 100644 server/modules/tvShows/__tests__/titleEpisodeAssigner.test.js create mode 100644 server/modules/tvShows/__tests__/titleMatcher.test.js create mode 100644 server/modules/tvShows/__tests__/titleNumbering.test.js create mode 100644 server/modules/tvShows/__tests__/titlePlanner.test.js create mode 100644 server/modules/tvShows/__tests__/titlePreview.test.js create mode 100644 server/modules/tvShows/__tests__/titleRowWriter.test.js create mode 100644 server/modules/tvShows/__tests__/titleShowDrafts.test.js create mode 100644 server/modules/tvShows/__tests__/titleShowQueries.test.js create mode 100644 server/modules/tvShows/__tests__/titleShowSaver.test.js create mode 100644 server/modules/tvShows/__tests__/titleShowService.test.js create mode 100644 server/modules/tvShows/__tests__/titleShowStore.test.js create mode 100644 server/modules/tvShows/archiveSuppressor.js create mode 100644 server/modules/tvShows/episodeConflicts.js create mode 100644 server/modules/tvShows/patternCompiler.js create mode 100644 server/modules/tvShows/showFolderNames.js create mode 100644 server/modules/tvShows/titleEpisodeAssigner.js create mode 100644 server/modules/tvShows/titleMatcher.js create mode 100644 server/modules/tvShows/titleNumbering.js create mode 100644 server/modules/tvShows/titlePlanner.js create mode 100644 server/modules/tvShows/titlePreview.js create mode 100644 server/modules/tvShows/titleRowWriter.js create mode 100644 server/modules/tvShows/titleShowDrafts.js create mode 100644 server/modules/tvShows/titleShowQueries.js create mode 100644 server/modules/tvShows/titleShowSaver.js create mode 100644 server/modules/tvShows/titleShowService.js create mode 100644 server/modules/tvShows/titleShowStore.js create mode 100644 server/routes/__tests__/tvTitleShows.test.js create mode 100644 server/routes/tvTitleShows.js diff --git a/CLAUDE.md b/CLAUDE.md index ede9189b3..f4bb75637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,10 +34,10 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/cookieDetails.js`: summarizes the YouTube login cookies (names, counts, earliest expiry; never values) in the active cookie file as `details` on `GET /api/cookies/status` and the upload/delete responses, reading external files through `externalCookies.readExternalCookies`. `modules/cookieTest.js`: `POST /api/cookies/test`, one yt-dlp request to the subscriptions feed with `buildCommonArgs` (same proxy/IP family/cookies/cache as downloads) through `ytDlpRunner`, against a private temp copy of uploaded cookies (yt-dlp writes its jar back on exit, which would otherwise undo a replace/delete made mid-test; external files get spawnYtDlp's snapshot) and never logs stderr (yt-dlp echoes malformed cookie lines with their values); any exit 0 with JSON is signed in (even zero subscriptions), and the feed's "Incomplete yt initial data" warnings mean signed out (EXPIRED_COOKIES), a meaning specific to this feed, so it is not in the shared subscription-import classifier. One test at a time (409), rate-limited by `cookieTestRateLimiter`. - `server.js`: Express entry point. `db.js`: Sequelize setup. `logger.js`: Pino logger with request correlation. - `logger.js` + `logging/`: two transport targets pinned to `trace`, so the root level is the only filter and `logger.applyLevelSetting` can change it live. Only the server process (`process.argv[1]` is `server.js`; the yt-dlp post-processor, scripts, and tests never write files, since post-processor output already reaches the server log through yt-dlp's stdout) also writes the console format, without colors or embedded ANSI codes, to `config/logs/youtarr..log` through `logging/logFileTransport.js` (pino-roll with `removeOtherLogFiles`, so files from earlier runs are pruned too; `LOG_FILE_MAX_SIZE` default 10MB, `LOG_FILE_MAX_COUNT` older files kept, default 5). A file error prints one stderr notice and stops file output only; an unwritable folder is detected at startup and reported through `logger.getLoggingStatus()` (served as `logging` on `/getconfig`). Never log a field named `level`: it overwrites pino's level and the record is dropped. `modules/logLevelSync.js` applies the `logLevel` setting ('' = `LOG_LEVEL`) at startup, on config change, and in the post-processor. `modules/logFilesModule.js` + `routes/logs.js` serve `GET /api/logs/download` (all files oldest first, numeric order), read line by line through `logging/logScrubber.js`, which replaces the configured keys/tokens and Apprise notification URLs by value plus `X-Plex-Token`/`X-Emby-Token`/`api_key` values and URL userinfo (proxy credentials) with `[REDACTED]`; pino's redaction only covers top-level keys, and tokens reach the log inside logged axios errors and the logged yt-dlp args. The files on disk are not scrubbed. -- `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus, tvshow, videoclassification, tvreorganizeoperation, tvreorganizeitem, watchstatushold). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates, TvShow hasMany VideoClassifications, TvReorganizeOperation hasMany TvReorganizeItems. -- `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs, tvShows, tvReorganize). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. +- `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus, tvshow, videoclassification, tvshowpattern, tvshowseason, episodeconflict, tvreorganizeoperation, tvreorganizeitem, watchstatushold). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates, TvShow hasMany VideoClassifications, TvShowPatterns (`patterns`) and TvShowSeasons (`seasons`), TvReorganizeOperation hasMany TvReorganizeItems. +- `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs, tvShows, tvTitleShows, tvReorganize). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. - `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to @latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings; MP3 is downgraded to video for videos whose predicted folder has the TV layout), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths, active references, and TV shows with numbered episodes in the folder, not counting a show whose tracked channel has since moved back to a videos folder; reads layouts through `tvShows/libraryLayouts`, which therefore never imports it), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: `; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation, downgrading MP3 to video for URLs whose predicted folder has the TV layout; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). -- `modules/titleFilterRegex.js`: evaluates channel title filter regexes with Python's `re` (via `utils/title-filter-regex.py`, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's `--match-filter` exactly. Channel downloads apply the pattern through `ytdlpCommandBuilder.buildMatchFilters`, which escapes only `'` and `&` because yt-dlp's `match_str` unescapes nothing else. Playlist title filters are separate: `playlistModule.buildTitleFilterRegExp` evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile. +- `modules/titleFilterRegex.js`: evaluates channel title filter regexes with Python's `re` (via `utils/title-filter-regex.py`, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's `--match-filter` exactly. Channel downloads apply the pattern through `ytdlpCommandBuilder.buildMatchFilters`, which escapes only `'` and `&` because yt-dlp's `match_str` unescapes nothing else. The same script has a `check` mode (compile-checks patterns; title show saves) and a `classify` mode (many patterns, first match and its named groups per title; `classifyTitles`), and `buildMatchFilterList` adds one `--match-filter` per title show for the show-only switch (`ChannelFilterConfig.showFilters`), never OR'd across shows into one regex. Playlist title filters are separate: `playlistModule.buildTitleFilterRegExp` evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile. - `modules/storageUsage.js`: total bytes of downloaded videos (`SUM(file_size + audio_file_size)` over non-removed rows, never a disk walk) and the shared `STORED_BYTES_SQL` fragment the oldest-first auto-removal candidate queries use, so audio-only (MP3) videos count their real size. - `modules/storageGuard.js`: the storage download pause (issues #196/#217). Optional `downloadPauseUsageLimit` (storageUsage total) and `downloadPauseMinFreeSpace` (df) triggers, both off by default; a failed measurement never pauses (fail open). `assertDownloadsAllowed()` throws `DownloadsPausedError` (code `DOWNLOADS_PAUSED`) from `downloadModule`'s fresh-request entry points (`doChannelAndPlaylistDownloads`, `doChannelDownloads`, `doSpecificDownloads`, `doPlaylistDownloads`; queued jobs started with isNextJob skip it), which routes map to 409 via the injected `storageGuard.isPausedError` (Download New reaches `doChannelAndPlaylistDownloads` through the scheduler's `channelDownloadFrequency` task rather than calling it directly, so its own pause check runs first, inside `autoDownloadScheduler.getRunBlocker`, and a paused Download New request gets the `downloads-paused` reason instead of a bare 409); the scheduled sweep records a skipped run first, and a pause hit mid-sweep stops the playlist loop and is reported as `pausedReason` (not a failure); `jobModule.startNextJob` refreshes the guard (so every finished job re-checks) and holds Pending jobs while paused, restarting the queue on the guard's `resumed` event. `startNextJob` coalesces overlapping calls into one in-flight scan (the `resumed` event fires inside the refresh a scan awaits, and a channel job stays Pending while it builds groups, so two scans would start it twice) and catches the unawaited job action's rejection; a job whose action rejects while it is still Pending is marked `Error` by `failUnstartedJob`, which waits for the launching scan to settle and then scans again, so the queue never stalls behind it (the single-group channel job's pre-spawn failure likewise reports a `jobIssue` to its run and calls `startNextJob`). `subscriptionImport` calls `startNextJob` when an import finishes (the import occupies the queue but completes outside the download path, so jobs queued or held meanwhile would otherwise stay Pending). The downloaded-size SUM runs only when a usage limit is set; `GET /api/jobs/download-pause` opts in with `refresh({ includeUsage: true })` for the settings page. `/updateconfig` rejects size values other than blank or `<positive int>MB|GB|TB`; `configModule.normalizeStorageSizeFields` corrects or clears hand-edited ones on load (startup and file-watch reload), because the client saves the full config and one bad value would otherwise block every Settings save. On a paused-state flip it broadcasts `downloadPauseChanged`, sends `notificationModule.sendDownloadPauseNotification` (paused and resumed), and re-checks every 5 min while paused; deletions re-check when paused. `GET /api/jobs/download-pause` serves the status; the client shows it through `hooks/useDownloadPauseStatus`, `layout/DownloadPauseBanner` (every page, dismissible per pause except on `/downloads`), and `shared/DownloadPauseAlert`, with settings at `/settings/storage-limits` (`Configuration/sections/StorageLimitsSection.tsx`). - `modules/playlistDownloadModule.js`: shared eligibility for scheduled, download-all, explicit-id, preview, batch queueing, and download/following count paths, with dependencies passed at call time. Requests persist for scheduled retry only when auto-download is enabled and a baseline exists; queue failures are logged with playlist id and selected count. Successful downloads clear their request flags. Scheduled selection returns disjoint discovery and older saved-retry pools, each capped at the configured limit. Requested discoveries belong only to the discovery pool. Older requests rotate by `playlistvideos.auto_download_last_attempt_at` (DATETIME(3), nullable), recorded before queue submission on explicit saved batches and scheduled retry selection, outside the pure selector. Null attempts remain in the bounded pool. The original batch queues in full. Discovery jobs are admitted before any retry settings group; retry jobs use `Playlist Retry: <title>` (displayed as Saved playlist retries), retaining URL-list processing, notifications, and run aggregation. Already-active entries are excluded before selection. `following_requested_count` reports eligible outstanding selections in the header without claiming they failed. Explicit-id downloads can override ignored and allowRedownload retains its existing meaning. @@ -47,8 +47,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`<library folder>/<show>/Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. -- `modules/tvShows/` (TV show support; channel shows and the reorganize ship in this branch, title shows come later): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json`. -- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings; items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. +- `modules/tvShows/` (TV show support: channel shows, title shows, and the reorganize): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. **Title shows** (a series inside one channel, `tv_shows.kind = 'title'`, numbered from its titles; classification decides, the match filter only screens downloads): `showFolderNames` (dependency-free folder-name rules, shared with `showStore`), `patternCompiler` (simple syntax (`{season}`/`{episode}` as `[0-9]+`, `{title}`, `*`, `^`; `{episode_end}`/`{part}` recognized as unsupported) or regex mode -> a Python regex with named groups plus its filter form (groups unnamed, leading flags scoped); seasons 0..199), `titleMatcher` (one Python batch per channel through `titleFilterRegex.classifyTitles`: first show by position, then pattern, without an exclude term; a number the pattern takes from the title but the title skipped (an optional regex group) is `unsupported`, reason `missing-number`; a pending match keeps its title episode for the reorganize as `titleEpisode`), `titleNumbering` (pure: stored rows + matches -> rows with statuses `assigned`/`pending_number`/`duplicate`/`unsupported`/`opted_out`/`error`; the stored holder of a title number keeps it while it still claims it and is available or downloaded, else the earliest available upload; a manual override takes its number from any holder, which is then classified as if it held nothing; a TV channel's episode that loses a title claim keeps its channel-show row; `order` numbers past `tv_show_seasons.order_high_water`, never reissued; year seasons and date episodes wait for the download (`pending_number`), except that a downloaded video's upload year is known (`Videos.originalDate`), so a year season with a title episode is numbered at plan time and a downloaded duplicate the post-processor recorded keeps that verdict, since the planner never has the upload time to redo it; manual and opted-out rows are never touched; gaps), `titleShowDrafts` (validates and compiles a channel's whole set of shows; keys `title:<id>`/`new:<n>`), `titleShowStore` (rows, patterns, seasons, retire/restore, folder availability, show filters for downloads), `titlePlanner` (a channel's plan after a change, for every listed and downloaded video and every row classified for the channel; the preview, direct saves and the reorganize all plan through `planChannel`; a download outside the downloads folder never moves (`staysOutside`, counted in the preview)), `titlePreview` (the editor's tabs), `titleRowWriter` (writes a plan in one transaction, numbers released before they are retaken, each row only while it is as planned: `ROW_CHANGED` otherwise, and the saver plans again once; conflicts are released by the channel's recorded duplicates), `titleShowSaver` (saves directly when no downloaded video's show, number or stem changes, else answers `reorganizeRequired` with a `titleShows` change; listing refreshes classify only new rows through `classifyNew`), `titleShowService` (the API's operations as drafts plus overrides: create, edit, retire, restore, reorder, manual episode, "Not an episode" (title show episodes only), back to automatic, "Use this copy instead", recheck; after each save it rewrites the channel's title show NFO files, which name and season-name edits change without moving a file; the reorganize follow-up does the same for a `titleShows` change), `titleShowQueries` (counts, `/getchannels` `titleShows`, missing episodes, the channel page's show filter, the planned episode chip of not-downloaded videos), `episodeConflicts` (duplicates and classification errors; a not-downloaded duplicate is ignored and suppressed in `complete.list`, and only what Youtarr did is undone on release: the ignore it set (`youtarr_ignored`) and the archive line it wrote (`archive_suppressed`, false when the line was already there, e.g. a deleted download's); a downloaded duplicate is never ignored or deleted; duplicates a listing refresh finds are ignored too), `archiveSuppressor` (the deferred `complete.list` writer: `archive_pending` on the conflict row, applied when no download job is In Progress, through `jobModule.onBeforeNextJob` and at startup, because channel jobs count archive lines), `titleEpisodeAssigner` (the post-processor's step: a stored episode is reused without Python; otherwise the full title is classified at finalize; duplicates, unsupported, opted-out and errors fall back to the channel layout and are recorded), and `tvNfo`'s title show output (`<uniqueid type="youtarr">` + custom UUID, `<namedseason>`, `season.nfo` per named season). The show-only switch (`channels.tv_show_only_downloads`) adds one match filter per show to channel downloads and limits Download All to classified videos (`assigned`/`pending_number`); it does nothing while the channel has no active show. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`) and `routes/tvTitleShows.js` (`/api/channels/:channelId/tv/shows` list/create/edit/retire/restore/order and `/:showId/missing`, `POST .../tv/preview`, `PUT .../tv/show-only`, `POST .../tv/conflicts/:youtubeId/use-copy`, `POST .../tv/recheck`, `GET`/`PUT /api/videos/:youtubeId/episode`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json` (`titleCorpusClassification.test.js` runs them through the real Python matcher). +- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder), or when a change to a channel's title shows moves downloaded videos (`titleShows` change: the show definitions and every classification row of the channel are written at the start, with a `titleSnapshot` of the previous ones in `settings_change`, restored when no video moved (deleting the shows the change created); title shows are planned under show keys `title:<id>`/`new:<n>`, channel shows under their owner's channel id; `titleTargets` keeps episodes of active title shows in place for every other change type and a folder holding title shows can't switch to Videos (400); `titleAssignments` numbers videos moving into a title show, upload-time numbers from the stored info.json as the post-processor would). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings; items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. - `modules/mediaServers/watchStatusHolds.js` + `watchStatusPushBack.js`: watch state a reorganize protects. Before files move, each watched or in-progress `video_watch_status` row (Plex: the owner only; other accounts come from play history, which a move never resets) is snapshotted into `watch_status_holds`; `watchStatusSync` passes every server's matches through `applyHolds`, which drops downgrades of held rows (the moved item looks new and unwatched, and until a rescan the server lists the old path, which the current-copy rule rejects) and ends a hold when the item at the video's current path (matching it with more trailing segments than the path the video came from, kept in the snapshot as `fromPath`: between TV folders the stale item shares the show, season and file names) shows at least the held state or a newer one. A push stamps the server item with the push time: a last-watched time within 10 minutes of the hold's last actual write (`last_pushed_at`; an attempt that found the state already there writes nothing and leaves no echo) is that echo, not a watch, so the row keeps its historical play count and last-watched time (restored holds are consulted for this too), while a later real watch is written as is. Re-holding a row for a later reorganize resets its push record (the earlier push went to the earlier location). Push-back first reads the user's state of the item (`getWatchState`: Plex `/library/metadata/{key}` as the owner, Jellyfin and Emby `/Users/{id}/Items/{id}`; `watchStateCompare` holds the pure `isAtLeast`/`isLaterWatch` the sync uses too) and leaves a state at least as watched, or watched since the snapshot, alone; otherwise it writes the state through the adapters' `setWatchState` (Plex `/:/scrobble` and `/:/progress` as the owner; Jellyfin `/UserPlayedItems` and `/UserItems/{id}/UserData`, falling back to the legacy `/Users/{id}/...` routes; Emby `/Users/{id}/PlayedItems` and `/Users/{id}/Items/{id}/UserData`), finding items with `resolveItemMatchesByPaths` (an item is skipped only when the same item answers for both the new and the old path and fits the old one at least as well, i.e. the stale item before the server rescans; two different items mean the new file is indexed; an explicit retry takes whatever matches); a hold is pushed once, and again only when a sync finds the item at the new path below the held state (`applyHolds` records that as the hold's error), since a blind re-push could undo a watch made in between. It runs 1, 5 and 15 minutes after a reorganize and at the end of every sync, an hour apart per hold. Holds not restored within 14 days become failed (still protecting the row) until retried or dismissed (`GET /api/tv/holds`, `POST /api/tv/holds/:id/retry|dismiss`). - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. @@ -64,6 +64,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `components/layout/`: app shell and navigation chrome. `AppShell.tsx` is the outer frame; `NavSidebar.tsx` / `NavHeader.tsx` own desktop and mobile nav; `navLayoutConstants.ts` holds shared sidebar/header sizing constants; `layoutFallback.css` provides fallback CSS variables for themes that skip layout overrides; `ReorganizeBanner.tsx` shows a running reorganize on every page (downloads wait for it). - `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle, the watch-state restores a reorganize left (`sections/components/WatchStateRestores.tsx` via `hooks/useWatchStateHolds.ts`: pending count, failed restores with Retry and Dismiss) and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. - `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`). +- `components/ChannelPage/components/TitleShows/`: title shows. `TitleShowsSection` (Channel Settings > TV Show: the list with counts, reorder arrows, remove/restore, the show-only switch, `ConflictList` for duplicates and classification errors) and `TitleShowEditorDialog` (`useTitleShowForm`, `PatternRow` with simple/regex mode, `SeasonNamesEditor`, the TV folder pick, live `TitleShowPreviewTabs` from `hooks/useTitleShowPreview`); changes that move downloads raise `ReorganizeRequiredError` from `hooks/useTitleShows` and open `ReorganizeDialog`. On the channel page, `ChannelShowFilter` (the list's show filter, `showId` on `/getchannelvideos`) and `MissingEpisodesDialog` (`hooks/useMissingEpisodes`); the header and Subscriptions cards/rows show `Subscriptions/components/chips/ShowsChip`. `shared/EpisodeAssign/` (`EpisodeAssignDialog` + `useVideoEpisode`, `GET`/`PUT /api/videos/:youtubeId/episode`: a manual episode, "Not an episode", back to automatic) is opened by the video modal's `VideoModal/components/VideoEpisodeSection` and the conflict list; a change that moves files is followed with `useReorganizeOutcome` and reported saved when the run ends. - `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow. - `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface. - `hooks/`: app-wide custom hooks for data fetching and state (e.g. `useLibraryCheck` for `GET /api/library-folders/check` and the Plex mapping, refetched on `LIBRARY_FOLDERS_UPDATED_EVENT`). diff --git a/client/src/components/ChannelPage.tsx b/client/src/components/ChannelPage.tsx index 7d1535855..5cab02cc4 100644 --- a/client/src/components/ChannelPage.tsx +++ b/client/src/components/ChannelPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { Card, CardContent, Grid, Typography, Box, Tooltip, Chip, Popover, Dialog, DialogTitle, DialogContent, Button } from './ui'; import { Settings as SettingsIcon, Clock as AccessTimeIcon, Filter as FilterAltIcon } from 'lucide-react'; @@ -14,9 +14,12 @@ import OpenInYouTubeLink, { youtubeChannelUrl } from './shared/OpenInYouTubeLink import SubFolderChip from './Subscriptions/components/chips/SubFolderChip'; import QualityChip from './Subscriptions/components/chips/QualityChip'; import TvChip from './Subscriptions/components/chips/TvChip'; +import ShowsChip from './Subscriptions/components/chips/ShowsChip'; import AutoDownloadTabToggles from './ChannelPage/components/AutoDownloadTabToggles'; import { useAutoDownloadTabToggle } from './ChannelPage/hooks/useAutoDownloadTabToggle'; import { useChannelTv } from './ChannelPage/hooks/useChannelTv'; +import { useTitleShows } from './ChannelPage/hooks/useTitleShows'; +import { useDownloadListingsRefresh } from '../hooks/useDownloadListingsRefresh'; import { SHARED_CHANNEL_META_CHIP_STYLE, SHARED_CHANNEL_META_DEFAULT_SURFACE_STYLE } from './shared/chipStyles'; interface ChannelPageProps { @@ -39,6 +42,19 @@ function ChannelPage({ token }: ChannelPageProps) { const { config, loading: configLoading } = useConfig(token); const globalPreferredResolution = config.preferredResolution || '1080'; const { tv: channelTv, refetch: refetchChannelTv } = useChannelTv(channel_id, token); + const { data: titleShowsData, refetch: refetchTitleShows } = useTitleShows(channel_id, token); + // Closing Channel Settings reloads the video list (show edits change planned episodes). + const [videosRefreshKey, setVideosRefreshKey] = useState(0); + // The header chips and the show filter follow downloads and reorganizes + // (which can end, or be undone, after Channel Settings closed). + useDownloadListingsRefresh(() => { + void refetchTitleShows(); + void refetchChannelTv(); + }); + const activeTitleShows = useMemo( + () => (titleShowsData?.shows ?? []).filter((show) => !show.retired).map(({ id, name }) => ({ id, name })), + [titleShowsData] + ); const handleSettingsSaved = (updated: { sub_folder: string | null; @@ -237,6 +253,7 @@ function ChannelPage({ token }: ChannelPageProps) { <Box className="flex flex-wrap items-center gap-1"> <SubFolderChip subFolder={channel.sub_folder} /> {channelTv?.layout === 'tv' && <TvChip />} + <ShowsChip count={activeTitleShows.length} /> </Box> ); }; @@ -673,12 +690,18 @@ function ChannelPage({ token }: ChannelPageProps) { channelAudioFormat={channel?.audio_format || null} channelAvailableTabs={channel?.available_tabs ?? null} onVideosLoaded={handleVideosLoaded} + titleShows={activeTitleShows} + refreshKey={videosRefreshKey} /> {channel && channel_id && ( <ChannelSettingsDialog open={settingsOpen} - onClose={() => setSettingsOpen(false)} + onClose={() => { + setSettingsOpen(false); + void refetchTitleShows(); + setVideosRefreshKey((key) => key + 1); + }} channelId={channel_id} channelName={channel.uploader} token={token} diff --git a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx index 6ad34c7d5..62cacc568 100644 --- a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx +++ b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx @@ -43,6 +43,7 @@ import { LibraryLayout } from '../../types/tvShows'; import RatingBadge from '../shared/RatingBadge'; import TabsEditor, { TabsEditorRefreshResult } from './components/TabsEditor'; import ChannelTvSection from './components/ChannelTvSection'; +import TitleShowsSection from './components/TitleShows/TitleShowsSection'; import { useChannelTv } from './hooks/useChannelTv'; import { ReorganizeDialog, useReorganizeRequest, useReorganizeOutcome, isReorganizeRequired, reorganizeChangeOf, @@ -872,19 +873,28 @@ function ChannelSettingsDialog({ ); case 'tv': return ( - <ChannelTvSection - token={token} - channelName={channelName} - tv={tv} - loading={tvLoading} - error={tvError} - folders={libraryFolders} - onSwitch={handleLayoutSwitch} - createSubfolder={createSubfolder} - setFolderLayout={setFolderLayout} - onShowReorganize={reorganize.showOperation} - disabled={saving} - /> + <div className="flex flex-col gap-6"> + <ChannelTvSection + token={token} + channelName={channelName} + tv={tv} + loading={tvLoading} + error={tvError} + folders={libraryFolders} + onSwitch={handleLayoutSwitch} + createSubfolder={createSubfolder} + setFolderLayout={setFolderLayout} + onShowReorganize={reorganize.showOperation} + disabled={saving} + /> + <TitleShowsSection + token={token} + channelId={channelId} + disabled={saving} + tvKey={tv ? `${tv.layout}|${tv.libraryFolder}` : ''} + onMoveEnded={() => { void refetchTv(); }} + /> + </div> ); case 'filters': return ( diff --git a/client/src/components/ChannelPage/ChannelVideos.tsx b/client/src/components/ChannelPage/ChannelVideos.tsx index 457e48f94..d4232f89f 100644 --- a/client/src/components/ChannelPage/ChannelVideos.tsx +++ b/client/src/components/ChannelPage/ChannelVideos.tsx @@ -27,6 +27,8 @@ import VideoCard from './VideoCard'; import VideoListItem from './VideoListItem'; import VideoTableView from './VideoTableView'; import ChannelVideosDialogs from './ChannelVideosDialogs'; +import ChannelShowFilter from './components/TitleShows/ChannelShowFilter'; +import MissingEpisodesDialog from './components/TitleShows/MissingEpisodesDialog'; import DownloadAllVideosDialog from './DownloadAllVideosDialog'; import { useChannelVideos } from './hooks/useChannelVideos'; import { useRefreshChannelVideos } from './hooks/useRefreshChannelVideos'; @@ -68,12 +70,17 @@ interface ChannelVideosProps { channelAudioFormat?: string | null; channelAvailableTabs?: string | null; onVideosLoaded?: (channelId: string) => void; + /** The channel's active title shows, for the show filter */ + titleShows?: Array<{ id: number; name: string }>; + /** Changed by the page to reload the list (e.g. after Channel Settings closes) */ + refreshKey?: number; } type SortBy = 'date' | 'title' | 'duration' | 'size'; type SortOrder = 'asc' | 'desc'; const VIEW_MODE_STORAGE_KEY = 'youtarr:channelVideosViewMode'; +const EMPTY_SHOWS: Array<{ id: number; name: string }> = []; function channelVideoToModalData( video: ChannelVideo, @@ -115,6 +122,8 @@ function ChannelVideos({ channelAudioFormat, channelAvailableTabs, onVideosLoaded, + titleShows = EMPTY_SHOWS, + refreshKey = 0, }: ChannelVideosProps) { const isMobile = useMediaQuery('(max-width: 767px)'); const initialViewMode: VideoListViewMode = isMobile ? 'list' : 'table'; @@ -137,6 +146,12 @@ function ChannelVideos({ const [missingFilter, setMissingFilter] = useState<ChipFilterMode>('off'); const [ignoredFilter, setIgnoredFilter] = useState<ChipFilterMode>('off'); const [watchedFilter, setWatchedFilter] = useState<ChipFilterMode>('off'); + // One title show's episodes, and the show whose missing episodes are shown. + const [showFilter, setShowFilter] = useState<number | null>(null); + const [missingShowId, setMissingShowId] = useState<number | null>(null); + useEffect(() => { + if (showFilter !== null && !titleShows.some((show) => show.id === showFilter)) setShowFilter(null); + }, [showFilter, titleShows]); const [selectedTab, setSelectedTab] = useState<string | null>(null); const [availableTabs, setAvailableTabs] = useState<string[]>([]); @@ -293,6 +308,7 @@ function ChannelVideos({ missingFilter, ignoredFilter, watchedFilter, + showFilter ?? '', useInfiniteScroll, ].join('|'), [ @@ -311,6 +327,7 @@ function ChannelVideos({ missingFilter, ignoredFilter, watchedFilter, + showFilter, useInfiniteScroll, ] ); @@ -344,10 +361,17 @@ function ChannelVideos({ missingFilter, ignoredFilter, watchedFilter, + showId: showFilter, onFirstLoad: onVideosLoaded, }); useDownloadListingsRefresh(refetchVideos); + const loadedRefreshKey = useRef(refreshKey); + useEffect(() => { + if (loadedRefreshKey.current === refreshKey) return; + loadedRefreshKey.current = refreshKey; + refetchVideos(); + }, [refreshKey, refetchVideos]); const { data: tabStats, refetch: refetchTabStats } = useChannelTabStats(channelId, token); useDownloadListingsRefresh(refetchTabStats); @@ -399,6 +423,7 @@ function ChannelVideos({ missingFilter, ignoredFilter, watchedFilter, + showFilter, downloadedFilter, sortBy, sortOrder, @@ -1069,7 +1094,7 @@ function ChannelVideos({ ); // The summary already gives the tab's size; the list count only adds // something while a search or filter narrows the list. - const listIsFiltered = Boolean(listState.search) || hasDurationOrDateFilter || maxRating !== '' + const listIsFiltered = Boolean(listState.search) || hasDurationOrDateFilter || maxRating !== '' || showFilter !== null || [downloadedFilter, protectedFilter, missingFilter, ignoredFilter, watchedFilter].some((mode) => mode !== 'off'); let countChipLabel: string | null = null; if (totalCount > 0 && listIsFiltered) { @@ -1315,9 +1340,22 @@ function ChannelVideos({ paginationMode={useInfiniteScroll ? 'infinite' : 'pages'} infiniteScrollSentinel={infiniteSentinel} isMobile={isMobile} + customFilters={titleShows.length > 0 ? ( + <ChannelShowFilter shows={titleShows} value={showFilter} onChange={setShowFilter} onShowMissing={setMissingShowId} /> + ) : undefined} /> </Card> + {channelId && ( + <MissingEpisodesDialog + open={missingShowId !== null} + token={token} + channelId={channelId} + showId={missingShowId} + onClose={() => setMissingShowId(null)} + /> + )} + <ChannelVideosDialogs token={token} downloadDialogOpen={downloadDialogOpen} @@ -1382,6 +1420,7 @@ function ChannelVideos({ }} onDownloadQueued={() => setModalVideo(null)} onRatingChanged={() => refetchVideos()} + onEpisodeChanged={() => refetchVideos()} onAvailabilityDetected={(youtubeId, availability) => { setLocalAvailabilityStatus((prev) => ({ ...prev, [youtubeId]: availability })); }} diff --git a/client/src/components/ChannelPage/VideoCard.tsx b/client/src/components/ChannelPage/VideoCard.tsx index 5099da031..6ec892f82 100644 --- a/client/src/components/ChannelPage/VideoCard.tsx +++ b/client/src/components/ChannelPage/VideoCard.tsx @@ -390,7 +390,7 @@ function VideoCard({ size="small" style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE, flexShrink: 0 }} /> - <EpisodeChip episode={video.episode} /> + <EpisodeChip episode={video.episode ?? video.plannedEpisode} planned={!video.episode && Boolean(video.plannedEpisode)} /> <WatchedChip watchedBy={video.watchedBy || []} /> <Chip icon={getStatusIcon(status)} diff --git a/client/src/components/ChannelPage/VideoListItem.tsx b/client/src/components/ChannelPage/VideoListItem.tsx index d393a6a52..8c1bddc71 100644 --- a/client/src/components/ChannelPage/VideoListItem.tsx +++ b/client/src/components/ChannelPage/VideoListItem.tsx @@ -370,7 +370,7 @@ function VideoListItem({ showNA style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }} /> - <EpisodeChip episode={video.episode} /> + <EpisodeChip episode={video.episode ?? video.plannedEpisode} planned={!video.episode && Boolean(video.plannedEpisode)} /> <WatchedChip watchedBy={video.watchedBy || []} /> <Chip icon={getStatusIcon(status)} diff --git a/client/src/components/ChannelPage/VideoTableView.tsx b/client/src/components/ChannelPage/VideoTableView.tsx index 39c0da535..a4be3889c 100644 --- a/client/src/components/ChannelPage/VideoTableView.tsx +++ b/client/src/components/ChannelPage/VideoTableView.tsx @@ -352,7 +352,7 @@ function VideoTableView({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }} /> )} - <EpisodeChip episode={video.episode} /> + <EpisodeChip episode={video.episode ?? video.plannedEpisode} planned={!video.episode && Boolean(video.plannedEpisode)} /> <WatchedChip watchedBy={video.watchedBy || []} /> <Chip icon={getStatusIcon(status)} diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx index 4c762b4aa..6aacd66dd 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx @@ -69,17 +69,26 @@ jest.mock('../../shared/Reorganize', () => { }); const mockSwitchLayout = jest.fn(); +const mockRefetchTv = jest.fn(); const mockChannelTv: { current: unknown } = { current: null }; jest.mock('../hooks/useChannelTv', () => ({ useChannelTv: () => ({ tv: mockChannelTv.current, loading: false, error: null, - refetch: jest.fn(), + refetch: mockRefetchTv, switchLayout: mockSwitchLayout, }), })); +jest.mock('../components/TitleShows/TitleShowsSection', () => ({ + __esModule: true, + default: function MockTitleShowsSection(props: { onMoveEnded?: () => void }) { + const React = require('react'); + return React.createElement('button', { type: 'button', onClick: () => props.onMoveEnded?.() }, 'mock title show move ended'); + }, +})); + // eslint-disable-next-line @typescript-eslint/no-var-requires const mockAxios = require('axios'); @@ -2135,6 +2144,17 @@ describe('ChannelSettingsDialog', () => { expect(screen.getByText('Show this channel as')).toBeInTheDocument(); }); + test('reloads the channel\'s TV state when a title show move ends', async () => { + mockChannelTv.current = videosChannelTv; + await renderLoaded(); + const user = await openSettingsSection('TV Show'); + mockRefetchTv.mockClear(); + + await user.click(screen.getByRole('button', { name: 'mock title show move ended' })); + + expect(mockRefetchTv).toHaveBeenCalledTimes(1); + }); + test('hides the file structure and playlist file controls for a TV folder', async () => { mockLayoutOf.mockImplementation((folder: string) => (folder === 'Anime' ? 'tv' : 'videos')); await renderLoaded({ ...mockChannelSettings, sub_folder: 'Anime' }); diff --git a/client/src/components/ChannelPage/__tests__/ChannelVideos.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelVideos.test.tsx index 32f9340e6..75a99213f 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelVideos.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelVideos.test.tsx @@ -97,6 +97,18 @@ jest.mock('../VideoCard', () => ({ } })); +jest.mock('../../shared/VideoModal', () => ({ + __esModule: true, + default: function MockVideoModal({ video, onEpisodeChanged }: any) { + const React = require('react'); + return React.createElement('button', { + type: 'button', + 'data-testid': 'modal-episode-changed', + onClick: () => onEpisodeChanged?.(video.youtubeId), + }, 'Episode changed'); + } +})); + jest.mock('../VideoListItem', () => ({ __esModule: true, default: function MockVideoListItem({ video }: any) { @@ -197,6 +209,7 @@ jest.mock('../../shared/VideoList', () => { props.headerSlot, props.tabsSlot, watchedControl, + props.customFilters, content, props.infiniteScrollSentinel, props.pagination, @@ -248,6 +261,24 @@ const mockClearError = jest.fn(); const mockTriggerDownloads = jest.fn(); const mockDeleteVideosByYoutubeIds = jest.fn(); +jest.mock('../components/TitleShows/ChannelShowFilter', () => ({ + __esModule: true, + default: function MockShowFilter(props: { onChange: (id: number | null) => void; onShowMissing: (id: number) => void }) { + const React = require('react'); + return React.createElement('div', null, + React.createElement('button', { type: 'button', 'data-testid': 'show-filter-3', onClick: () => props.onChange(3) }, 'Show 3'), + React.createElement('button', { type: 'button', 'data-testid': 'show-missing-3', onClick: () => props.onShowMissing(3) }, 'Missing')); + }, +})); + +jest.mock('../components/TitleShows/MissingEpisodesDialog', () => ({ + __esModule: true, + default: function MockMissing(props: { open: boolean; showId: number | null }) { + const React = require('react'); + return props.open ? React.createElement('div', { 'data-testid': 'missing-episodes' }, `show ${props.showId}`) : null; + }, +})); + jest.mock('../hooks/useChannelVideos', () => ({ useChannelVideos: jest.fn(), })); @@ -476,6 +507,61 @@ describe('ChannelVideos Component', () => { }); }); + describe('Refresh requests', () => { + test('reloads the videos when the page asks for a refresh', () => { + const view = renderChannelVideos({ refreshKey: 0 }); + const before = mockRefetchVideos.mock.calls.length; + view.rerender(<ChannelVideos token={mockToken} refreshKey={1} />); + expect(mockRefetchVideos.mock.calls.length).toBe(before + 1); + }); + + test('reloads the videos when the video modal changes an episode', async () => { + useChannelVideos.mockReturnValue({ + videos: [mockVideos[0]], totalCount: 1, oldestVideoDate: '2023-01-01', + autoDownloadsEnabled: false, loading: false, refetch: mockRefetchVideos, + }); + renderChannelVideos(); + fireEvent.click(await screen.findByTestId('open-video-video1')); + const before = mockRefetchVideos.mock.calls.length; + fireEvent.click(screen.getByTestId('modal-episode-changed')); + expect(mockRefetchVideos.mock.calls.length).toBe(before + 1); + }); + + test('does not reload on a render without a new refresh', () => { + const view = renderChannelVideos({ refreshKey: 0 }); + const before = mockRefetchVideos.mock.calls.length; + view.rerender(<ChannelVideos token={mockToken} refreshKey={0} />); + expect(mockRefetchVideos.mock.calls.length).toBe(before); + }); + }); + + describe('Show filter', () => { + test('offers no show filter for a channel without title shows', () => { + renderChannelVideos(); + expect(screen.queryByTestId('show-filter-3')).not.toBeInTheDocument(); + }); + + test('lists one title show\'s episodes', async () => { + const user = userEvent.setup(); + renderChannelVideos({ titleShows: [{ id: 3, name: 'Beyblade' }] }); + + await user.click(screen.getByTestId('show-filter-3')); + + await waitFor(() => { + expect(useChannelVideos).toHaveBeenLastCalledWith(expect.objectContaining({ showId: 3, page: 1 })); + }); + }); + + test('opens a show\'s missing episodes', async () => { + const user = userEvent.setup(); + renderChannelVideos({ titleShows: [{ id: 3, name: 'Beyblade' }] }); + + await user.click(screen.getByTestId('show-missing-3')); + + expect(screen.getByTestId('missing-episodes')).toHaveTextContent('show 3'); + }); + }); + describe('Download All', () => { test('the header button opens the download-all dialog for the active tab', () => { renderChannelVideos(); diff --git a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx index c5d0194d4..afd8f0129 100644 --- a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx @@ -894,6 +894,12 @@ describe('VideoCard Component', () => { expect(screen.getByTestId('episode-chip')).toHaveTextContent('S2024E03151200'); }); + test('renders the planned episode of a video not downloaded yet', () => { + const plannedVideo = { ...mockVideo, added: false, plannedEpisode: { showName: 'Beyblade', season: 1, episode: 20, code: 'S01E20' } }; + renderWithProviders(<VideoCard {...defaultProps} video={plannedVideo} />); + expect(screen.getByLabelText('Planned TV episode Beyblade, S01E20, once downloaded')).toBeInTheDocument(); + }); + test('does not render an episode chip for a video without episode details', () => { renderWithProviders(<VideoCard {...defaultProps} />); expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); diff --git a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx index dd61ed279..e33eb5826 100644 --- a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx @@ -897,6 +897,12 @@ describe('VideoListItem Component', () => { expect(screen.getByTestId('episode-chip')).toHaveTextContent('S2024E03151200'); }); + test('renders the planned episode of a video not downloaded yet', () => { + const plannedVideo = { ...mockVideo, added: false, plannedEpisode: { showName: 'Beyblade', season: 1, episode: 20, code: 'S01E20' } }; + renderWithProviders(<VideoListItem {...defaultProps} video={plannedVideo} />); + expect(screen.getByLabelText('Planned TV episode Beyblade, S01E20, once downloaded')).toBeInTheDocument(); + }); + test('does not render an episode chip for a video without episode details', () => { renderWithProviders(<VideoListItem {...defaultProps} />); expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); diff --git a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx index a872d46e3..d543c96ab 100644 --- a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx @@ -1130,6 +1130,12 @@ describe('VideoTableView Component', () => { expect(screen.getByTestId('episode-chip')).toHaveTextContent('S2024E03151200'); }); + test('renders the planned episode of a video not downloaded yet', () => { + const plannedVideo = { ...mockVideo, added: false, plannedEpisode: { showName: 'Beyblade', season: 1, episode: 20, code: 'S01E20' } }; + renderWithProviders(<VideoTableView {...defaultProps} videos={[plannedVideo]} />); + expect(screen.getByLabelText('Planned TV episode Beyblade, S01E20, once downloaded')).toBeInTheDocument(); + }); + test('does not render an episode chip for a video without episode details', () => { renderWithProviders(<VideoTableView {...defaultProps} />); expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); diff --git a/client/src/components/ChannelPage/components/TitleShows/ChannelShowFilter.tsx b/client/src/components/ChannelPage/components/TitleShows/ChannelShowFilter.tsx new file mode 100644 index 000000000..f577796b1 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/ChannelShowFilter.tsx @@ -0,0 +1,40 @@ +import React, { useId } from 'react'; +import { Box, Button, FormControl, InputLabel, MenuItem, Select } from '../../../ui'; + +const ALL_VIDEOS = 'all'; + +interface ChannelShowFilterProps { + /** The channel's active title shows */ + shows: Array<{ id: number; name: string }>; + /** The show whose episodes are listed, or null for every video */ + value: number | null; + onChange: (showId: number | null) => void; + onShowMissing: (showId: number) => void; +} + +/** The channel page's show filter: list one title show's episodes, and see what it is missing. */ +function ChannelShowFilter({ shows, value, onChange, onShowMissing }: ChannelShowFilterProps) { + const labelId = useId(); + if (shows.length === 0) return null; + return ( + <Box className="flex flex-wrap items-end gap-2"> + <FormControl className="min-w-[200px]"> + <InputLabel id={labelId} shrink>Show</InputLabel> + <Select + labelId={labelId} + size="small" + value={value === null ? ALL_VIDEOS : String(value)} + onChange={(event) => onChange(event.target.value === ALL_VIDEOS ? null : Number(event.target.value))} + > + <MenuItem value={ALL_VIDEOS}>All videos</MenuItem> + {shows.map((show) => <MenuItem key={show.id} value={String(show.id)}>{show.name}</MenuItem>)} + </Select> + </FormControl> + {value !== null && ( + <Button size="small" variant="outlined" onClick={() => onShowMissing(value)}>Missing episodes</Button> + )} + </Box> + ); +} + +export default ChannelShowFilter; diff --git a/client/src/components/ChannelPage/components/TitleShows/ConflictList.tsx b/client/src/components/ChannelPage/components/TitleShows/ConflictList.tsx new file mode 100644 index 000000000..206437fb1 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/ConflictList.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Box, Button, Typography } from '../../../ui'; +import { TitleShowConflict } from '../../../../types/titleShows'; + +function episodeCode(season: number | null, episode: number | null): string { + if (season === null || episode === null) return 'its episode'; + return `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`; +} + +function describe(conflict: TitleShowConflict): string { + if (conflict.kind === 'classification_error') { + return `Its title could not be checked against the shows: ${conflict.message || 'unknown error'}`; + } + const holder = conflict.duplicateOfTitle || conflict.duplicateOf; + const state = conflict.downloaded + ? 'Downloaded duplicate: it stays where it is.' + : conflict.suppressed ? 'Ignored, so it isn\'t downloaded.' : 'You ignored it.'; + return `Duplicate of ${episodeCode(conflict.season, conflict.episode)}: ${holder}. ${state}`; +} + +interface ConflictListProps { + conflicts: TitleShowConflict[]; + busy: boolean; + /** "Use this copy instead": the duplicate takes the number */ + onUseCopy: (conflict: TitleShowConflict) => void; + /** "Not a duplicate": assign the video by hand */ + onAssign: (conflict: TitleShowConflict) => void; + /** Delete a downloaded duplicate's files */ + onDelete: (conflict: TitleShowConflict) => void; + /** Classify the channel's titles again */ + onRecheck: () => void; +} + +/** A channel's duplicate episodes and titles that could not be classified, with what can be done about them. */ +function ConflictList({ conflicts, busy, onUseCopy, onAssign, onDelete, onRecheck }: ConflictListProps) { + if (conflicts.length === 0) return null; + return ( + <Box className="flex flex-col gap-2"> + <Typography variant="body2" className="font-semibold">Duplicates and errors</Typography> + <ul aria-label="Duplicates and errors" className="max-h-[280px] divide-y divide-border overflow-auto"> + {conflicts.map((conflict) => ( + <li key={conflict.youtubeId} className="flex flex-col gap-1 py-2"> + <Typography variant="body2" className="break-words">{conflict.title || conflict.youtubeId}</Typography> + <Typography variant="caption" color="text.secondary">{describe(conflict)}</Typography> + <Box className="flex flex-wrap gap-2"> + {conflict.kind === 'duplicate' ? ( + <> + <Button size="small" variant="outlined" disabled={busy} onClick={() => onUseCopy(conflict)}>Use this copy instead</Button> + <Button size="small" variant="text" disabled={busy} onClick={() => onAssign(conflict)}>Not a duplicate</Button> + {conflict.downloaded && conflict.videoId !== null && ( + <Button size="small" variant="text" color="error" disabled={busy} onClick={() => onDelete(conflict)}>Delete this copy</Button> + )} + </> + ) : ( + <Button size="small" variant="outlined" disabled={busy} onClick={onRecheck}>Check again</Button> + )} + </Box> + </li> + ))} + </ul> + </Box> + ); +} + +export default ConflictList; diff --git a/client/src/components/ChannelPage/components/TitleShows/MissingEpisodesDialog.tsx b/client/src/components/ChannelPage/components/TitleShows/MissingEpisodesDialog.tsx new file mode 100644 index 000000000..e7e23fed2 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/MissingEpisodesDialog.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { + Alert, Box, Button, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Typography, +} from '../../../ui'; +import { MissingSeason } from '../../../../types/titleShows'; +import { useMissingEpisodes } from '../../hooks/useMissingEpisodes'; + +const GAPS_NOTE = 'Numbers between 1 and the highest episode that no known video holds: not uploaded, uploaded under ' + + 'another title, or not listed yet (Load all on the channel page).'; + +function seasonSummary(season: MissingSeason): string { + const label = season.name ? `Season ${season.season}, ${season.name}` : `Season ${season.season}`; + return season.downloaded === season.episodes + ? `${label}: all ${season.episodes} downloaded` + : `${label}: ${season.downloaded} of ${season.episodes} downloaded`; +} + +interface MissingEpisodesDialogProps { + open: boolean; + token: string | null; + channelId: string; + showId: number | null; + onClose: () => void; +} + +/** A title show's episodes that aren't downloaded, season by season, and the numbers no known video holds. */ +function MissingEpisodesDialog({ open, token, channelId, showId, onClose }: MissingEpisodesDialogProps) { + const { data, loading, error } = useMissingEpisodes(channelId, open ? showId : null, token); + + return ( + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> + <DialogTitle onClose={onClose}>{data ? `Missing episodes of ${data.name}` : 'Missing episodes'}</DialogTitle> + <DialogContent> + <Box className="flex flex-col gap-4 pt-2"> + {loading && <CircularProgress size={24} />} + {error && <Alert severity="error">{error}</Alert>} + {data && data.seasons.length === 0 && <Typography variant="body2" color="text.secondary">No numbered episodes yet.</Typography>} + {data && data.seasons.map((season) => ( + <Box key={season.season} className="flex flex-col gap-1"> + <Typography variant="body2" className="font-semibold">{seasonSummary(season)}</Typography> + {season.notDownloaded.length > 0 && ( + <ul aria-label={`Season ${season.season} not downloaded`} className="flex flex-col gap-1"> + {season.notDownloaded.map((episode) => ( + <li key={episode.youtubeId} className="flex items-center gap-2"> + <Chip label={episode.code} size="small" variant="outlined" /> + <Typography variant="body2" className="break-words">{episode.title || episode.youtubeId}</Typography> + </li> + ))} + </ul> + )} + {season.gaps.length > 0 && ( + <Typography variant="caption" color="text.secondary" title={GAPS_NOTE}> + {`No known video for ${season.gaps.map((n) => `E${n}`).join(', ')}${season.gapsTruncated ? ', ...' : ''}.`} + </Typography> + )} + </Box> + ))} + </Box> + </DialogContent> + <DialogActions> + <Button variant="contained" onClick={onClose}>Close</Button> + </DialogActions> + </Dialog> + ); +} + +export default MissingEpisodesDialog; diff --git a/client/src/components/ChannelPage/components/TitleShows/PatternRow.tsx b/client/src/components/ChannelPage/components/TitleShows/PatternRow.tsx new file mode 100644 index 000000000..5094627a2 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/PatternRow.tsx @@ -0,0 +1,127 @@ +import React, { useId } from 'react'; +import { Box, Button, FormControl, IconButton, InputLabel, MenuItem, Select, TextField } from '../../../ui'; +import { ArrowDownward, ArrowUpward, Delete } from '../../../../lib/icons'; +import { EpisodeSource, SeasonSource } from '../../../../types/titleShows'; +import { EditablePattern } from './useTitleShowForm'; + +const SEASON_SOURCES: Array<{ value: SeasonSource; label: string }> = [ + { value: 'fixed', label: 'Fixed season' }, + { value: 'title', label: 'From the title ({season})' }, + { value: 'year', label: 'Upload year' }, +]; + +const EPISODE_SOURCES: Array<{ value: EpisodeSource; label: string }> = [ + { value: 'title', label: 'From the title ({episode})' }, + { value: 'order', label: 'Next number in the season' }, + { value: 'date', label: 'Upload time (year seasons)' }, +]; + +export interface PatternRowProps { + index: number; + count: number; + pattern: EditablePattern; + /** The pattern as the preview compiled it, only while the preview is for the current text */ + compiledRegex: string | null; + onChange: (pattern: EditablePattern) => void; + onRemove: () => void; + /** Move to another position */ + onMove: (to: number) => void; +} + +/** One title pattern of a show: its text, mode, and where season and episode numbers come from. */ +function PatternRow({ index, count, pattern, compiledRegex, onChange, onRemove, onMove }: PatternRowProps) { + const seasonLabelId = useId(); + const episodeLabelId = useId(); + const number = index + 1; + const regex = pattern.kind === 'regex'; + + // Regex mode starts from the compiled pattern; the way back restores the + // simple text (regex text doesn't read as simple syntax). A regex that had + // no simple text (a saved one) is kept while its text stands in for the + // simple syntax, so it can be restored without a compiled preview. + const switchMode = () => { + if (regex) { + onChange({ + ...pattern, + kind: 'simple', + text: pattern.simpleText ?? pattern.text, + simpleText: undefined, + regexText: pattern.simpleText ? undefined : pattern.text, + }); + } else if (pattern.regexText) { + onChange({ ...pattern, kind: 'regex', text: pattern.regexText, regexText: undefined }); + } else if (compiledRegex) { + onChange({ ...pattern, kind: 'regex', text: compiledRegex, simpleText: pattern.text }); + } + }; + const canSwitch = regex || Boolean(pattern.regexText) || Boolean(compiledRegex); + const setText = (text: string) => onChange(regex ? { ...pattern, text } : { ...pattern, text, regexText: undefined }); + + return ( + <Box className="flex flex-col gap-2 rounded-[var(--radius-ui)] border border-border p-3"> + <Box className="flex items-start gap-2"> + <TextField + label={`Pattern ${number}`} + size="small" + fullWidth + className={regex ? '[&_input]:font-mono' : undefined} + value={pattern.text} + onChange={(event) => setText(event.target.value)} + inputProps={{ spellCheck: false }} + helperText={regex ? 'Python regular expression with named groups season, episode and title' : undefined} + /> + <IconButton aria-label={`Move pattern ${number} up`} size="small" disabled={index === 0} onClick={() => onMove(index - 1)}> + <ArrowUpward size={16} /> + </IconButton> + <IconButton aria-label={`Move pattern ${number} down`} size="small" disabled={index === count - 1} onClick={() => onMove(index + 1)}> + <ArrowDownward size={16} /> + </IconButton> + <IconButton aria-label={`Remove pattern ${number}`} size="small" onClick={onRemove}> + <Delete size={16} /> + </IconButton> + </Box> + <Box className="flex flex-wrap items-center gap-3"> + <FormControl className="min-w-[180px]"> + <InputLabel id={seasonLabelId} shrink>Season</InputLabel> + <Select + labelId={seasonLabelId} + size="small" + value={pattern.seasonSource} + onChange={(event) => onChange({ ...pattern, seasonSource: event.target.value as SeasonSource })} + > + {SEASON_SOURCES.map((source) => <MenuItem key={source.value} value={source.value}>{source.label}</MenuItem>)} + </Select> + </FormControl> + {pattern.seasonSource === 'fixed' && ( + <TextField + label="Season number" + type="number" + size="small" + className="w-[120px]" + value={pattern.seasonFixed ?? ''} + onChange={(event) => onChange({ + ...pattern, seasonFixed: event.target.value === '' ? null : Number(event.target.value), + })} + inputProps={{ min: 0, max: 199 }} + /> + )} + <FormControl className="min-w-[220px]"> + <InputLabel id={episodeLabelId} shrink>Episode</InputLabel> + <Select + labelId={episodeLabelId} + size="small" + value={pattern.episodeSource} + onChange={(event) => onChange({ ...pattern, episodeSource: event.target.value as EpisodeSource })} + > + {EPISODE_SOURCES.map((source) => <MenuItem key={source.value} value={source.value}>{source.label}</MenuItem>)} + </Select> + </FormControl> + <Button variant="text" size="small" disabled={!canSwitch} onClick={switchMode}> + {regex ? 'Use the simple syntax' : 'Edit as regular expression'} + </Button> + </Box> + </Box> + ); +} + +export default PatternRow; diff --git a/client/src/components/ChannelPage/components/TitleShows/SeasonNamesEditor.tsx b/client/src/components/ChannelPage/components/TitleShows/SeasonNamesEditor.tsx new file mode 100644 index 000000000..77e612e02 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/SeasonNamesEditor.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Box, Button, IconButton, TextField, Typography } from '../../../ui'; +import { Add, Delete } from '../../../../lib/icons'; +import { isAssignableSeason, MAX_YEAR_SEASON, SEASON_RANGE_TEXT } from '../../../../utils/seasonNumbers'; + +export interface SeasonNameRow { + season: string; + name: string; +} + +/** Rows to the API's season names, keeping named seasons with a valid number. */ +export function rowsToSeasonNames(rows: SeasonNameRow[]): Record<string, string> { + const names: Record<string, string> = {}; + for (const row of rows) { + const name = row.name.trim(); + if (!name || !/^\d+$/.test(row.season.trim())) continue; + names[Number(row.season.trim())] = name; + } + return names; +} + +/** Why a named row can't be saved, or null (rows without a name are dropped). */ +export function seasonRowProblem(row: SeasonNameRow, rows: SeasonNameRow[]): string | null { + if (!row.name.trim()) return null; + const text = row.season.trim(); + if (!/^\d+$/.test(text)) return 'A season number'; + const season = Number(text); + if (!isAssignableSeason(season)) return SEASON_RANGE_TEXT; + const named = rows.filter((other) => other.name.trim() && other.season.trim() !== '' && Number(other.season.trim()) === season); + return named.length > 1 ? `Season ${season} is named twice` : null; +} + +export function seasonNamesToRows(names: Record<string, string>): SeasonNameRow[] { + return Object.entries(names) + .map(([season, name]) => ({ season, name })) + .sort((a, b) => Number(a.season) - Number(b.season)); +} + +interface SeasonNamesEditorProps { + rows: SeasonNameRow[]; + onChange: (rows: SeasonNameRow[]) => void; +} + +/** Names for a show's seasons (tvshow.nfo and season.nfo), e.g. season 2 "V-Force". */ +function SeasonNamesEditor({ rows, onChange }: SeasonNamesEditorProps) { + const update = (index: number, patch: Partial<SeasonNameRow>) => { + onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row))); + }; + const nextSeason = rows.reduce((max, row) => Math.max(max, Number(row.season) || 0), 0) + 1; + + return ( + <Box className="flex flex-col gap-2"> + <Typography variant="body2" className="font-semibold">Season names</Typography> + {rows.map((row, index) => ( + // Rows have no identity beyond their position while being edited. + // eslint-disable-next-line react/no-array-index-key + <Box key={index} className="flex items-center gap-2"> + <TextField + label="Season" + type="number" + size="small" + className="w-[100px]" + value={row.season} + onChange={(event) => update(index, { season: event.target.value })} + error={Boolean(seasonRowProblem(row, rows))} + helperText={seasonRowProblem(row, rows) || undefined} + inputProps={{ min: 0, max: MAX_YEAR_SEASON, 'aria-label': `Season number of row ${index + 1}` }} + /> + <TextField + label="Name" + size="small" + fullWidth + value={row.name} + onChange={(event) => update(index, { name: event.target.value })} + inputProps={{ 'aria-label': `Name of season ${row.season || index + 1}` }} + /> + <IconButton + aria-label={`Remove the name of season ${row.season || index + 1}`} + size="small" + onClick={() => onChange(rows.filter((_, i) => i !== index))} + > + <Delete size={16} /> + </IconButton> + </Box> + ))} + <Box> + <Button + variant="text" + size="small" + startIcon={<Add size={16} />} + onClick={() => onChange([...rows, { season: String(nextSeason), name: '' }])} + > + Name a season + </Button> + </Box> + </Box> + ); +} + +export default SeasonNamesEditor; diff --git a/client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx b/client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx new file mode 100644 index 000000000..c7c7bb7e2 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx @@ -0,0 +1,206 @@ +import React, { useId, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + Typography, +} from '../../../ui'; +import { Add } from '../../../../lib/icons'; +import { TitleShow, TitleShowDraft } from '../../../../types/titleShows'; +import { ReorganizeChange } from '../../../../types/reorganize'; +import { libraryFolderLabel } from '../../../../utils/libraryLayouts'; +import { isReorganizeRequired } from '../../../shared/Reorganize/reorganizeErrors'; +import { useTitleShowPreview } from '../../hooks/useTitleShowPreview'; +import { ShowFolderTakenError } from '../../hooks/useTitleShows'; +import PatternRow from './PatternRow'; +import SeasonNamesEditor from './SeasonNamesEditor'; +import TitleShowPreviewTabs from './TitleShowPreviewTabs'; +import { EMPTY_PATTERN, useTitleShowForm } from './useTitleShowForm'; + +const SYNTAX_HELP = 'Placeholders: {season}, {episode}, {title} (the episode title), {episode_end} and {part} (recognized, ' + + 'not placed yet). * matches any text, a space any spaces, letters ignore case; start with ^ to match from the start of the title.'; +const NO_TV_FOLDER = 'Set up a TV folder first: switch this channel to TV show above, or set a folder to TV shows in ' + + 'Settings > Core > File Structure.'; +const EXCLUDE_HELP = 'One per line. A title containing any of them never joins this show (another show can still take it).'; + +export interface TitleShowEditorDialogProps { + open: boolean; + token: string | null; + channelId: string; + /** The show being edited, or null for a new one */ + show: TitleShow | null; + /** The channel's active shows as drafts, in order (the edited one included) */ + drafts: TitleShowDraft[]; + tvFolders: string[]; + defaultLibraryFolder: string | null; + onClose: () => void; + /** Save the show; rejects like useTitleShows' changes */ + onSave: (draft: TitleShowDraft) => Promise<void>; + /** The save moves downloaded videos: review this change instead */ + onReviewMove: (change: ReorganizeChange) => void; + /** Restore a removed show that used the folder */ + onRestore?: (showId: number) => Promise<void>; +} + +/** Add or edit a title show: name, folder, patterns, exclude terms, season names, with a live preview. */ +function TitleShowEditorDialog({ + open, token, channelId, show, drafts, tvFolders, defaultLibraryFolder, onClose, onSave, onReviewMove, onRestore, +}: TitleShowEditorDialogProps) { + const folderLabelId = useId(); + const defaultFolder = show ? show.libraryFolder : defaultLibraryFolder ?? tvFolders[0] ?? ''; + const { form, update, draft, valid } = useTitleShowForm(open, show, defaultFolder); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState<string | null>(null); + const [taken, setTaken] = useState<ShowFolderTakenError | null>(null); + + // The channel's whole set of shows with this one in place, as the server classifies them. + const index = show ? drafts.findIndex((entry) => entry.id === show.id) : drafts.length; + const showKey = show ? `title:${show.id}` : `new:${index}`; + const previewShows = useMemo(() => (show + ? drafts.map((entry) => (entry.id === show.id ? draft : entry)) + : [...drafts, draft]), [drafts, draft, show]); + const { preview, loading, error, current } = useTitleShowPreview(channelId, token, previewShows, { enabled: open && valid }); + const compiled = preview?.compiled.find((entry) => entry.key === showKey)?.patterns ?? []; + + const setPattern = (position: number, pattern: typeof form.patterns[number]) => { + update({ patterns: form.patterns.map((entry, i) => (i === position ? pattern : entry)) }); + }; + const movePattern = (from: number, to: number) => { + const patterns = [...form.patterns]; + const [moved] = patterns.splice(from, 1); + patterns.splice(to, 0, moved); + update({ patterns }); + }; + + const save = async () => { + setSaving(true); + setSaveError(null); + setTaken(null); + try { + await onSave(draft); + onClose(); + } catch (err: unknown) { + if (isReorganizeRequired(err)) onReviewMove(err.change); + else if (err instanceof ShowFolderTakenError) setTaken(err); + else setSaveError(err instanceof Error ? err.message : 'Failed to save the show'); + } finally { + setSaving(false); + } + }; + + const noTvFolder = tvFolders.length === 0; + + return ( + <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth> + <DialogTitle onClose={onClose}>{show ? `Edit ${show.name}` : 'Add a show'}</DialogTitle> + <DialogContent> + <Box className="flex flex-col gap-4 pt-2"> + {noTvFolder && <Alert severity="warning">{NO_TV_FOLDER}</Alert>} + <Box className="flex flex-wrap gap-3"> + <TextField + label="Show name" size="small" className="min-w-[220px] flex-1" value={form.name} + onChange={(event) => update({ name: event.target.value })} + /> + <TextField + label="Folder name" size="small" className="min-w-[220px] flex-1" value={form.folderName} + placeholder="Same as the name" onChange={(event) => update({ folderName: event.target.value })} + /> + {!noTvFolder && ( + <FormControl className="min-w-[180px]"> + <InputLabel id={folderLabelId} shrink>TV folder</InputLabel> + <Select labelId={folderLabelId} size="small" value={form.libraryFolder} onChange={(event) => update({ libraryFolder: String(event.target.value) })}> + {tvFolders.map((folder) => <MenuItem key={folder || '(main)'} value={folder}>{libraryFolderLabel(folder)}</MenuItem>)} + </Select> + </FormControl> + )} + </Box> + + <Box className="flex flex-col gap-2"> + <Typography variant="body2" className="font-semibold">Title patterns</Typography> + <Typography variant="caption" color="text.secondary">{SYNTAX_HELP}</Typography> + {form.patterns.map((pattern, position) => ( + <PatternRow + // Patterns have no identity beyond their position while being edited. + // eslint-disable-next-line react/no-array-index-key + key={position} + index={position} + count={form.patterns.length} + pattern={pattern} + compiledRegex={current ? compiled[position] ?? null : null} + onChange={(next) => setPattern(position, next)} + onRemove={() => update({ patterns: form.patterns.filter((_, i) => i !== position) })} + onMove={(to) => movePattern(position, to)} + /> + ))} + <Box> + <Button variant="text" size="small" startIcon={<Add size={16} />} onClick={() => update({ patterns: [...form.patterns, { ...EMPTY_PATTERN }] })}> + Add a pattern + </Button> + </Box> + </Box> + + <Box className="flex flex-wrap gap-4"> + <Box className="min-w-[260px] flex-1"> + <TextField + label="Exclude titles containing" multiline rows={3} size="small" fullWidth value={form.excludeText} + helperText={EXCLUDE_HELP} onChange={(event) => update({ excludeText: event.target.value })} + /> + </Box> + <Box className="min-w-[260px] flex-1"> + <SeasonNamesEditor rows={form.seasonRows} onChange={(seasonRows) => update({ seasonRows })} /> + </Box> + </Box> + + {saveError && <Alert severity="error">{saveError}</Alert>} + {taken && ( + <Alert severity="warning"> + <Typography variant="body2">{taken.message}</Typography> + <Box className="mt-2 flex flex-wrap gap-2"> + {taken.suggestion && ( + <Button size="small" variant="outlined" onClick={() => { update({ folderName: taken.suggestion || '' }); setTaken(null); }}> + {`Use "${taken.suggestion}"`} + </Button> + )} + {taken.retiredShowId !== null && onRestore && ( + <Button size="small" variant="outlined" onClick={() => { void onRestore(taken.retiredShowId as number); onClose(); }}> + Restore the removed show + </Button> + )} + </Box> + </Alert> + )} + + <Box className="flex flex-col gap-2"> + <Typography variant="body2" className="font-semibold">Preview</Typography> + {valid + ? <TitleShowPreviewTabs preview={preview} showKey={showKey} loading={loading} error={error} /> + : <Typography variant="body2" color="text.secondary">Name the show and write a pattern to preview it.</Typography>} + </Box> + </Box> + </DialogContent> + <DialogActions> + <Button variant="outlined" onClick={onClose}>Cancel</Button> + <Button + variant="contained" + disabled={!valid || saving || noTvFolder} + startIcon={saving ? <CircularProgress size={14} /> : undefined} + onClick={() => { void save(); }} + > + Save show + </Button> + </DialogActions> + </Dialog> + ); +} + +export default TitleShowEditorDialog; diff --git a/client/src/components/ChannelPage/components/TitleShows/TitleShowList.tsx b/client/src/components/ChannelPage/components/TitleShows/TitleShowList.tsx new file mode 100644 index 000000000..eb1ff603d --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/TitleShowList.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { Box, Button, IconButton, Typography } from '../../../ui'; +import { ArrowDownward, ArrowUpward, Delete } from '../../../../lib/icons'; +import { TitleShow } from '../../../../types/titleShows'; +import { libraryFolderLabel } from '../../../../utils/libraryLayouts'; + +const REMOVE_NOTE = 'Its videos follow the channel\'s layout again. Its order and hand-assigned numbers are kept if you restore it.'; + +function countsText(show: TitleShow): string { + const counts = show.counts; + if (!counts) return ''; + const parts = [`${counts.episodes} ${counts.episodes === 1 ? 'episode' : 'episodes'}`, `${counts.downloaded} downloaded`]; + if (counts.duplicates > 0) parts.push(`${counts.duplicates} ${counts.duplicates === 1 ? 'duplicate' : 'duplicates'}`); + if (counts.unsupported > 0) parts.push(`${counts.unsupported} not supported yet`); + return parts.join(', '); +} + +interface TitleShowListProps { + shows: TitleShow[]; + retired: TitleShow[]; + busy: boolean; + onEdit: (show: TitleShow) => void; + onRemove: (show: TitleShow) => void; + onRestore: (show: TitleShow) => void; + /** Move the show at one position to another */ + onMove: (from: number, to: number) => void; +} + +/** A channel's title shows in order, with their counts and actions, and its removed shows. */ +function TitleShowList({ shows, retired, busy, onEdit, onRemove, onRestore, onMove }: TitleShowListProps) { + const [confirming, setConfirming] = useState<number | null>(null); + + return ( + <Box className="flex flex-col gap-2"> + {shows.length === 0 && ( + <Typography variant="body2" color="text.secondary">No shows yet. Add one for a series this channel uploads.</Typography> + )} + <ul aria-label="Shows" className="flex flex-col gap-2"> + {shows.map((show, index) => ( + <li key={show.id} className="flex flex-col gap-1 rounded-[var(--radius-ui)] border border-border p-3"> + <Box className="flex flex-wrap items-center gap-2"> + <Typography variant="body2" className="min-w-0 flex-1 font-semibold break-words">{show.name}</Typography> + <IconButton aria-label={`Move ${show.name} up`} size="small" disabled={busy || index === 0} onClick={() => onMove(index, index - 1)}> + <ArrowUpward size={16} /> + </IconButton> + <IconButton + aria-label={`Move ${show.name} down`} size="small" disabled={busy || index === shows.length - 1} + onClick={() => onMove(index, index + 1)} + > + <ArrowDownward size={16} /> + </IconButton> + <Button size="small" variant="outlined" disabled={busy} aria-label={`Edit ${show.name}`} onClick={() => onEdit(show)}>Edit</Button> + <IconButton aria-label={`Remove ${show.name}`} size="small" disabled={busy} onClick={() => setConfirming(show.id)}> + <Delete size={16} /> + </IconButton> + </Box> + <Typography variant="caption" color="text.secondary">{libraryFolderLabel(show.libraryFolder)}/{show.folderName}</Typography> + {show.counts && <Typography variant="caption" color="text.secondary">{countsText(show)}</Typography>} + {confirming === show.id && ( + <Box className="flex flex-wrap items-center gap-2"> + <Typography variant="caption" className="flex-1">{`Remove ${show.name}? ${REMOVE_NOTE}`}</Typography> + <Button size="small" variant="text" onClick={() => setConfirming(null)}>Cancel</Button> + <Button size="small" variant="contained" color="error" onClick={() => { setConfirming(null); onRemove(show); }}>Remove</Button> + </Box> + )} + </li> + ))} + </ul> + {retired.length > 0 && ( + <Box className="flex flex-col gap-1"> + <Typography variant="caption" color="text.secondary">Removed shows</Typography> + {retired.map((show) => ( + <Box key={show.id} className="flex items-center gap-2"> + <Typography variant="body2" color="text.secondary" className="flex-1 break-words">{show.name}</Typography> + <Button size="small" variant="text" disabled={busy} aria-label={`Restore ${show.name}`} onClick={() => onRestore(show)}> + Restore + </Button> + </Box> + ))} + </Box> + )} + </Box> + ); +} + +export default TitleShowList; diff --git a/client/src/components/ChannelPage/components/TitleShows/TitleShowPreviewTabs.tsx b/client/src/components/ChannelPage/components/TitleShows/TitleShowPreviewTabs.tsx new file mode 100644 index 000000000..418f9e286 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/TitleShowPreviewTabs.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react'; +import { Alert, Box, Chip, LinearProgress, Tab, Tabs, Typography } from '../../../ui'; +import { + EpisodeDownloadState, PreviewEpisodeRef, PreviewUnsupported, TitleShowPreview, +} from '../../../../types/titleShows'; + +type TabKey = 'episodes' | 'duplicates' | 'gaps' | 'unmatched' | 'unsupported' | 'changes'; + +const DOWNLOAD_LABELS: Record<EpisodeDownloadState, string> = { + downloaded: 'Downloaded', + queued: 'Queued', + not_downloaded: 'Not downloaded', +}; + +const LOAD_ALL_NOTE = 'Load all of the channel\'s videos on its page to classify the rest too.'; + +function unsupportedText(entry: PreviewUnsupported): string { + if (entry.reason === 'compilation') return `Compilation of episodes ${entry.episode}-${entry.episodeEnd}: not supported yet`; + if (entry.reason === 'part') return `Part ${entry.part} of episode ${entry.episode}: not supported yet`; + if (entry.reason === 'missing-number') return 'The title has no season or episode number for this pattern'; + return 'Its season or episode number is out of range'; +} + +function refText(ref: PreviewEpisodeRef | null): string { + if (!ref) return 'no show'; + if (ref.status === 'pending_number') return `${ref.showName} (numbered when it downloads)`; + if (ref.status === 'duplicate') return `a duplicate in ${ref.showName}`; + if (ref.status === 'opted_out') return 'not an episode'; + return ref.code ? `${ref.code} of ${ref.showName}` : `${ref.showName}`; +} + +interface ListProps { + label: string; + /** How many items the list shows, and how many there are (the server cuts long lists) */ + shown: number; + total: number; + children: React.ReactNode; +} + +function List({ label, shown, total, children }: ListProps) { + return ( + <ul aria-label={label} className="max-h-[320px] divide-y divide-border overflow-auto"> + {children} + {total > shown && ( + <li className="py-1.5"><Typography variant="caption" color="text.secondary">Showing the first {shown} of {total}.</Typography></li> + )} + </ul> + ); +} + +interface TitleShowPreviewTabsProps { + preview: TitleShowPreview | null; + /** The edited show's key in the preview ('title:<id>' or 'new:<index>') */ + showKey: string; + loading: boolean; + error: string | null; +} + +/** What a show's patterns do to the channel's known videos. */ +function TitleShowPreviewTabs({ preview, showKey, loading, error }: TitleShowPreviewTabsProps) { + const [tab, setTab] = useState<TabKey>('episodes'); + if (!preview) { + return ( + <Box className="flex flex-col gap-2"> + {loading && <LinearProgress />} + {error && <Alert severity="error">{error}</Alert>} + </Box> + ); + } + const show = preview.shows.find((entry) => entry.key === showKey) || null; + const duplicates = preview.duplicates.filter((entry) => entry.showKey === showKey); + const gaps = preview.gaps.filter((entry) => entry.showKey === showKey); + const unsupported = preview.unsupported.filter((entry) => entry.showKey === showKey); + const episodes = show ? show.episodes : []; + const duplicateCount = show ? show.counts.duplicates : duplicates.length; + const unsupportedCount = show ? show.counts.unsupported : unsupported.length; + + return ( + <Box className="flex flex-col gap-2"> + {loading && <LinearProgress />} + {error && <Alert severity="error">{error}</Alert>} + <Typography variant="caption" color="text.secondary"> + Based on {preview.knownVideos} known videos. {LOAD_ALL_NOTE} + </Typography> + {preview.staysOutside > 0 && ( + <Typography variant="caption" color="text.secondary"> + {preview.staysOutside === 1 + ? '1 downloaded video is outside the downloads folder: its file stays where it is.' + : `${preview.staysOutside} downloaded videos are outside the downloads folder: their files stay where they are.`} + </Typography> + )} + <Tabs value={tab} onChange={(_event, value) => setTab(value as TabKey)} variant="scrollable" aria-label="Preview"> + <Tab value="episodes" label={`Episodes (${show ? show.counts.episodes : 0})`} /> + <Tab value="duplicates" label={`Duplicates (${duplicateCount})`} /> + <Tab value="gaps" label={`Gaps (${gaps.filter((gap) => gap.missing.length > 0).length})`} /> + <Tab value="unmatched" label={`Unmatched (${preview.unmatched.count})`} /> + <Tab value="unsupported" label={`Not supported (${unsupportedCount})`} /> + <Tab value="changes" label={`Would change (${preview.changeCount})`} /> + </Tabs> + + {tab === 'episodes' && ( + <List label="Episodes" shown={episodes.length} total={show ? show.counts.episodes : 0}> + {episodes.map((episode) => ( + <li key={episode.youtubeId} className="flex flex-wrap items-center gap-2 py-1.5"> + {episode.code + ? <Chip label={episode.code} size="small" variant="outlined" /> + : <Chip label="Numbered when it downloads" size="small" variant="outlined" />} + <Typography variant="body2" className="min-w-0 flex-1 break-words">{episode.episodeTitle || episode.title}</Typography> + <Typography variant="caption" color="text.secondary">{DOWNLOAD_LABELS[episode.downloadState]}</Typography> + </li> + ))} + {episodes.length === 0 && <li className="py-1.5"><Typography variant="body2" color="text.secondary">No video matches yet.</Typography></li>} + </List> + )} + + {tab === 'duplicates' && ( + <List label="Duplicates" shown={duplicates.length} total={duplicateCount}> + {duplicates.map((duplicate) => ( + <li key={duplicate.youtubeId} className="flex flex-col py-1.5"> + <Typography variant="body2" className="break-words">{duplicate.title || duplicate.youtubeId}</Typography> + <Typography variant="caption" color="text.secondary"> + Duplicate of {duplicate.code}: {duplicate.duplicateOfTitle || duplicate.duplicateOf} + {duplicate.downloaded ? ' (this copy is downloaded and stays where it is)' : ' (ignored, not downloaded)'} + </Typography> + </li> + ))} + </List> + )} + + {tab === 'gaps' && ( + <List label="Gaps" shown={gaps.length} total={gaps.length}> + {gaps.map((gap) => ( + <li key={gap.season} className="py-1.5"> + <Typography variant="body2"> + Season {gap.season}: {gap.have} of {gap.highest} + {gap.missing.length > 0 ? `; missing ${gap.missing.map((n) => `E${n}`).join(', ')}${gap.truncated ? ', ...' : ''}` : ''} + </Typography> + </li> + ))} + </List> + )} + + {tab === 'unmatched' && ( + <List label="Unmatched videos" shown={preview.unmatched.videos.length} total={preview.unmatched.count}> + {preview.unmatched.videos.map((video) => ( + <li key={video.youtubeId} className="py-1.5"> + <Typography variant="body2" className="break-words">{video.title}</Typography> + </li> + ))} + </List> + )} + + {tab === 'unsupported' && ( + <List label="Not supported" shown={unsupported.length} total={unsupportedCount}> + {unsupported.map((entry) => ( + <li key={entry.youtubeId} className="flex flex-col py-1.5"> + <Typography variant="body2" className="break-words">{entry.title || entry.youtubeId}</Typography> + <Typography variant="caption" color="text.secondary">{unsupportedText(entry)}</Typography> + </li> + ))} + </List> + )} + + {tab === 'changes' && ( + <Box className="flex flex-col gap-1"> + {preview.filesToMove > 0 && ( + <Alert severity="info"> + {preview.filesToMove} downloaded {preview.filesToMove === 1 ? 'video moves' : 'videos move'} when you save: you review the move first. + </Alert> + )} + <List label="Changes" shown={preview.changes.length} total={preview.changeCount}> + {preview.changes.map((change) => ( + <li key={change.youtubeId} className="flex flex-col py-1.5"> + <Typography variant="body2" className="break-words">{change.title}</Typography> + <Typography variant="caption" color="text.secondary">{`${refText(change.from)} -> ${refText(change.to)}`}</Typography> + </li> + ))} + </List> + </Box> + )} + </Box> + ); +} + +export default TitleShowPreviewTabs; diff --git a/client/src/components/ChannelPage/components/TitleShows/TitleShowsSection.tsx b/client/src/components/ChannelPage/components/TitleShows/TitleShowsSection.tsx new file mode 100644 index 000000000..776e6679d --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/TitleShowsSection.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, Typography } from '../../../ui'; +import { Add } from '../../../../lib/icons'; +import { TitleShow, TitleShowConflict, TitleShowDraft } from '../../../../types/titleShows'; +import { ReorganizeChange, ReorganizeStartResult } from '../../../../types/reorganize'; +import { isReorganizeRequired } from '../../../shared/Reorganize/reorganizeErrors'; +import ReorganizeDialog from '../../../shared/Reorganize/ReorganizeDialog'; +import { useReorganizeOutcome } from '../../../shared/Reorganize/hooks/useReorganizeOutcome'; +import EpisodeAssignDialog from '../../../shared/EpisodeAssign/EpisodeAssignDialog'; +import DeleteVideosDialog from '../../../shared/DeleteVideosDialog'; +import { useVideoDeletion } from '../../../shared/useVideoDeletion'; +import { useTitleShows } from '../../hooks/useTitleShows'; +import TitleShowList from './TitleShowList'; +import ConflictList from './ConflictList'; +import TitleShowEditorDialog from './TitleShowEditorDialog'; + +const INTRO = 'Shows built from video titles, such as "Hermitcraft 10: Episode 43". The first show whose pattern matches ' + + 'a title takes the video; the channel\'s other videos follow its layout.'; +const SHOW_ONLY_NOTE = 'Channel downloads skip videos no show takes. Download All queues only show episodes.'; + +/** A stored show as the draft that saves it unchanged. */ +export function titleShowToDraft(show: TitleShow): TitleShowDraft { + return { + id: show.id, + name: show.name, + folderName: show.folderName, + libraryFolder: show.libraryFolder, + excludeTerms: show.excludeTerms, + seasonNames: show.seasonNames, + patterns: show.patterns.map(({ text, kind, seasonSource, seasonFixed, episodeSource }) => ({ + text, kind, seasonSource, seasonFixed, episodeSource, + })), + }; +} + +interface TitleShowsSectionProps { + token: string | null; + channelId: string; + disabled?: boolean; + /** The channel's TV layout and folder: a change moves where new shows go */ + tvKey?: string; + /** A move a change started has ended (its videos may not all have moved) */ + onMoveEnded?: () => void; +} + +/** Channel Settings: the channel's title shows, their duplicates, and the show-only download switch. */ +function TitleShowsSection({ token, channelId, disabled = false, tvKey = '', onMoveEnded }: TitleShowsSectionProps) { + const titleShows = useTitleShows(channelId, token); + const loadedTvKey = useRef(tvKey); + const { refetch } = titleShows; + useEffect(() => { + if (loadedTvKey.current === tvKey) return; + loadedTvKey.current = tvKey; + void refetch(); + }, [tvKey, refetch]); + const { deleteVideos } = useVideoDeletion(); + const [editing, setEditing] = useState<TitleShow | 'new' | null>(null); + const [move, setMove] = useState<ReorganizeChange | null>(null); + const [assigning, setAssigning] = useState<TitleShowConflict | null>(null); + const [deleting, setDeleting] = useState<TitleShowConflict | null>(null); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState<string | null>(null); + // The move a change started: the shows are read again when it ends (the + // server undoes the change when nothing could move). + const [trackedMove, setTrackedMove] = useState<{ operationId: number; attempt: number } | null>(null); + useReorganizeOutcome(token, trackedMove?.operationId ?? null, () => { + void titleShows.refetch(); + onMoveEnded?.(); + }, { + attempt: trackedMove?.attempt ?? 0, + }); + + const data = titleShows.data; + const active = useMemo(() => (data ? data.shows.filter((show) => !show.retired) : []), [data]); + const retired = useMemo(() => (data ? data.shows.filter((show) => show.retired) : []), [data]); + const drafts = useMemo(() => active.map(titleShowToDraft), [active]); + + // A change that moves downloaded videos opens the review instead. + const run = async (action: () => Promise<void>) => { + setBusy(true); + setActionError(null); + try { + await action(); + } catch (err: unknown) { + if (isReorganizeRequired(err)) setMove(err.change); + else setActionError(err instanceof Error ? err.message : 'The change could not be saved'); + } finally { + setBusy(false); + } + }; + + const moveShow = (from: number, to: number) => { + const ids = active.map((show) => show.id); + const [moved] = ids.splice(from, 1); + ids.splice(to, 0, moved); + void run(() => titleShows.reorderShows(ids)); + }; + + const confirmDelete = async () => { + const conflict = deleting; + setDeleting(null); + if (!conflict || conflict.videoId === null) return; + await run(async () => { + const result = await deleteVideos([conflict.videoId as number], token); + if (!result.success) throw new Error(result.failed[0]?.error || 'Failed to delete the copy'); + await titleShows.refetch(); + }); + }; + + if (!data) { + if (titleShows.error) return <Alert severity="error">{titleShows.error}</Alert>; + return <Box className="flex justify-center py-6"><CircularProgress /></Box>; + } + const locked = disabled || busy; + + return ( + <Box className="flex flex-col gap-3"> + <Box className="flex flex-col gap-1"> + <Typography variant="subtitle2" className="font-semibold">Shows in this channel</Typography> + <Typography variant="caption" color="text.secondary">{INTRO}</Typography> + </Box> + <TitleShowList + shows={active} + retired={retired} + busy={locked} + onEdit={(show) => setEditing(show)} + onRemove={(show) => { void run(() => titleShows.retireShow(show.id)); }} + onRestore={(show) => { void run(() => titleShows.restoreShow(show.id)); }} + onMove={moveShow} + /> + <Box> + <Button variant="outlined" size="small" startIcon={<Add size={16} />} disabled={locked} onClick={() => setEditing('new')}> + Add show + </Button> + </Box> + {actionError && <Alert severity="error" onClose={() => setActionError(null)}>{actionError}</Alert>} + <Box className="flex flex-col gap-1"> + <FormControlLabel + control={( + <Switch + checked={data.showOnlyDownloads} + disabled={locked || active.length === 0} + onChange={(event) => { void run(() => titleShows.setShowOnly(event.target.checked)); }} + /> + )} + label="Only download videos that belong to a show" + /> + <Typography variant="caption" color="text.secondary">{SHOW_ONLY_NOTE}</Typography> + </Box> + <ConflictList + conflicts={data.conflicts} + busy={locked} + onUseCopy={(conflict) => { void run(() => titleShows.takeDuplicateCopy(conflict.youtubeId)); }} + onAssign={setAssigning} + onDelete={setDeleting} + onRecheck={() => { void run(() => titleShows.recheck()); }} + /> + + <TitleShowEditorDialog + open={editing !== null} + token={token} + channelId={channelId} + show={editing === 'new' ? null : editing} + drafts={drafts} + tvFolders={data.tvFolders} + defaultLibraryFolder={data.defaultLibraryFolder} + onClose={() => setEditing(null)} + onSave={(draft) => (draft.id ? titleShows.updateShow(draft.id, draft) : titleShows.createShow(draft))} + onReviewMove={(change) => { setEditing(null); setMove(change); }} + onRestore={(showId) => run(() => titleShows.restoreShow(showId))} + /> + <ReorganizeDialog + open={move !== null} + token={token} + change={move} + onClose={() => { setMove(null); void titleShows.refetch(); }} + onApplied={(result: ReorganizeStartResult) => { + if (result.operationId) setTrackedMove({ operationId: result.operationId, attempt: 0 }); + else void titleShows.refetch(); + }} + onRetried={(operationId: number) => setTrackedMove((current) => ({ + operationId, + attempt: current && current.operationId === operationId ? current.attempt + 1 : 1, + }))} + /> + <EpisodeAssignDialog + open={assigning !== null} + token={token} + youtubeId={assigning?.youtubeId ?? null} + videoTitle={assigning?.title ?? null} + onClose={() => setAssigning(null)} + onSaved={() => { void titleShows.refetch(); }} + /> + <DeleteVideosDialog open={deleting !== null} onClose={() => setDeleting(null)} onConfirm={() => { void confirmDelete(); }} videoCount={1} /> + </Box> + ); +} + +export default TitleShowsSection; diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/ChannelShowFilter.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/ChannelShowFilter.test.tsx new file mode 100644 index 000000000..0a1c2464a --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/ChannelShowFilter.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ChannelShowFilter from '../ChannelShowFilter'; + +const SHOWS = [{ id: 3, name: 'Beyblade' }, { id: 4, name: 'Clips' }]; + +describe('ChannelShowFilter', () => { + test('renders nothing for a channel without shows', () => { + const { container } = render(<ChannelShowFilter shows={[]} value={null} onChange={jest.fn()} onShowMissing={jest.fn()} />); + expect(container).toBeEmptyDOMElement(); + }); + + test('offers the missing episodes of the chosen show', () => { + const onShowMissing = jest.fn(); + render(<ChannelShowFilter shows={SHOWS} value={4} onChange={jest.fn()} onShowMissing={onShowMissing} />); + fireEvent.click(screen.getByRole('button', { name: 'Missing episodes' })); + expect(onShowMissing).toHaveBeenCalledWith(4); + }); + + test('offers no missing episodes while every video is listed', () => { + render(<ChannelShowFilter shows={SHOWS} value={null} onChange={jest.fn()} onShowMissing={jest.fn()} />); + expect(screen.queryByRole('button', { name: 'Missing episodes' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/ConflictList.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/ConflictList.test.tsx new file mode 100644 index 000000000..aae2e207b --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/ConflictList.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ConflictList from '../ConflictList'; +import { TitleShowConflict } from '../../../../../types/titleShows'; + +function conflict(extra: Partial<TitleShowConflict> = {}): TitleShowConflict { + return { + youtubeId: 'JN49nXLOqQI', kind: 'duplicate', showId: 3, duplicateOf: 'y7xVT7DTt2k', season: 1, episode: 20, message: null, + suppressed: true, title: 'BEYBLADE EN Episode 20 (2022)', downloaded: false, videoId: null, + duplicateOfTitle: 'BEYBLADE EN Episode 20 (2020)', ...extra, + }; +} + +function renderList(conflicts: TitleShowConflict[]) { + const handlers = { onUseCopy: jest.fn(), onAssign: jest.fn(), onDelete: jest.fn(), onRecheck: jest.fn() }; + render(<ConflictList conflicts={conflicts} busy={false} {...handlers} />); + return handlers; +} + +describe('ConflictList', () => { + test('names the episode a duplicate lost and the upload holding it', () => { + renderList([conflict()]); + expect(screen.getByText('Duplicate of S01E20: BEYBLADE EN Episode 20 (2020). Ignored, so it isn\'t downloaded.')).toBeInTheDocument(); + }); + + test('uses a duplicate\'s copy instead', () => { + const { onUseCopy } = renderList([conflict()]); + fireEvent.click(screen.getByRole('button', { name: 'Use this copy instead' })); + expect(onUseCopy).toHaveBeenCalledWith(expect.objectContaining({ youtubeId: 'JN49nXLOqQI' })); + }); + + test('assigns a video that is not a duplicate by hand', () => { + const { onAssign } = renderList([conflict()]); + fireEvent.click(screen.getByRole('button', { name: 'Not a duplicate' })); + expect(onAssign).toHaveBeenCalledWith(expect.objectContaining({ youtubeId: 'JN49nXLOqQI' })); + }); + + test('offers to delete only a downloaded copy', () => { + const { onDelete } = renderList([conflict({ downloaded: true, videoId: 8, suppressed: false })]); + expect(screen.getByText(/Downloaded duplicate/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Delete this copy' })); + expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ videoId: 8 })); + }); + + test('checks a title that could not be classified again', () => { + const { onRecheck } = renderList([conflict({ kind: 'classification_error', message: 'timed out', showId: null, duplicateOf: null })]); + expect(screen.getByText('Its title could not be checked against the shows: timed out')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Check again' })); + expect(onRecheck).toHaveBeenCalled(); + }); + + test('renders nothing without conflicts', () => { + renderList([]); + expect(screen.queryByText('Duplicates and errors')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/MissingEpisodesDialog.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/MissingEpisodesDialog.test.tsx new file mode 100644 index 000000000..abf3891da --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/MissingEpisodesDialog.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MissingEpisodes } from '../../../../../types/titleShows'; + +let mockState: { data: MissingEpisodes | null; loading: boolean; error: string | null }; +jest.mock('../../../hooks/useMissingEpisodes', () => ({ useMissingEpisodes: () => mockState })); + +import MissingEpisodesDialog from '../MissingEpisodesDialog'; + +const MISSING: MissingEpisodes = { + showId: 3, + name: 'Beyblade', + seasons: [ + { + season: 1, name: 'Beyblade', episodes: 3, downloaded: 1, gaps: [2], gapsTruncated: false, + notDownloaded: [{ youtubeId: 'b', title: 'BEYBLADE EN Episode 3: Third', episode: 3, code: 'S01E03' }], + }, + { season: 2, name: null, episodes: 2, downloaded: 2, gaps: [], gapsTruncated: false, notDownloaded: [] }, + ], +}; + +describe('MissingEpisodesDialog', () => { + beforeEach(() => { + mockState = { data: MISSING, loading: false, error: null }; + }); + + const renderDialog = () => render(<MissingEpisodesDialog open token="token" channelId="UC1" showId={3} onClose={jest.fn()} />); + + test('summarizes each season', () => { + renderDialog(); + expect(screen.getByText('Season 1, Beyblade: 1 of 3 downloaded')).toBeInTheDocument(); + expect(screen.getByText('Season 2: all 2 downloaded')).toBeInTheDocument(); + }); + + test('lists the episodes not downloaded yet', () => { + renderDialog(); + expect(screen.getByText('S01E03')).toBeInTheDocument(); + expect(screen.getByText('BEYBLADE EN Episode 3: Third')).toBeInTheDocument(); + }); + + test('lists the numbers no known video holds', () => { + renderDialog(); + expect(screen.getByText('No known video for E2.')).toBeInTheDocument(); + }); + + test('reports a failed load', () => { + mockState = { data: null, loading: false, error: 'Show not found' }; + renderDialog(); + expect(screen.getByText('Show not found')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/PatternRow.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/PatternRow.test.tsx new file mode 100644 index 000000000..40cb4c84e --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/PatternRow.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import PatternRow from '../PatternRow'; +import { TitlePatternDraft } from '../../../../../types/titleShows'; + +const PATTERN: TitlePatternDraft = { + text: 'BEYBLADE EN Episode {episode}: {title}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title', +}; + +function renderRow(props: Partial<React.ComponentProps<typeof PatternRow>> = {}) { + const onChange = jest.fn(); + const onRemove = jest.fn(); + const onMove = jest.fn(); + render( + <PatternRow + index={0} + count={2} + pattern={PATTERN} + compiledRegex={'(?i)BEYBLADE\\s+EN'} + onChange={onChange} + onRemove={onRemove} + onMove={onMove} + {...props} + /> + ); + return { onChange, onRemove, onMove }; +} + +describe('PatternRow', () => { + test('edits the pattern text', () => { + const { onChange } = renderRow(); + fireEvent.change(screen.getByLabelText('Pattern 1'), { target: { value: 'Ep {episode}' } }); + expect(onChange).toHaveBeenCalledWith({ ...PATTERN, text: 'Ep {episode}' }); + }); + + test.each([ + ['simple', PATTERN], + ['regex', { ...PATTERN, kind: 'regex' as const, text: '(?i)BEYBLADE\\s+EN' }], + ])('styles the %s pattern input like the row\'s other text fields', (_mode, pattern) => { + renderRow({ pattern }); + const fieldClasses = screen.getByLabelText('Season number').className.split(' '); + expect(screen.getByLabelText('Pattern 1')).toHaveClass(...fieldClasses); + }); + + test('switches to regex mode with the compiled pattern', () => { + const { onChange } = renderRow(); + fireEvent.click(screen.getByRole('button', { name: 'Edit as regular expression' })); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ kind: 'regex', text: '(?i)BEYBLADE\\s+EN' })); + }); + + test('keeps the simple text for the way back', () => { + const { onChange } = renderRow(); + fireEvent.click(screen.getByRole('button', { name: 'Edit as regular expression' })); + expect(onChange.mock.calls[0][0].simpleText).toBe(PATTERN.text); + }); + + test('switches back to the simple text it started from', () => { + const { onChange } = renderRow({ pattern: { ...PATTERN, kind: 'regex', text: '(?i)BEYBLADE\\s+EN', simpleText: PATTERN.text } }); + fireEvent.click(screen.getByRole('button', { name: 'Use the simple syntax' })); + expect(onChange).toHaveBeenCalledWith({ ...PATTERN, simpleText: undefined }); + }); + + test('offers regex mode only once the preview has compiled the current text', () => { + renderRow({ compiledRegex: null }); + expect(screen.getByRole('button', { name: 'Edit as regular expression' })).toBeDisabled(); + }); + + test('keeps a saved regex for the way back when it switches to the simple syntax', () => { + const { onChange } = renderRow({ pattern: { ...PATTERN, kind: 'regex', text: 'x' } }); + fireEvent.click(screen.getByRole('button', { name: 'Use the simple syntax' })); + expect(onChange).toHaveBeenCalledWith({ ...PATTERN, kind: 'simple', text: 'x', simpleText: undefined, regexText: 'x' }); + }); + + test('restores the saved regex without a compiled preview', () => { + const { onChange } = renderRow({ pattern: { ...PATTERN, text: 'x', regexText: 'x' }, compiledRegex: null }); + const button = screen.getByRole('button', { name: 'Edit as regular expression' }); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(onChange).toHaveBeenCalledWith({ ...PATTERN, kind: 'regex', text: 'x', regexText: undefined }); + }); + + test('forgets the saved regex once the simple text is edited', () => { + const { onChange } = renderRow({ pattern: { ...PATTERN, text: 'x', regexText: 'x' } }); + fireEvent.change(screen.getByLabelText('Pattern 1'), { target: { value: 'Ep {episode}' } }); + expect(onChange).toHaveBeenCalledWith({ ...PATTERN, text: 'Ep {episode}', regexText: undefined }); + }); + + test('sets a fixed season number', () => { + const { onChange } = renderRow(); + fireEvent.change(screen.getByLabelText('Season number'), { target: { value: '2' } }); + expect(onChange).toHaveBeenCalledWith({ ...PATTERN, seasonFixed: 2 }); + }); + + test('hides the season number for a season taken from the title', () => { + renderRow({ pattern: { ...PATTERN, seasonSource: 'title' } }); + expect(screen.queryByLabelText('Season number')).not.toBeInTheDocument(); + }); + + test('removes the pattern', () => { + const { onRemove } = renderRow(); + fireEvent.click(screen.getByRole('button', { name: 'Remove pattern 1' })); + expect(onRemove).toHaveBeenCalled(); + }); + + test('moves the pattern down but not up from the top', () => { + const { onMove } = renderRow(); + expect(screen.getByRole('button', { name: 'Move pattern 1 up' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Move pattern 1 down' })); + expect(onMove).toHaveBeenCalledWith(1); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/SeasonNamesEditor.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/SeasonNamesEditor.test.tsx new file mode 100644 index 000000000..21f681312 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/SeasonNamesEditor.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import SeasonNamesEditor, { SeasonNameRow, rowsToSeasonNames, seasonNamesToRows } from '../SeasonNamesEditor'; + +describe('SeasonNamesEditor', () => { + const ROWS: SeasonNameRow[] = [{ season: '1', name: 'Beyblade' }]; + + test('names a season', () => { + const onChange = jest.fn(); + render(<SeasonNamesEditor rows={ROWS} onChange={onChange} />); + fireEvent.change(screen.getByLabelText('Name of season 1'), { target: { value: 'Original' } }); + expect(onChange).toHaveBeenCalledWith([{ season: '1', name: 'Original' }]); + }); + + test('adds the next season', () => { + const onChange = jest.fn(); + render(<SeasonNamesEditor rows={ROWS} onChange={onChange} />); + fireEvent.click(screen.getByRole('button', { name: 'Name a season' })); + expect(onChange).toHaveBeenCalledWith([...ROWS, { season: '2', name: '' }]); + }); + + test('removes a season name', () => { + const onChange = jest.fn(); + render(<SeasonNamesEditor rows={ROWS} onChange={onChange} />); + fireEvent.click(screen.getByRole('button', { name: 'Remove the name of season 1' })); + expect(onChange).toHaveBeenCalledWith([]); + }); + + test('flags a season named twice', () => { + render(<SeasonNamesEditor rows={[{ season: '1', name: 'A' }, { season: '1', name: 'B' }]} onChange={jest.fn()} />); + expect(screen.getAllByText('Season 1 is named twice')).toHaveLength(2); + }); + + test('flags a season number media servers don\'t read as a season', () => { + render(<SeasonNamesEditor rows={[{ season: '250', name: 'X' }]} onChange={jest.fn()} />); + expect(screen.getByText('0-199, or a year 1928-2500')).toBeInTheDocument(); + }); + + test('accepts a name for an upload year', () => { + render(<SeasonNamesEditor rows={[{ season: '2024', name: 'The first year' }]} onChange={jest.fn()} />); + expect(screen.queryByText('0-199, or a year 1928-2500')).not.toBeInTheDocument(); + }); + + test('converts rows to season names, dropping empty ones', () => { + expect(rowsToSeasonNames([{ season: '2', name: ' V-Force ' }, { season: '3', name: '' }, { season: 'x', name: 'a' }])) + .toEqual({ 2: 'V-Force' }); + }); + + test('lists season names in order', () => { + expect(seasonNamesToRows({ 3: 'G', 1: 'B' })).toEqual([{ season: '1', name: 'B' }, { season: '3', name: 'G' }]); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowEditorDialog.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowEditorDialog.test.tsx new file mode 100644 index 000000000..264726bc8 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowEditorDialog.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { TitleShow, TitleShowDraft } from '../../../../../types/titleShows'; +import { ReorganizeRequiredError } from '../../../../shared/Reorganize/reorganizeErrors'; +import { ShowFolderTakenError } from '../../../hooks/useTitleShows'; + +const mockPreviewHook = jest.fn(); +jest.mock('../../../hooks/useTitleShowPreview', () => ({ + useTitleShowPreview: (...args: unknown[]) => mockPreviewHook(...args), +})); +jest.mock('../TitleShowPreviewTabs', () => ({ + __esModule: true, + default: function MockTabs(props: { showKey: string }) { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'preview-tabs' }, props.showKey); + }, +})); + +import TitleShowEditorDialog from '../TitleShowEditorDialog'; + +const SHOW: TitleShow = { + id: 3, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV', position: 0, retired: false, + excludeTerms: ['Official Clip'], seasonNames: { 2: 'V-Force' }, counts: null, + patterns: [{ text: 'BEYBLADE EN Episode {episode}: {title}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title', compiledRegex: '(?i)x' }], +}; +const OTHER: TitleShowDraft = { id: 4, name: 'Clips', patterns: [{ text: '{title} | Clip', kind: 'simple', seasonSource: 'fixed', seasonFixed: 0, episodeSource: 'order' }] }; + +function renderEditor(props: Partial<React.ComponentProps<typeof TitleShowEditorDialog>> = {}) { + const handlers = { onClose: jest.fn(), onSave: jest.fn().mockResolvedValue(undefined), onReviewMove: jest.fn(), onRestore: jest.fn() }; + render( + <TitleShowEditorDialog + open + token="token" + channelId="UC1" + show={null} + drafts={[OTHER]} + tvFolders={['TV', 'Anime']} + defaultLibraryFolder="TV" + {...handlers} + {...props} + /> + ); + return handlers; +} + +describe('TitleShowEditorDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPreviewHook.mockReturnValue({ preview: null, loading: false, error: null, current: false }); + }); + + test('offers regex mode once the preview has compiled the patterns as they are', () => { + mockPreviewHook.mockReturnValue({ + preview: { compiled: [{ key: 'title:3', patterns: ['(?i)x'] }] }, loading: false, error: null, current: true, + }); + renderEditor({ show: SHOW }); + expect(screen.getByRole('button', { name: 'Edit as regular expression' })).toBeEnabled(); + }); + + test('holds regex mode back while the preview is out of date', () => { + mockPreviewHook.mockReturnValue({ + preview: { compiled: [{ key: 'title:3', patterns: ['(?i)x'] }] }, loading: false, error: null, current: false, + }); + renderEditor({ show: SHOW }); + expect(screen.getByRole('button', { name: 'Edit as regular expression' })).toBeDisabled(); + }); + + test('previews a new show after the channel\'s other shows', () => { + renderEditor(); + fireEvent.change(screen.getByLabelText('Show name'), { target: { value: 'Beyblade' } }); + fireEvent.change(screen.getByLabelText('Pattern 1'), { target: { value: 'Ep {episode}' } }); + const [, , shows] = mockPreviewHook.mock.calls[mockPreviewHook.mock.calls.length - 1]; + expect(shows).toEqual([OTHER, expect.objectContaining({ name: 'Beyblade', libraryFolder: 'TV' })]); + expect(screen.getByTestId('preview-tabs')).toHaveTextContent('new:1'); + }); + + test('edits an existing show in place', () => { + renderEditor({ show: SHOW, drafts: [{ id: 3, name: 'Beyblade', patterns: [] }, OTHER] }); + expect(screen.getByLabelText('Show name')).toHaveValue('Beyblade'); + expect(screen.getByLabelText('Exclude titles containing')).toHaveValue('Official Clip'); + expect(screen.getByTestId('preview-tabs')).toHaveTextContent('title:3'); + const [, , shows] = mockPreviewHook.mock.calls[mockPreviewHook.mock.calls.length - 1]; + expect(shows.map((draft: TitleShowDraft) => draft.id)).toEqual([3, 4]); + }); + + test('saves the show as a draft', async () => { + const { onSave, onClose } = renderEditor({ show: SHOW, drafts: [{ id: 3, name: 'Beyblade', patterns: [] }] }); + fireEvent.click(screen.getByRole('button', { name: 'Save show' })); + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ + id: 3, + name: 'Beyblade', + folderName: 'Beyblade', + libraryFolder: 'TV', + excludeTerms: ['Official Clip'], + seasonNames: { 2: 'V-Force' }, + patterns: [{ text: 'BEYBLADE EN Episode {episode}: {title}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }], + })); + expect(onClose).toHaveBeenCalled(); + }); + + test('needs a name and a pattern before saving', () => { + renderEditor(); + expect(screen.getByRole('button', { name: 'Save show' })).toBeDisabled(); + }); + + test('adds and removes patterns', () => { + renderEditor({ show: SHOW }); + fireEvent.click(screen.getByRole('button', { name: 'Add a pattern' })); + expect(screen.getByLabelText('Pattern 2')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove pattern 2' })); + expect(screen.queryByLabelText('Pattern 2')).not.toBeInTheDocument(); + }); + + test('hands a save that moves files to the review', async () => { + const change = { type: 'titleShows' as const, channelId: 'UC1', shows: [] }; + const { onSave, onReviewMove } = renderEditor({ show: SHOW }); + onSave.mockRejectedValueOnce(new ReorganizeRequiredError('Review the move first.', change)); + fireEvent.click(screen.getByRole('button', { name: 'Save show' })); + await waitFor(() => expect(onReviewMove).toHaveBeenCalledWith(change)); + }); + + test('offers the suggested folder name when the folder is taken', async () => { + const { onSave } = renderEditor({ show: SHOW }); + onSave.mockRejectedValueOnce(new ShowFolderTakenError('The folder "Beyblade" is taken.', 'Beyblade (Chan)', null)); + fireEvent.click(screen.getByRole('button', { name: 'Save show' })); + fireEvent.click(await screen.findByRole('button', { name: 'Use "Beyblade (Chan)"' })); + expect(screen.getByLabelText('Folder name')).toHaveValue('Beyblade (Chan)'); + }); + + test('offers to restore a removed show that used the folder', async () => { + const { onSave, onRestore } = renderEditor({ show: null }); + fireEvent.change(screen.getByLabelText('Show name'), { target: { value: 'Beyblade' } }); + fireEvent.change(screen.getByLabelText('Pattern 1'), { target: { value: 'Ep {episode}' } }); + onSave.mockRejectedValueOnce(new ShowFolderTakenError('taken', 'Beyblade (Chan)', 5)); + fireEvent.click(screen.getByRole('button', { name: 'Save show' })); + fireEvent.click(await screen.findByRole('button', { name: 'Restore the removed show' })); + expect(onRestore).toHaveBeenCalledWith(5); + }); + + test('asks for a TV folder when the channel has none', () => { + renderEditor({ tvFolders: [], defaultLibraryFolder: null }); + expect(screen.getByText(/Set up a TV folder first/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowList.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowList.test.tsx new file mode 100644 index 000000000..da26750fc --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowList.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TitleShowList from '../TitleShowList'; +import { TitleShow } from '../../../../../types/titleShows'; + +function show(id: number, extra: Partial<TitleShow> = {}): TitleShow { + return { + id, name: `Show ${id}`, folderName: `Show ${id}`, libraryFolder: 'TV', position: id, retired: false, excludeTerms: [], + seasonNames: {}, patterns: [], counts: { episodes: 51, downloaded: 49, duplicates: 22, unsupported: 0 }, ...extra, + }; +} + +function renderList(props: Partial<React.ComponentProps<typeof TitleShowList>> = {}) { + const handlers = { onEdit: jest.fn(), onRemove: jest.fn(), onRestore: jest.fn(), onMove: jest.fn() }; + render(<TitleShowList shows={[show(3), show(4)]} retired={[]} busy={false} {...handlers} {...props} />); + return handlers; +} + +describe('TitleShowList', () => { + // Not "ignored": a downloaded duplicate is kept, never ignored. + test('summarizes each show\'s episodes', () => { + renderList(); + expect(screen.getAllByText('51 episodes, 49 downloaded, 22 duplicates')).toHaveLength(2); + expect(screen.getByText('__TV/Show 3')).toBeInTheDocument(); + }); + + test('edits a show', () => { + const { onEdit } = renderList(); + fireEvent.click(screen.getByRole('button', { name: 'Edit Show 3' })); + expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); + }); + + test('moves a show down the order', () => { + const { onMove } = renderList(); + expect(screen.getByRole('button', { name: 'Move Show 3 up' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Move Show 3 down' })); + expect(onMove).toHaveBeenCalledWith(0, 1); + }); + + test('asks before removing a show', () => { + const { onRemove } = renderList(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Show 3' })); + expect(onRemove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + expect(onRemove).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); + }); + + test('restores a removed show', () => { + const { onRestore } = renderList({ retired: [show(5, { retired: true })] }); + fireEvent.click(screen.getByRole('button', { name: 'Restore Show 5' })); + expect(onRestore).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })); + }); + + test('says when the channel has no shows', () => { + renderList({ shows: [] }); + expect(screen.getByText(/No shows yet/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowPreviewTabs.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowPreviewTabs.test.tsx new file mode 100644 index 000000000..ae13b28aa --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowPreviewTabs.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import TitleShowPreviewTabs from '../TitleShowPreviewTabs'; +import { TitleShowPreview } from '../../../../../types/titleShows'; + +const PREVIEW: TitleShowPreview = { + knownVideos: 312, + staysOutside: 0, + shows: [{ + key: 'new:0', id: null, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV', truncated: false, + counts: { episodes: 2, downloaded: 1, pending: 0, duplicates: 1, unsupported: 1 }, + episodes: [ + { youtubeId: 'a', title: 'BEYBLADE EN Episode 1: The Blade Raider', season: 1, episode: 1, code: 'S01E01', status: 'assigned', episodeTitle: 'The Blade Raider', patternIndex: 0, downloadState: 'downloaded' }, + { youtubeId: 'b', title: 'BEYBLADE EN Episode 3: Third', season: 1, episode: 3, code: 'S01E03', status: 'assigned', episodeTitle: 'Third', patternIndex: 0, downloadState: 'not_downloaded' }, + ], + }], + duplicates: [{ + youtubeId: 'c', title: 'BEYBLADE EN Episode 1: The Blade Raider', showKey: 'new:0', showName: 'Beyblade', season: 1, episode: 1, + code: 'S01E01', duplicateOf: 'a', duplicateOfTitle: 'BEYBLADE EN Episode 1: The Blade Raider (2020)', downloaded: false, + }], + gaps: [{ showKey: 'new:0', season: 1, have: 2, highest: 3, missing: [2], truncated: false }], + unsupported: [{ youtubeId: 'd', title: 'Ep.19 | Ep.20', showKey: 'new:0', showName: 'Beyblade', reason: 'compilation', season: 1, episode: 19, episodeEnd: 20, part: null }], + unmatched: { count: 307, videos: [{ youtubeId: 'e', title: 'BEYBLADE PT-BR EPISÓDIO 6', downloaded: false }] }, + changes: [{ + youtubeId: 'f', title: 'Moved', downloaded: true, + from: { showKey: 'title:2', showName: 'Old', code: 'S01E05', status: 'assigned' }, to: null, + }], + changeCount: 1, + filesToMove: 1, + retired: [], + relocated: [], + compiled: [{ key: 'new:0', patterns: ['(?i)x'] }], +}; + +function renderTabs(props: Partial<React.ComponentProps<typeof TitleShowPreviewTabs>> = {}) { + render(<TitleShowPreviewTabs preview={PREVIEW} showKey="new:0" loading={false} error={null} {...props} />); +} + +describe('TitleShowPreviewTabs', () => { + test('says how many videos the preview is based on', () => { + renderTabs(); + expect(screen.getByText(/Based on 312 known videos/)).toBeInTheDocument(); + }); + + test('lists the show\'s episodes with their codes and download state', () => { + renderTabs(); + expect(screen.getByText('S01E01')).toBeInTheDocument(); + expect(screen.getByText('The Blade Raider')).toBeInTheDocument(); + expect(screen.getAllByText('Downloaded')).toHaveLength(1); + }); + + test('names the upload that keeps a duplicate\'s number', async () => { + renderTabs(); + await userEvent.click(screen.getByRole('tab', { name: 'Duplicates (1)' })); + expect(screen.getByText(/Duplicate of S01E01: BEYBLADE EN Episode 1: The Blade Raider \(2020\)/)).toBeInTheDocument(); + }); + + test('lists the missing numbers of each season', async () => { + renderTabs(); + await userEvent.click(screen.getByRole('tab', { name: 'Gaps (1)' })); + expect(screen.getByText('Season 1: 2 of 3; missing E2')).toBeInTheDocument(); + }); + + test('counts the videos no show takes', async () => { + renderTabs(); + await userEvent.click(screen.getByRole('tab', { name: 'Unmatched (307)' })); + expect(screen.getByText('BEYBLADE PT-BR EPISÓDIO 6')).toBeInTheDocument(); + }); + + test('explains what isn\'t supported yet', async () => { + renderTabs(); + await userEvent.click(screen.getByRole('tab', { name: 'Not supported (1)' })); + expect(screen.getByText(/Compilation of episodes 19-20/)).toBeInTheDocument(); + }); + + test('says when downloaded files outside the downloads folder stay where they are', () => { + renderTabs({ preview: { ...PREVIEW, staysOutside: 2 } }); + expect(screen.getByText('2 downloaded videos are outside the downloads folder: their files stay where they are.')).toBeInTheDocument(); + }); + + test('says nothing about outside files when there are none', () => { + renderTabs(); + expect(screen.queryByText(/outside the downloads folder/)).not.toBeInTheDocument(); + }); + + test('explains a title that skipped its season or episode number', async () => { + renderTabs({ preview: { + ...PREVIEW, + unsupported: [{ youtubeId: 'e', title: 'Episode 3', showKey: 'new:0', showName: 'Beyblade', reason: 'missing-number', season: null, episode: 3, episodeEnd: null, part: null }], + } }); + await userEvent.click(screen.getByRole('tab', { name: 'Not supported (1)' })); + expect(screen.getByText('The title has no season or episode number for this pattern')).toBeInTheDocument(); + }); + + test('lists stored episodes that would change and the files that move', async () => { + renderTabs(); + await userEvent.click(screen.getByRole('tab', { name: 'Would change (1)' })); + expect(screen.getByText('S01E05 of Old -> no show')).toBeInTheDocument(); + expect(screen.getByText(/1 downloaded video moves/)).toBeInTheDocument(); + }); + + test.each([ + ['Episodes (520)', 'Showing the first 2 of 520.'], + ['Duplicates (600)', 'Showing the first 1 of 600.'], + ['Unmatched (307)', 'Showing the first 1 of 307.'], + ['Not supported (3)', 'Showing the first 1 of 3.'], + ['Would change (700)', 'Showing the first 1 of 700.'], + ])('counts every item of %s and says the list is cut short', async (tabName, note) => { + const [show] = PREVIEW.shows; + renderTabs({ preview: { + ...PREVIEW, + shows: [{ ...show, truncated: true, counts: { ...show.counts, episodes: 520, duplicates: 600, unsupported: 3 } }], + changeCount: 700, + } }); + await userEvent.click(screen.getByRole('tab', { name: tabName })); + expect(screen.getByText(note)).toBeInTheDocument(); + }); + + test('says nothing about a cut list when every episode is listed', () => { + renderTabs(); + expect(screen.queryByText(/Showing the first/)).not.toBeInTheDocument(); + }); + + test('shows the server\'s refusal of the drafts', () => { + renderTabs({ error: 'Beyblade, pattern 1: Unknown placeholder {ep}' }); + expect(screen.getByText('Beyblade, pattern 1: Unknown placeholder {ep}')).toBeInTheDocument(); + }); + + test('waits for the first preview', () => { + renderTabs({ preview: null, loading: true }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowsSection.test.tsx b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowsSection.test.tsx new file mode 100644 index 000000000..aee503660 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/TitleShowsSection.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ChannelTitleShows, TitleShow } from '../../../../../types/titleShows'; +import { ReorganizeRequiredError } from '../../../../shared/Reorganize/reorganizeErrors'; + +const mockHook = { + data: null as ChannelTitleShows | null, + loading: false, + error: null as string | null, + refetch: jest.fn(), + createShow: jest.fn(), + updateShow: jest.fn(), + retireShow: jest.fn(), + restoreShow: jest.fn(), + reorderShows: jest.fn(), + setShowOnly: jest.fn(), + takeDuplicateCopy: jest.fn(), + recheck: jest.fn(), +}; +jest.mock('../../../hooks/useTitleShows', () => ({ + ...jest.requireActual('../../../hooks/useTitleShows'), + useTitleShows: () => mockHook, +})); + +const mockDeleteVideos = jest.fn(); +jest.mock('../../../../shared/useVideoDeletion', () => ({ useVideoDeletion: () => ({ deleteVideos: mockDeleteVideos }) })); + +function mockDialog(testId: string) { + return { + __esModule: true, + default: function MockDialog(props: { open: boolean; drafts?: unknown; change?: unknown; onClose: () => void }) { + const React = require('react'); + if (!props.open) return null; + return React.createElement('div', { 'data-testid': testId }, JSON.stringify(props.drafts ?? props.change ?? null)); + }, + }; +} +jest.mock('../TitleShowEditorDialog', () => ({ + __esModule: true, + default: function MockEditor(props: { open: boolean; drafts: unknown; onRestore?: (showId: number) => Promise<void> }) { + const React = require('react'); + if (!props.open) return null; + return React.createElement('div', null, + React.createElement('div', { 'data-testid': 'editor' }, JSON.stringify(props.drafts)), + React.createElement('button', { type: 'button', onClick: () => { void props.onRestore?.(5); } }, 'Restore the removed show')); + }, +})); +jest.mock('../../../../shared/Reorganize/ReorganizeDialog', () => ({ + __esModule: true, + default: function MockReview(props: { open: boolean; change: unknown; onApplied?: (result: unknown) => void }) { + const React = require('react'); + if (!props.open) return null; + return React.createElement('div', null, + React.createElement('div', { 'data-testid': 'review' }, JSON.stringify(props.change)), + React.createElement('button', { type: 'button', onClick: () => props.onApplied?.({ operationId: 55, applied: false }) }, 'Move')); + }, +})); + +const mockOutcome: { operationId: number | null; onFinished: (() => void) | null } = { operationId: null, onFinished: null }; +jest.mock('../../../../shared/Reorganize/hooks/useReorganizeOutcome', () => ({ + useReorganizeOutcome: (_token: string | null, operationId: number | null, onFinished: () => void) => { + mockOutcome.operationId = operationId; + mockOutcome.onFinished = onFinished; + }, +})); +jest.mock('../../../../shared/EpisodeAssign/EpisodeAssignDialog', () => mockDialog('assign')); + +import TitleShowsSection from '../TitleShowsSection'; + +function show(id: number, extra: Partial<TitleShow> = {}): TitleShow { + return { + id, name: `Show ${id}`, folderName: `Show ${id}`, libraryFolder: 'TV', position: id, retired: false, excludeTerms: [], + seasonNames: {}, counts: null, + patterns: [{ text: 'Ep {episode}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title', compiledRegex: 'x' }], + ...extra, + }; +} + +const STATE: ChannelTitleShows = { + shows: [show(3), show(4), show(5, { retired: true })], + conflicts: [], + showOnlyDownloads: false, + tvFolders: ['TV'], + defaultLibraryFolder: 'TV', +}; + +describe('TitleShowsSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHook.data = STATE; + mockHook.error = null; + Object.values(mockHook).forEach((value) => { + if (jest.isMockFunction(value)) value.mockResolvedValue(undefined); + }); + }); + + const renderSection = () => render(<TitleShowsSection token="token" channelId="UC1" />); + + test('lists the active shows and the removed ones', () => { + renderSection(); + expect(screen.getByRole('button', { name: 'Edit Show 3' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Restore Show 5' })).toBeInTheDocument(); + }); + + test('opens the editor for a new show with the channel\'s shows as drafts', () => { + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Add show' })); + const drafts = JSON.parse(screen.getByTestId('editor').textContent || '[]'); + expect(drafts.map((draft: { id: number }) => draft.id)).toEqual([3, 4]); + expect(drafts[0]).not.toHaveProperty('counts'); + }); + + test('reorders the shows', async () => { + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Move Show 3 down' })); + await waitFor(() => expect(mockHook.reorderShows).toHaveBeenCalledWith([4, 3])); + }); + + test('opens the review when removing a show moves downloaded videos', async () => { + const change = { type: 'titleShows' as const, channelId: 'UC1', shows: [] }; + mockHook.retireShow.mockRejectedValueOnce(new ReorganizeRequiredError('Review the move first.', change)); + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Show 3' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + expect(await screen.findByTestId('review')).toHaveTextContent('titleShows'); + }); + + test('reloads the shows when the move a change started ends, not when it starts', async () => { + const change = { type: 'titleShows' as const, channelId: 'UC1', shows: [] }; + mockHook.retireShow.mockRejectedValueOnce(new ReorganizeRequiredError('Review the move first.', change)); + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Show 3' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + fireEvent.click(await screen.findByRole('button', { name: 'Move' })); + expect([mockOutcome.operationId, mockHook.refetch.mock.calls.length]).toEqual([55, 0]); + act(() => { mockOutcome.onFinished?.(); }); + expect(mockHook.refetch).toHaveBeenCalled(); + }); + + test('tells the settings dialog when the move a change started ends', async () => { + const onMoveEnded = jest.fn(); + const change = { type: 'titleShows' as const, channelId: 'UC1', shows: [] }; + mockHook.retireShow.mockRejectedValueOnce(new ReorganizeRequiredError('Review the move first.', change)); + render(<TitleShowsSection token="token" channelId="UC1" onMoveEnded={onMoveEnded} />); + fireEvent.click(screen.getByRole('button', { name: 'Remove Show 3' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + fireEvent.click(await screen.findByRole('button', { name: 'Move' })); + expect(onMoveEnded).not.toHaveBeenCalled(); + act(() => { mockOutcome.onFinished?.(); }); + expect(onMoveEnded).toHaveBeenCalledTimes(1); + }); + + test('reloads the shows when the channel\'s TV layout or folder changes', () => { + const { rerender } = render(<TitleShowsSection token="token" channelId="UC1" tvKey="videos|Kids" />); + expect(mockHook.refetch).not.toHaveBeenCalled(); + rerender(<TitleShowsSection token="token" channelId="UC1" tvKey="tv|TV Shows" />); + expect(mockHook.refetch).toHaveBeenCalledTimes(1); + }); + + test('shows a refused action', async () => { + mockHook.restoreShow.mockRejectedValueOnce(new Error('A reorganize is moving this channel\'s files.')); + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Restore Show 5' })); + expect(await screen.findByText('A reorganize is moving this channel\'s files.')).toBeInTheDocument(); + }); + + test('opens the review when the editor restores a removed show whose videos move', async () => { + const change = { type: 'titleShows' as const, channelId: 'UC1', shows: [] }; + mockHook.restoreShow.mockRejectedValueOnce(new ReorganizeRequiredError('Review the move first.', change)); + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Add show' })); + fireEvent.click(screen.getByRole('button', { name: 'Restore the removed show' })); + expect(await screen.findByTestId('review')).toHaveTextContent('titleShows'); + }); + + test('shows a refused restore from the editor', async () => { + mockHook.restoreShow.mockRejectedValueOnce(new Error('A reorganize is moving this channel\'s files.')); + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Add show' })); + fireEvent.click(screen.getByRole('button', { name: 'Restore the removed show' })); + expect(await screen.findByText('A reorganize is moving this channel\'s files.')).toBeInTheDocument(); + }); + + test('limits downloads to the shows', async () => { + renderSection(); + fireEvent.click(screen.getByRole('checkbox', { name: /Only download videos that belong to a show/ })); + await waitFor(() => expect(mockHook.setShowOnly).toHaveBeenCalledWith(true)); + }); + + test('keeps the show-only switch off without shows', () => { + mockHook.data = { ...STATE, shows: [] }; + renderSection(); + expect(screen.getByRole('checkbox', { name: /Only download videos that belong to a show/ })).toBeDisabled(); + }); + + test('assigns a video that is not a duplicate by hand', () => { + mockHook.data = { + ...STATE, + conflicts: [{ + youtubeId: 'JN49nXLOqQI', kind: 'duplicate', showId: 3, duplicateOf: 'y7xVT7DTt2k', season: 1, episode: 20, message: null, + suppressed: true, title: 'Re-upload', downloaded: false, videoId: null, duplicateOfTitle: 'Original', + }], + }; + renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Not a duplicate' })); + expect(screen.getByTestId('assign')).toBeInTheDocument(); + }); + + test('reports a failed load', () => { + mockHook.data = null; + mockHook.error = 'Failed to load the channel\'s shows'; + renderSection(); + expect(screen.getByText('Failed to load the channel\'s shows')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/__tests__/useTitleShowForm.test.ts b/client/src/components/ChannelPage/components/TitleShows/__tests__/useTitleShowForm.test.ts new file mode 100644 index 000000000..2d60cc3c0 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/__tests__/useTitleShowForm.test.ts @@ -0,0 +1,43 @@ +import { renderHook, act } from '@testing-library/react'; +import { useTitleShowForm } from '../useTitleShowForm'; +import { TitleShow } from '../../../../../types/titleShows'; + +const SHOW: TitleShow = { + id: 3, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV', position: 0, retired: false, excludeTerms: [], + seasonNames: {}, counts: null, + patterns: [{ text: 'Ep {episode}', kind: 'simple', seasonSource: 'title', seasonFixed: 4, episodeSource: 'title', compiledRegex: 'x' }], +}; + +describe('useTitleShowForm', () => { + test('starts a new show in the default TV folder with one empty pattern', () => { + const { result } = renderHook(() => useTitleShowForm(true, null, 'TV')); + expect(result.current.form.libraryFolder).toBe('TV'); + expect(result.current.form.patterns).toHaveLength(1); + expect(result.current.valid).toBe(false); + }); + + test('builds the draft of an existing show without the compiled regex', () => { + const { result } = renderHook(() => useTitleShowForm(true, SHOW, 'TV')); + expect(result.current.draft.patterns).toEqual([ + { text: 'Ep {episode}', kind: 'simple', seasonSource: 'title', seasonFixed: null, episodeSource: 'title' }, + ]); + }); + + test('sends exclude terms one per line and drops empty lines', () => { + const { result } = renderHook(() => useTitleShowForm(true, SHOW, 'TV')); + act(() => result.current.update({ excludeText: 'Official Clip\n\n Dub ' })); + expect(result.current.draft.excludeTerms).toEqual(['Official Clip', 'Dub']); + }); + + test('cannot save while a season is named twice', () => { + const { result } = renderHook(() => useTitleShowForm(true, SHOW, 'TV')); + act(() => result.current.update({ seasonRows: [{ season: '1', name: 'A' }, { season: '1', name: 'B' }] })); + expect(result.current.valid).toBe(false); + }); + + test('leaves the folder name to the server when it is empty', () => { + const { result } = renderHook(() => useTitleShowForm(true, SHOW, 'TV')); + act(() => result.current.update({ folderName: ' ' })); + expect('folderName' in result.current.draft).toBe(false); + }); +}); diff --git a/client/src/components/ChannelPage/components/TitleShows/useTitleShowForm.ts b/client/src/components/ChannelPage/components/TitleShows/useTitleShowForm.ts new file mode 100644 index 000000000..95ab081a1 --- /dev/null +++ b/client/src/components/ChannelPage/components/TitleShows/useTitleShowForm.ts @@ -0,0 +1,72 @@ +import { useEffect, useMemo, useState } from 'react'; +import { TitlePatternDraft, TitleShow, TitleShowDraft } from '../../../../types/titleShows'; +import { rowsToSeasonNames, SeasonNameRow, seasonNamesToRows, seasonRowProblem } from './SeasonNamesEditor'; + +export const EMPTY_PATTERN: TitlePatternDraft = { + text: '', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title', +}; + +function toPatternDraft(pattern: TitlePatternDraft): TitlePatternDraft { + const { text, kind, seasonSource, seasonFixed, episodeSource } = pattern; + return { text, kind, seasonSource, seasonFixed: seasonSource === 'fixed' ? seasonFixed ?? null : null, episodeSource }; +} + +/** + * A pattern being edited: the simple text it had before switching to regex + * mode, and the regex a saved pattern had before switching to the simple + * syntax, each for the way back. + */ +export type EditablePattern = TitlePatternDraft & { simpleText?: string; regexText?: string }; + +export interface TitleShowForm { + name: string; + folderName: string; + libraryFolder: string; + patterns: EditablePattern[]; + excludeText: string; + seasonRows: SeasonNameRow[]; +} + +/** The editor's form for one title show, and the draft it saves. */ +export function useTitleShowForm(open: boolean, show: TitleShow | null, defaultFolder: string) { + const [form, setForm] = useState<TitleShowForm>(() => initialForm(show, defaultFolder)); + + useEffect(() => { + if (open) setForm(initialForm(show, defaultFolder)); + }, [open, show, defaultFolder]); + + const draft = useMemo<TitleShowDraft>(() => { + const value: TitleShowDraft = { + name: form.name.trim(), + libraryFolder: form.libraryFolder, + excludeTerms: form.excludeText.split('\n').map((term) => term.trim()).filter(Boolean), + seasonNames: rowsToSeasonNames(form.seasonRows), + patterns: form.patterns.map(toPatternDraft), + }; + if (show) value.id = show.id; + if (form.folderName.trim()) value.folderName = form.folderName.trim(); + return value; + }, [form, show]); + + const valid = Boolean(form.name.trim()) && form.patterns.length > 0 && form.patterns.every((pattern) => pattern.text.trim()) + && form.seasonRows.every((row) => !seasonRowProblem(row, form.seasonRows)); + const update = (patch: Partial<TitleShowForm>) => setForm((current) => ({ ...current, ...patch })); + + return { form, update, draft, valid }; +} + +function initialForm(show: TitleShow | null, defaultFolder: string): TitleShowForm { + if (!show) { + return { name: '', folderName: '', libraryFolder: defaultFolder, patterns: [{ ...EMPTY_PATTERN }], excludeText: '', seasonRows: [] }; + } + return { + name: show.name, + folderName: show.folderName, + libraryFolder: show.libraryFolder, + patterns: show.patterns.map(toPatternDraft), + excludeText: show.excludeTerms.join('\n'), + seasonRows: seasonNamesToRows(show.seasonNames), + }; +} + +export default useTitleShowForm; diff --git a/client/src/components/ChannelPage/hooks/__tests__/useChannelVideos.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useChannelVideos.test.ts index c1c339107..7e1b20eac 100644 --- a/client/src/components/ChannelPage/hooks/__tests__/useChannelVideos.test.ts +++ b/client/src/components/ChannelPage/hooks/__tests__/useChannelVideos.test.ts @@ -237,6 +237,17 @@ describe('useChannelVideos', () => { expect(result.current.videos.map((v) => v.youtube_id)).toEqual(['fresh1']); }); + test('asks for one title show\'s episodes', async () => { + mockFetch.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(mockResponse) }); + + renderHook(() => useChannelVideos({ ...defaultParams, showId: 7 })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + expect(mockFetch.mock.calls[0][0]).toContain('showId=7'); + }); + test('sends watchedFilter when mode is active and omits it when off', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/client/src/components/ChannelPage/hooks/__tests__/useMissingEpisodes.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useMissingEpisodes.test.ts new file mode 100644 index 000000000..6e1786aa8 --- /dev/null +++ b/client/src/components/ChannelPage/hooks/__tests__/useMissingEpisodes.test.ts @@ -0,0 +1,36 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +jest.mock('axios', () => ({ + get: jest.fn(), + isAxiosError: (e: unknown) => Boolean(e && (e as { isAxiosError?: boolean }).isAxiosError), +})); + +const axios = require('axios'); + +import { useMissingEpisodes } from '../useMissingEpisodes'; + +const MISSING = { showId: 3, name: 'Beyblade', seasons: [] }; + +describe('useMissingEpisodes', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: MISSING }); + }); + + test('loads a show\'s missing episodes', async () => { + const { result } = renderHook(() => useMissingEpisodes('UC1', 3, 'token')); + await waitFor(() => expect(result.current.data).toEqual(MISSING)); + expect(axios.get).toHaveBeenCalledWith('/api/channels/UC1/tv/shows/3/missing', { headers: { 'x-access-token': 'token' } }); + }); + + test('loads nothing without a show', () => { + renderHook(() => useMissingEpisodes('UC1', null, 'token')); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('reports a failed load', async () => { + axios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404, data: { error: 'Show not found' } } }); + const { result } = renderHook(() => useMissingEpisodes('UC1', 3, 'token')); + await waitFor(() => expect(result.current.error).toBe('Show not found')); + }); +}); diff --git a/client/src/components/ChannelPage/hooks/__tests__/useTitleShowPreview.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useTitleShowPreview.test.ts new file mode 100644 index 000000000..74e6da361 --- /dev/null +++ b/client/src/components/ChannelPage/hooks/__tests__/useTitleShowPreview.test.ts @@ -0,0 +1,100 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; + +jest.mock('axios', () => ({ + post: jest.fn(), + isAxiosError: (e: unknown) => Boolean(e && (e as { isAxiosError?: boolean }).isAxiosError), +})); + +const axios = require('axios'); + +import { useTitleShowPreview, PREVIEW_DEBOUNCE_MS } from '../useTitleShowPreview'; +import { TitleShowDraft } from '../../../../types/titleShows'; + +const SHOWS: TitleShowDraft[] = [{ name: 'Beyblade', patterns: [{ text: 'Ep {episode}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }] }]; +const PREVIEW = { knownVideos: 3, shows: [] }; + +describe('useTitleShowPreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + axios.post.mockResolvedValue({ data: PREVIEW }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('previews the drafts once they stop changing', async () => { + const { result } = renderHook(() => useTitleShowPreview('UC1', 'token', SHOWS)); + expect(axios.post).not.toHaveBeenCalled(); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS); }); + await waitFor(() => expect(result.current.preview).toEqual(PREVIEW)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(axios.post).toHaveBeenCalledWith( + '/api/channels/UC1/tv/preview', { shows: SHOWS, overrides: [] }, { headers: { 'x-access-token': 'token' } } + ); + }); + + test('waits for the drafts to settle before asking again', async () => { + const { result, rerender } = renderHook(({ shows }: { shows: TitleShowDraft[] }) => useTitleShowPreview('UC1', 'token', shows), { + initialProps: { shows: SHOWS }, + }); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS / 2); }); + rerender({ shows: [{ ...SHOWS[0], name: 'Beyblade!' }] }); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS / 2); }); + expect(axios.post).not.toHaveBeenCalled(); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS); }); + expect(axios.post).toHaveBeenCalledTimes(1); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + test('reports the server\'s refusal of a draft', async () => { + axios.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 400, data: { error: 'Beyblade, pattern 1: bad' } } }); + const { result } = renderHook(() => useTitleShowPreview('UC1', 'token', SHOWS)); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS); }); + await waitFor(() => expect(result.current.error).toBe('Beyblade, pattern 1: bad')); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + test('drops a preview that arrives after it was disabled', async () => { + let answer: (value: unknown) => void = () => undefined; + axios.post.mockReturnValueOnce(new Promise((resolve) => { answer = resolve; })); + const { result, rerender } = renderHook(({ enabled }: { enabled: boolean }) => useTitleShowPreview('UC1', 'token', SHOWS, { enabled }), { + initialProps: { enabled: true }, + }); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS); }); + rerender({ enabled: false }); + await act(async () => { answer({ data: PREVIEW }); }); + expect(result.current.preview).toBeNull(); + }); + + test('clears the preview when disabled', async () => { + const { result, rerender } = renderHook(({ enabled }: { enabled: boolean }) => useTitleShowPreview('UC1', 'token', SHOWS, { enabled }), { + initialProps: { enabled: true }, + }); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS); }); + await waitFor(() => expect(result.current.preview).toEqual(PREVIEW)); + await waitFor(() => expect(result.current.loading).toBe(false)); + rerender({ enabled: false }); + expect([result.current.preview, result.current.error, result.current.loading]).toEqual([null, null, false]); + }); + + test('says whether the preview is for the current drafts', async () => { + const { result, rerender } = renderHook(({ shows }: { shows: TitleShowDraft[] }) => useTitleShowPreview('UC1', 'token', shows), { + initialProps: { shows: SHOWS }, + }); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS); }); + await waitFor(() => expect(result.current.current).toBe(true)); + rerender({ shows: [{ ...SHOWS[0], name: 'Beyblade!' }] }); + expect(result.current.current).toBe(false); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS); }); + await waitFor(() => expect(result.current.current).toBe(true)); + }); + + test('previews nothing while disabled', () => { + renderHook(() => useTitleShowPreview('UC1', 'token', SHOWS, { enabled: false })); + act(() => { jest.advanceTimersByTime(PREVIEW_DEBOUNCE_MS * 2); }); + expect(axios.post).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/ChannelPage/hooks/__tests__/useTitleShows.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useTitleShows.test.ts new file mode 100644 index 000000000..15ce9288f --- /dev/null +++ b/client/src/components/ChannelPage/hooks/__tests__/useTitleShows.test.ts @@ -0,0 +1,144 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + isAxiosError: (e: unknown) => Boolean(e && (e as { isAxiosError?: boolean }).isAxiosError), +})); + +const axios = require('axios'); + +import { useTitleShows, ShowFolderTakenError } from '../useTitleShows'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../../hooks/useLibraryFolders'; +import { ReorganizeRequiredError } from '../../../shared/Reorganize/reorganizeErrors'; + +const HEADERS = { headers: { 'x-access-token': 'token' } }; +const STATE = { shows: [], conflicts: [], showOnlyDownloads: false, tvFolders: ['TV'], defaultLibraryFolder: 'TV' }; +const AFTER = { ...STATE, shows: [{ id: 3, name: 'Beyblade' }] }; +const DRAFT = { name: 'Beyblade', patterns: [] }; + +function refusal(status: number, data: Record<string, unknown>) { + return { isAxiosError: true, response: { status, data } }; +} + +describe('useTitleShows', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: STATE }); + }); + + async function loaded() { + const { result } = renderHook(() => useTitleShows('UC1', 'token')); + await waitFor(() => expect(result.current.data).toEqual(STATE)); + return { result }; + } + + test('loads the channel\'s title shows', async () => { + await loaded(); + expect(axios.get).toHaveBeenCalledWith('/api/channels/UC1/tv/shows', HEADERS); + }); + + test('loads again when library folders change (a new TV folder)', async () => { + await loaded(); + act(() => { window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + }); + + test('does not load while disabled', () => { + renderHook(() => useTitleShows('UC1', 'token', false)); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('reports a failed load', async () => { + axios.get.mockRejectedValueOnce(refusal(500, { error: 'Failed to load the channel\'s shows' })); + const { result } = renderHook(() => useTitleShows('UC1', 'token')); + await waitFor(() => expect(result.current.error).toBe('Failed to load the channel\'s shows')); + }); + + test('adds a show and keeps the returned state', async () => { + axios.post.mockResolvedValueOnce({ data: AFTER }); + const { result } = await loaded(); + await act(async () => { await result.current.createShow(DRAFT); }); + expect(axios.post).toHaveBeenCalledWith('/api/channels/UC1/tv/shows', DRAFT, HEADERS); + expect(result.current.data).toEqual(AFTER); + }); + + test('edits a show', async () => { + axios.put.mockResolvedValueOnce({ data: AFTER }); + const { result } = await loaded(); + await act(async () => { await result.current.updateShow(3, DRAFT); }); + expect(axios.put).toHaveBeenCalledWith('/api/channels/UC1/tv/shows/3', DRAFT, HEADERS); + }); + + test('removes a show', async () => { + axios.delete.mockResolvedValueOnce({ data: STATE }); + const { result } = await loaded(); + await act(async () => { await result.current.retireShow(3); }); + expect(axios.delete).toHaveBeenCalledWith('/api/channels/UC1/tv/shows/3', HEADERS); + }); + + test('restores a show', async () => { + axios.post.mockResolvedValueOnce({ data: AFTER }); + const { result } = await loaded(); + await act(async () => { await result.current.restoreShow(3); }); + expect(axios.post).toHaveBeenCalledWith('/api/channels/UC1/tv/shows/3/restore', {}, HEADERS); + }); + + test('reorders the shows', async () => { + axios.put.mockResolvedValueOnce({ data: AFTER }); + const { result } = await loaded(); + await act(async () => { await result.current.reorderShows([4, 3]); }); + expect(axios.put).toHaveBeenCalledWith('/api/channels/UC1/tv/shows/order', { showIds: [4, 3] }, HEADERS); + }); + + test('sets the show-only switch', async () => { + axios.put.mockResolvedValueOnce({ data: { showOnlyDownloads: true } }); + const { result } = await loaded(); + await act(async () => { await result.current.setShowOnly(true); }); + expect(axios.put).toHaveBeenCalledWith('/api/channels/UC1/tv/show-only', { enabled: true }, HEADERS); + expect(result.current.data?.showOnlyDownloads).toBe(true); + }); + + test('uses a duplicate\'s copy', async () => { + axios.post.mockResolvedValueOnce({ data: AFTER }); + const { result } = await loaded(); + await act(async () => { await result.current.takeDuplicateCopy('abcdefghijk'); }); + expect(axios.post).toHaveBeenCalledWith('/api/channels/UC1/tv/conflicts/abcdefghijk/use-copy', {}, HEADERS); + }); + + test('checks the titles again', async () => { + axios.post.mockResolvedValueOnce({ data: AFTER }); + const { result } = await loaded(); + await act(async () => { await result.current.recheck(); }); + expect(axios.post).toHaveBeenCalledWith('/api/channels/UC1/tv/recheck', {}, HEADERS); + }); + + test('throws a reorganize request when downloaded videos would move', async () => { + const change = { type: 'titleShows', channelId: 'UC1', shows: [DRAFT] }; + axios.post.mockRejectedValueOnce(refusal(409, { error: 'Review the move first.', reorganizeRequired: true, change })); + const { result } = await loaded(); + let thrown: unknown; + await act(async () => { try { await result.current.createShow(DRAFT); } catch (err) { thrown = err; } }); + expect(thrown).toBeInstanceOf(ReorganizeRequiredError); + expect((thrown as ReorganizeRequiredError).change).toEqual(change); + }); + + test('throws a taken folder with its suggestion and the show to restore', async () => { + axios.post.mockRejectedValueOnce(refusal(409, { error: 'taken', suggestion: 'Beyblade (Chan)', retiredShowId: 5 })); + const { result } = await loaded(); + let thrown: unknown; + await act(async () => { try { await result.current.createShow(DRAFT); } catch (err) { thrown = err; } }); + expect(thrown).toBeInstanceOf(ShowFolderTakenError); + expect([(thrown as ShowFolderTakenError).suggestion, (thrown as ShowFolderTakenError).retiredShowId]).toEqual(['Beyblade (Chan)', 5]); + }); + + test('throws the server\'s message for any other refusal', async () => { + axios.post.mockRejectedValueOnce(refusal(400, { error: 'Beyblade, pattern 1: Unknown placeholder {ep}' })); + const { result } = await loaded(); + await act(async () => { + await expect(result.current.createShow(DRAFT)).rejects.toThrow('Unknown placeholder {ep}'); + }); + }); +}); diff --git a/client/src/components/ChannelPage/hooks/useChannelVideos.ts b/client/src/components/ChannelPage/hooks/useChannelVideos.ts index 6f62f24da..f0a5412fb 100644 --- a/client/src/components/ChannelPage/hooks/useChannelVideos.ts +++ b/client/src/components/ChannelPage/hooks/useChannelVideos.ts @@ -23,6 +23,8 @@ interface UseChannelVideosParams { missingFilter?: ChipFilterMode; ignoredFilter?: ChipFilterMode; watchedFilter?: ChipFilterMode; + /** Only the episodes of this title show */ + showId?: number | null; onFirstLoad?: (channelId: string) => void; } @@ -58,6 +60,7 @@ export function useChannelVideos({ missingFilter, ignoredFilter, watchedFilter, + showId = null, onFirstLoad, }: UseChannelVideosParams): UseChannelVideosResult { const [videos, setVideos] = useState<ChannelVideo[]>([]); @@ -134,6 +137,9 @@ export function useChannelVideos({ if (watchedFilter && watchedFilter !== 'off') { queryParams.append('watchedFilter', watchedFilter); } + if (showId !== null) { + queryParams.append('showId', String(showId)); + } const response = await fetch(`/getchannelvideos/${channelId}?${queryParams}`, { headers: { @@ -201,7 +207,7 @@ export function useChannelVideos({ setLoading(false); } } - }, [channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, maxRating, token, append, resetKey, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter]); + }, [channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, maxRating, token, append, resetKey, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, showId]); useEffect(() => { fetchVideos(); diff --git a/client/src/components/ChannelPage/hooks/useMissingEpisodes.ts b/client/src/components/ChannelPage/hooks/useMissingEpisodes.ts new file mode 100644 index 000000000..1cb2c3878 --- /dev/null +++ b/client/src/components/ChannelPage/hooks/useMissingEpisodes.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import axios from 'axios'; +import { MissingEpisodes } from '../../../types/titleShows'; +import { serverMessageOf } from '../../shared/Reorganize/reorganizeErrors'; + +export interface UseMissingEpisodesResult { + data: MissingEpisodes | null; + loading: boolean; + error: string | null; +} + +/** A title show's episodes that aren't downloaded, and the numbers missing from its seasons. */ +export function useMissingEpisodes(channelId: string | undefined, showId: number | null, token: string | null): UseMissingEpisodesResult { + const [data, setData] = useState<MissingEpisodes | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + setData(null); + setError(null); + if (!channelId || showId === null || !token) return undefined; + let cancelled = false; + setLoading(true); + axios.get<MissingEpisodes>( + `/api/channels/${encodeURIComponent(channelId)}/tv/shows/${showId}/missing`, + { headers: { 'x-access-token': token } } + ) + .then((response) => { if (!cancelled) setData(response.data); }) + .catch((err) => { if (!cancelled) setError(serverMessageOf(err, 'Failed to load the missing episodes')); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [channelId, showId, token]); + + return { data, loading, error }; +} + +export default useMissingEpisodes; diff --git a/client/src/components/ChannelPage/hooks/useTitleShowPreview.ts b/client/src/components/ChannelPage/hooks/useTitleShowPreview.ts new file mode 100644 index 000000000..97654ce22 --- /dev/null +++ b/client/src/components/ChannelPage/hooks/useTitleShowPreview.ts @@ -0,0 +1,74 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import axios from 'axios'; +import { TitleShowDraft, TitleShowPreview } from '../../../types/titleShows'; +import { EpisodeOverride } from '../../../types/reorganize'; +import { serverMessageOf } from '../../shared/Reorganize/reorganizeErrors'; + +/** The editor previews once the drafts stop changing for this long. */ +export const PREVIEW_DEBOUNCE_MS = 600; + +export interface UseTitleShowPreviewResult { + preview: TitleShowPreview | null; + loading: boolean; + /** The server's refusal of the drafts (a bad pattern, a taken folder) */ + error: string | null; + /** The preview shown is for the drafts as they are now */ + current: boolean; +} + +/** + * The live preview of draft title shows (POST /api/channels/:channelId/tv/preview): + * the channel's whole set of shows after the edit, classified against every + * known video of the channel. + */ +export function useTitleShowPreview( + channelId: string | undefined, + token: string | null, + shows: TitleShowDraft[], + { enabled = true, overrides = [] }: { enabled?: boolean; overrides?: EpisodeOverride[] } = {} +): UseTitleShowPreviewResult { + const [preview, setPreview] = useState<TitleShowPreview | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [previewedBody, setPreviewedBody] = useState<string | null>(null); + const requestSeq = useRef(0); + // The drafts as a value, so a new array with the same content asks nothing. + const body = useMemo(() => JSON.stringify({ shows, overrides }), [shows, overrides]); + + useEffect(() => { + if (!enabled || !token || !channelId) { + // A request still in flight must not land, and the next show edited + // must not start from this one's preview. + requestSeq.current += 1; + setPreview(null); + setError(null); + setLoading(false); + setPreviewedBody(null); + return undefined; + } + const seq = ++requestSeq.current; + const timer = setTimeout(async () => { + setLoading(true); + try { + const response = await axios.post<TitleShowPreview>( + `/api/channels/${encodeURIComponent(channelId)}/tv/preview`, + JSON.parse(body), + { headers: { 'x-access-token': token } } + ); + if (seq !== requestSeq.current) return; + setPreview(response.data); + setPreviewedBody(body); + setError(null); + } catch (err) { + if (seq === requestSeq.current) setError(serverMessageOf(err, 'Failed to preview the shows')); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, PREVIEW_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [body, channelId, token, enabled]); + + return { preview, loading, error, current: previewedBody === body && !loading }; +} + +export default useTitleShowPreview; diff --git a/client/src/components/ChannelPage/hooks/useTitleShows.ts b/client/src/components/ChannelPage/hooks/useTitleShows.ts new file mode 100644 index 000000000..20ab7e1bf --- /dev/null +++ b/client/src/components/ChannelPage/hooks/useTitleShows.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { ChannelTitleShows, TitleShowDraft } from '../../../types/titleShows'; +import { serverMessageOf, toRequestError } from '../../shared/Reorganize/reorganizeErrors'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; + +/** A show's folder is taken; the server suggests another name, or a removed show to restore. */ +export class ShowFolderTakenError extends Error { + readonly suggestion: string | null; + readonly retiredShowId: number | null; + + constructor(message: string, suggestion: string | null, retiredShowId: number | null) { + super(message); + this.name = 'ShowFolderTakenError'; + this.suggestion = suggestion; + this.retiredShowId = retiredShowId; + } +} + +interface FolderRefusal { + suggestion?: unknown; + retiredShowId?: unknown; +} + +function toSaveError(err: unknown, fallback: string): Error { + if (axios.isAxiosError(err)) { + const data = err.response?.data as FolderRefusal | undefined; + if (data && typeof data.suggestion === 'string') { + return new ShowFolderTakenError( + serverMessageOf(err, fallback), + data.suggestion, + typeof data.retiredShowId === 'number' ? data.retiredShowId : null + ); + } + } + return toRequestError(err, fallback); +} + +export interface UseTitleShowsResult { + data: ChannelTitleShows | null; + loading: boolean; + error: string | null; + refetch: () => Promise<void>; + /** + * The changes below throw a ReorganizeRequiredError when downloaded videos + * would move, a ShowFolderTakenError for a taken folder, else an Error + * with the server's message. + */ + createShow: (draft: TitleShowDraft) => Promise<void>; + updateShow: (showId: number, draft: TitleShowDraft) => Promise<void>; + retireShow: (showId: number) => Promise<void>; + restoreShow: (showId: number) => Promise<void>; + reorderShows: (showIds: number[]) => Promise<void>; + setShowOnly: (enabled: boolean) => Promise<void>; + /** "Use this copy instead" for a duplicate */ + takeDuplicateCopy: (youtubeId: string) => Promise<void>; + /** Classify the channel's titles again (after a classification error) */ + recheck: () => Promise<void>; +} + +/** A channel's title shows (GET/POST/PUT/DELETE /api/channels/:channelId/tv/shows...). */ +export function useTitleShows(channelId: string | undefined, token: string | null, enabled = true): UseTitleShowsResult { + const [data, setData] = useState<ChannelTitleShows | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + // Only the newest request may set the state. + const requestSeq = useRef(0); + + const base = channelId ? `/api/channels/${encodeURIComponent(channelId)}/tv` : ''; + const headers = useCallback(() => ({ headers: { 'x-access-token': token || '' } }), [token]); + + const refetch = useCallback(async () => { + if (!token || !channelId || !enabled) return; + const seq = ++requestSeq.current; + setLoading(true); + setError(null); + try { + const response = await axios.get<ChannelTitleShows>(`${base}/shows`, headers()); + if (seq === requestSeq.current) setData(response.data); + } catch (err) { + if (seq === requestSeq.current) setError(serverMessageOf(err, 'Failed to load the channel\'s shows')); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [base, channelId, token, enabled, headers]); + + useEffect(() => { + void refetch(); + }, [refetch]); + + // A new TV folder changes where shows can go. + useEffect(() => { + const handler = () => { void refetch(); }; + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + return () => window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + }, [refetch]); + + // Every change answers with the channel's shows after it. + const change = useCallback(async (request: () => Promise<{ data: ChannelTitleShows }>, fallback: string) => { + const seq = ++requestSeq.current; + try { + const response = await request(); + if (seq === requestSeq.current) setData(response.data); + } catch (err) { + throw toSaveError(err, fallback); + } + }, []); + + const createShow = useCallback((draft: TitleShowDraft) => change( + () => axios.post<ChannelTitleShows>(`${base}/shows`, draft, headers()), 'Failed to add the show' + ), [base, change, headers]); + + const updateShow = useCallback((showId: number, draft: TitleShowDraft) => change( + () => axios.put<ChannelTitleShows>(`${base}/shows/${showId}`, draft, headers()), 'Failed to save the show' + ), [base, change, headers]); + + const retireShow = useCallback((showId: number) => change( + () => axios.delete<ChannelTitleShows>(`${base}/shows/${showId}`, headers()), 'Failed to remove the show' + ), [base, change, headers]); + + const restoreShow = useCallback((showId: number) => change( + () => axios.post<ChannelTitleShows>(`${base}/shows/${showId}/restore`, {}, headers()), 'Failed to restore the show' + ), [base, change, headers]); + + const reorderShows = useCallback((showIds: number[]) => change( + () => axios.put<ChannelTitleShows>(`${base}/shows/order`, { showIds }, headers()), 'Failed to reorder the shows' + ), [base, change, headers]); + + const takeDuplicateCopy = useCallback((youtubeId: string) => change( + () => axios.post<ChannelTitleShows>(`${base}/conflicts/${encodeURIComponent(youtubeId)}/use-copy`, {}, headers()), + 'Failed to use the copy' + ), [base, change, headers]); + + const recheck = useCallback(() => change( + () => axios.post<ChannelTitleShows>(`${base}/recheck`, {}, headers()), 'Failed to check the titles' + ), [base, change, headers]); + + const setShowOnly = useCallback(async (value: boolean) => { + try { + const response = await axios.put<{ showOnlyDownloads: boolean }>(`${base}/show-only`, { enabled: value }, headers()); + setData((current) => (current ? { ...current, showOnlyDownloads: response.data.showOnlyDownloads } : current)); + } catch (err) { + throw toRequestError(err, 'Failed to save the switch'); + } + }, [base, headers]); + + return { + data, loading, error, refetch, createShow, updateShow, retireShow, restoreShow, reorderShows, setShowOnly, takeDuplicateCopy, recheck, + }; +} + +export default useTitleShows; diff --git a/client/src/components/Subscriptions/components/ChannelCard.tsx b/client/src/components/Subscriptions/components/ChannelCard.tsx index 2fb84076b..a6bdb4bc9 100644 --- a/client/src/components/Subscriptions/components/ChannelCard.tsx +++ b/client/src/components/Subscriptions/components/ChannelCard.tsx @@ -3,7 +3,7 @@ import { Link as RouterLink } from 'react-router-dom'; import { Avatar, Card, CardActionArea, CardContent, Chip, Tooltip, Typography } from '../../ui'; import { Delete as DeleteIcon, Edit as EditIcon, Image as ImageIcon, Folder as FolderIcon } from '../../../lib/icons'; import { Channel } from '../../../types/Channel'; -import { QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip, TvChip } from './chips'; +import { QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip, TvChip, ShowsChip } from './chips'; const THUMBNAIL_ASPECT_PADDING = '56.25%'; @@ -140,6 +140,7 @@ const ChannelCard: React.FC<ChannelCardProps> = ({ {channel.sub_folder ? `/${channel.sub_folder}` : 'Default Folder'} </Typography> {channel.layout === 'tv' && <TvChip />} + <ShowsChip count={channel.titleShows ?? 0} /> </div> </div> {isPendingAddition && <Chip label="Pending" size="small" color="warning" />} diff --git a/client/src/components/Subscriptions/components/ChannelListRow.tsx b/client/src/components/Subscriptions/components/ChannelListRow.tsx index 9995da10d..dc889aaf2 100644 --- a/client/src/components/Subscriptions/components/ChannelListRow.tsx +++ b/client/src/components/Subscriptions/components/ChannelListRow.tsx @@ -10,7 +10,7 @@ import { } from '../../ui'; import { Delete as DeleteIcon, Edit as EditIcon } from '../../../lib/icons'; import { Channel } from '../../../types/Channel'; -import { SubFolderChip, QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip, TvChip } from './chips'; +import { SubFolderChip, QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator, TerminatedChip, ProtectedChip, TvChip, ShowsChip } from './chips'; import RatingBadge from '../../shared/RatingBadge'; interface ChannelListRowProps { @@ -86,6 +86,7 @@ const ChannelListRow: React.FC<ChannelListRowProps> = ({ <QualityChip videoQuality={channel.video_quality} globalPreferredResolution={globalPreferredResolution} /> <SubFolderChip subFolder={channel.sub_folder} /> {isTvChannel && <TvChip />} + <ShowsChip count={channel.titleShows ?? 0} /> <RatingBadge rating={channel.default_rating} /> <ProtectedChip autoRemovalProtected={channel.auto_removal_protected} @@ -217,6 +218,7 @@ const ChannelListRow: React.FC<ChannelListRowProps> = ({ <QualityChip videoQuality={channel.video_quality} globalPreferredResolution={globalPreferredResolution} /> <SubFolderChip subFolder={channel.sub_folder} /> {isTvChannel && <TvChip />} + <ShowsChip count={channel.titleShows ?? 0} /> <RatingBadge rating={channel.default_rating} /> <ProtectedChip autoRemovalProtected={channel.auto_removal_protected} diff --git a/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx b/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx index c71c0e1ae..f52c40539 100644 --- a/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx +++ b/client/src/components/Subscriptions/components/__tests__/ChannelCard.test.tsx @@ -87,6 +87,10 @@ jest.mock('../chips', () => ({ const React = require('react'); return React.createElement('div', { 'data-testid': 'tv-chip' }, 'TV'); }, + ShowsChip: function MockShowsChip({ count }: { count: number }) { + const React = require('react'); + return count ? React.createElement('div', { 'data-testid': 'shows-chip' }, `${count} shows`) : null; + }, })); describe('ChannelCard Component', () => { @@ -280,6 +284,11 @@ describe('ChannelCard Component', () => { expect(screen.getByTestId('tv-chip')).toBeInTheDocument(); }); + test('counts the channel\'s title shows', () => { + renderWithProviders(<ChannelCard {...defaultProps} channel={{ ...mockChannel, titleShows: 2 }} />); + expect(screen.getByTestId('shows-chip')).toHaveTextContent('2 shows'); + }); + test('does not render TvChip for a channel in a Videos folder', () => { renderWithProviders( <ChannelCard {...defaultProps} channel={{ ...mockChannel, layout: 'videos' }} /> diff --git a/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx b/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx index 2a7ed1df3..c21f184eb 100644 --- a/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx +++ b/client/src/components/Subscriptions/components/__tests__/ChannelListRow.test.tsx @@ -117,6 +117,10 @@ jest.mock('../chips', () => ({ const React = require('react'); return React.createElement('div', { 'data-testid': 'tv-chip' }, 'TV'); }, + ShowsChip: function MockShowsChip({ count }: { count: number }) { + const React = require('react'); + return count ? React.createElement('div', { 'data-testid': 'shows-chip' }, `${count} shows`) : null; + }, })); describe('ChannelListRow', () => { @@ -223,6 +227,11 @@ describe('ChannelListRow', () => { expect(screen.getByTestId('tv-chip')).toBeInTheDocument(); }); + test('counts the channel\'s title shows', () => { + renderWithProviders(<ChannelListRow {...defaultProps} channel={{ ...mockChannel, titleShows: 2 }} />); + expect(screen.getByTestId('shows-chip')).toHaveTextContent('2 shows'); + }); + test('does not render TvChip for a channel in a Videos folder', () => { renderWithProviders(<ChannelListRow {...defaultProps} channel={{ ...mockChannel, layout: 'videos' }} />); diff --git a/client/src/components/Subscriptions/components/chips/ShowsChip.tsx b/client/src/components/Subscriptions/components/chips/ShowsChip.tsx new file mode 100644 index 000000000..de308c069 --- /dev/null +++ b/client/src/components/Subscriptions/components/chips/ShowsChip.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Chip } from '../../../../components/ui'; +import { MovieOutlined as ShowsIcon } from '../../../../lib/icons'; +import { SHARED_CHANNEL_META_CHIP_STYLE, SHARED_CHANNEL_META_DEFAULT_SURFACE_STYLE } from '../../../shared/chipStyles'; + +interface ShowsChipProps { + /** The channel's active title shows */ + count: number; +} + +/** Marks a channel with title shows (shows built from its video titles). */ +const ShowsChip: React.FC<ShowsChipProps> = ({ count }) => { + if (!count) return null; + return ( + <Chip + data-testid="shows-chip" + size="small" + color="default" + icon={<ShowsIcon size={14} style={{ color: 'var(--channel-meta-chip-icon)' }} />} + label={`${count} ${count === 1 ? 'show' : 'shows'}`} + title="Title shows: series built from this channel's video titles" + style={{ ...SHARED_CHANNEL_META_CHIP_STYLE, ...SHARED_CHANNEL_META_DEFAULT_SURFACE_STYLE }} + /> + ); +}; + +export default ShowsChip; diff --git a/client/src/components/Subscriptions/components/chips/__tests__/ShowsChip.test.tsx b/client/src/components/Subscriptions/components/chips/__tests__/ShowsChip.test.tsx new file mode 100644 index 000000000..293b3d1bd --- /dev/null +++ b/client/src/components/Subscriptions/components/chips/__tests__/ShowsChip.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ShowsChip from '../ShowsChip'; + +describe('ShowsChip', () => { + test('counts a channel\'s title shows', () => { + render(<ShowsChip count={3} />); + expect(screen.getByTestId('shows-chip')).toHaveTextContent('3 shows'); + }); + + test('says "1 show" for one', () => { + render(<ShowsChip count={1} />); + expect(screen.getByTestId('shows-chip')).toHaveTextContent('1 show'); + }); + + test('renders nothing without shows', () => { + render(<ShowsChip count={0} />); + expect(screen.queryByTestId('shows-chip')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Subscriptions/components/chips/index.ts b/client/src/components/Subscriptions/components/chips/index.ts index 147af3125..836711b8c 100644 --- a/client/src/components/Subscriptions/components/chips/index.ts +++ b/client/src/components/Subscriptions/components/chips/index.ts @@ -7,3 +7,4 @@ export { default as DownloadFormatConfigIndicator } from './DownloadFormatConfig export { default as TerminatedChip } from './TerminatedChip'; export { default as ProtectedChip } from './ProtectedChip'; export { default as TvChip } from './TvChip'; +export { default as ShowsChip } from './ShowsChip'; diff --git a/client/src/components/__tests__/ChannelPage.test.tsx b/client/src/components/__tests__/ChannelPage.test.tsx index 07df697f0..171441ad3 100644 --- a/client/src/components/__tests__/ChannelPage.test.tsx +++ b/client/src/components/__tests__/ChannelPage.test.tsx @@ -63,6 +63,25 @@ jest.mock('../ChannelPage/hooks/useChannelTv', () => ({ }), })); +const mockRefetchTitleShows = jest.fn(() => Promise.resolve()); +const mockTitleShows: { current: { shows: Array<{ id: number; name: string; retired: boolean }> } | null } = { current: null }; +jest.mock('../ChannelPage/hooks/useTitleShows', () => ({ + useTitleShows: () => ({ + data: mockTitleShows.current, + loading: false, + error: null, + refetch: mockRefetchTitleShows, + }), +})); + +// The listing-refresh subscriptions the page makes (download events, a reorganize's end). +const listingRefreshCallbacks: Array<() => void> = []; +jest.mock('../../hooks/useDownloadListingsRefresh', () => ({ + useDownloadListingsRefresh: (onRefresh: () => void) => { + listingRefreshCallbacks.push(onRefresh); + }, +})); + const axios = require('axios'); // Mock fetch @@ -107,6 +126,7 @@ describe('ChannelPage Component', () => { dialogPropsStore.current = null; channelVideosPropsStore.current = null; mockChannelTv.current = null; + mockTitleShows.current = null; }); describe('TV chip', () => { @@ -143,6 +163,81 @@ describe('ChannelPage Component', () => { }); }); + describe('Title shows', () => { + const page = () => ( + <BrowserRouter> + <ChannelPage token={mockToken} /> + </BrowserRouter> + ); + const shows = [ + { id: 1, name: 'Tier Lists', retired: false }, + { id: 2, name: 'Old Series', retired: true }, + { id: 3, name: 'Q&A', retired: false }, + ]; + + test('counts the active title shows in the header chip', async () => { + mockTitleShows.current = { shows }; + render(page()); + + expect(await screen.findByTestId('shows-chip')).toHaveTextContent('2 shows'); + }); + + test('shows no shows chip for a channel without title shows', async () => { + mockTitleShows.current = { shows: [] }; + render(page()); + + await screen.findByText('Tech Channel'); + expect(screen.queryByTestId('shows-chip')).not.toBeInTheDocument(); + }); + + test('offers only the active shows to the video list', async () => { + mockTitleShows.current = { shows }; + render(page()); + await screen.findByText('Tech Channel'); + + expect(channelVideosPropsStore.current.titleShows).toEqual([ + { id: 1, name: 'Tier Lists' }, + { id: 3, name: 'Q&A' }, + ]); + }); + + test('asks the video list to reload when the settings dialog closes', async () => { + render(page()); + await screen.findByText('Tech Channel'); + const before = channelVideosPropsStore.current.refreshKey; + act(() => { + dialogPropsStore.current.onClose(); + }); + expect(channelVideosPropsStore.current.refreshKey).not.toBe(before); + }); + + test('reloads the shows when the settings dialog closes', async () => { + render(page()); + await screen.findByText('Tech Channel'); + + act(() => { + dialogPropsStore.current.onClose(); + }); + + expect(mockRefetchTitleShows).toHaveBeenCalled(); + }); + + test('reloads the shows and TV state on a listing refresh (a reorganize ended)', async () => { + listingRefreshCallbacks.length = 0; + render(page()); + await screen.findByText('Tech Channel'); + mockRefetchTitleShows.mockClear(); + mockRefetchChannelTv.mockClear(); + + act(() => { + listingRefreshCallbacks.forEach((callback) => callback()); + }); + + expect(mockRefetchTitleShows).toHaveBeenCalled(); + expect(mockRefetchChannelTv).toHaveBeenCalled(); + }); + }); + describe('Auto-download toggles', () => { const channelA = { uploader: 'Channel A', channel_id: 'UC123456', available_tabs: 'videos,shorts,streams', auto_download_enabled_tabs: 'video' }; const savedAs = (value: string) => ({ data: { settings: { auto_download_enabled_tabs: value } } }); diff --git a/client/src/components/shared/EpisodeAssign/EpisodeAssignDialog.tsx b/client/src/components/shared/EpisodeAssign/EpisodeAssignDialog.tsx new file mode 100644 index 000000000..bb05192f3 --- /dev/null +++ b/client/src/components/shared/EpisodeAssign/EpisodeAssignDialog.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useId, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + Typography, +} from '../../ui'; +import { EpisodeAssignment, VideoEpisode } from '../../../types/titleShows'; +import { ReorganizeChange, ReorganizeStartResult } from '../../../types/reorganize'; +import { isReorganizeRequired } from '../Reorganize/reorganizeErrors'; +import ReorganizeDialog from '../Reorganize/ReorganizeDialog'; +import { useReorganizeOutcome } from '../Reorganize/hooks/useReorganizeOutcome'; +import { useVideoEpisode } from './useVideoEpisode'; +import { isAssignableSeason, MAX_YEAR_SEASON, SEASON_RANGE_TEXT } from '../../../utils/seasonNumbers'; + +const NO_SHOWS_NOTE = 'This video\'s channel has no shows. Add a show in Channel Settings > TV Show first.'; +const MANUAL_NOTE = 'A number you assign stays until you change it: new titles and show edits never move it.'; + +export interface EpisodeAssignDialogProps { + open: boolean; + token: string | null; + youtubeId: string | null; + videoTitle?: string | null; + onClose: () => void; + /** The assignment was saved, or the move it needs was started */ + onSaved?: () => void; +} + +function describeCurrent(classification: VideoEpisode['classification']): string { + if (!classification) return 'Now: not in a show.'; + if (classification.notAnEpisode) return 'Now: not an episode.'; + if (classification.status === 'duplicate') return `Now: a duplicate in ${classification.showName}.`; + if (classification.status === 'pending_number') return `Now: in ${classification.showName}, numbered when it downloads.`; + if (classification.status === 'unsupported') return `Now: matched ${classification.showName}, but not supported yet.`; + if (!classification.code) return `Now: in ${classification.showName}.`; + return `Now: ${classification.code} of ${classification.showName}.`; +} + +function toNumber(text: string): number | null { + return /^\d+$/.test(text.trim()) ? Number(text.trim()) : null; +} + +/** Assign a video to a title show episode by hand, or take it out of title shows. */ +function EpisodeAssignDialog({ open, token, youtubeId, videoTitle = null, onClose, onSaved }: EpisodeAssignDialogProps) { + const showLabelId = useId(); + const { data, loading, error, assign } = useVideoEpisode(open ? youtubeId : null, token); + const [showId, setShowId] = useState<number | null>(null); + const [season, setSeason] = useState('1'); + const [episode, setEpisode] = useState(''); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState<string | null>(null); + const [move, setMove] = useState<ReorganizeChange | null>(null); + // The move a reassignment started: the episode is saved when it ends (or + // undone, when nothing could move), not when it starts. + const [trackedMove, setTrackedMove] = useState<{ operationId: number; attempt: number } | null>(null); + useReorganizeOutcome(token, trackedMove?.operationId ?? null, () => onSaved?.(), { attempt: trackedMove?.attempt ?? 0 }); + + const handleMoveStarted = (result: ReorganizeStartResult) => { + if (result.operationId) setTrackedMove({ operationId: result.operationId, attempt: 0 }); + else onSaved?.(); + }; + const handleMoveRetried = (operationId: number) => { + setTrackedMove((current) => ({ + operationId, + attempt: current && current.operationId === operationId ? current.attempt + 1 : 1, + })); + }; + + // Start from the current episode, else the first show; forget the previous + // video's values while the next one loads. + useEffect(() => { + if (!data) { + setShowId(null); + setSeason('1'); + setEpisode(''); + return; + } + const current = data.classification; + const inTitleShow = current && current.kind === 'title' && data.shows.some((show) => show.id === current.showId); + setShowId(inTitleShow ? current.showId : data.shows[0]?.id ?? null); + setSeason(inTitleShow && current.season !== null ? String(current.season) : '1'); + setEpisode(inTitleShow && current.episode !== null ? String(current.episode) : ''); + setSaveError(null); + }, [data]); + + const save = async (assignment: EpisodeAssignment) => { + setSaving(true); + setSaveError(null); + try { + await assign(assignment); + onSaved?.(); + onClose(); + } catch (err: unknown) { + if (isReorganizeRequired(err)) setMove(err.change); + else setSaveError(err instanceof Error ? err.message : 'Failed to save the episode'); + } finally { + setSaving(false); + } + }; + + const seasonNumber = toNumber(season); + const episodeNumber = toNumber(episode); + const valid = Boolean(data && data.assignable) && showId !== null && isAssignableSeason(seasonNumber) + && episodeNumber !== null && episodeNumber >= 1; + const current = data?.classification ?? null; + const inTitleShow = Boolean(current && current.kind === 'title' && !current.notAnEpisode); + const canReset = Boolean(current && (current.notAnEpisode || current.source === 'manual')); + const selectedShow = data?.shows.find((show) => show.id === showId); + const seasonName = selectedShow && seasonNumber !== null ? selectedShow.seasonNames[seasonNumber] : undefined; + const seasonHint = season.trim() !== '' && !isAssignableSeason(seasonNumber) ? SEASON_RANGE_TEXT : seasonName; + + return ( + <> + <Dialog open={open && move === null} onClose={onClose} maxWidth="xs" fullWidth> + <DialogTitle onClose={onClose}>Change episode</DialogTitle> + <DialogContent> + <Box className="flex flex-col gap-3 pt-2"> + {videoTitle && <Typography variant="body2" className="font-medium break-words">{videoTitle}</Typography>} + {loading && !data && <CircularProgress size={24} />} + {error && <Alert severity="error">{error}</Alert>} + {data && !data.assignable && <Alert severity="info">{NO_SHOWS_NOTE}</Alert>} + {data && data.assignable && ( + <> + <Typography variant="body2" color="text.secondary">{describeCurrent(current)}</Typography> + <FormControl fullWidth> + <InputLabel id={showLabelId} shrink>Show</InputLabel> + <Select + labelId={showLabelId} + size="small" + value={showId === null ? '' : String(showId)} + onChange={(event) => setShowId(Number(event.target.value))} + > + {data.shows.map((show) => <MenuItem key={show.id} value={String(show.id)}>{show.name}</MenuItem>)} + </Select> + </FormControl> + <Box className="flex gap-3"> + <TextField + label="Season" + type="number" + size="small" + value={season} + onChange={(event) => setSeason(event.target.value)} + inputProps={{ min: 0, max: MAX_YEAR_SEASON }} + helperText={seasonHint || undefined} + /> + <TextField + label="Episode" + type="number" + size="small" + value={episode} + onChange={(event) => setEpisode(event.target.value)} + inputProps={{ min: 1 }} + /> + </Box> + <Typography variant="caption" color="text.secondary">{MANUAL_NOTE}</Typography> + </> + )} + {saveError && <Alert severity="error">{saveError}</Alert>} + </Box> + </DialogContent> + <DialogActions> + {data && data.assignable && canReset && ( + <Button variant="text" disabled={saving} onClick={() => { void save({ automatic: true }); }}>Back to automatic</Button> + )} + {data && data.assignable && inTitleShow && ( + <Button variant="outlined" disabled={saving} onClick={() => { void save({ notAnEpisode: true }); }}>Not an episode</Button> + )} + <Button + variant="contained" + disabled={!valid || saving} + startIcon={saving ? <CircularProgress size={14} /> : undefined} + onClick={() => { + if (valid && showId !== null && seasonNumber !== null && episodeNumber !== null) { + void save({ showId, season: seasonNumber, episode: episodeNumber }); + } + }} + > + Assign + </Button> + </DialogActions> + </Dialog> + <ReorganizeDialog + open={move !== null} + token={token} + change={move} + onClose={() => { setMove(null); onClose(); }} + onApplied={handleMoveStarted} + onRetried={handleMoveRetried} + /> + </> + ); +} + +export default EpisodeAssignDialog; diff --git a/client/src/components/shared/EpisodeAssign/__tests__/EpisodeAssignDialog.test.tsx b/client/src/components/shared/EpisodeAssign/__tests__/EpisodeAssignDialog.test.tsx new file mode 100644 index 000000000..65ff828fd --- /dev/null +++ b/client/src/components/shared/EpisodeAssign/__tests__/EpisodeAssignDialog.test.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { VideoEpisode } from '../../../../types/titleShows'; +import { ReorganizeRequiredError } from '../../Reorganize/reorganizeErrors'; + +const mockAssign = jest.fn(); +let mockState: { data: VideoEpisode | null; loading: boolean; error: string | null }; + +jest.mock('../useVideoEpisode', () => ({ + useVideoEpisode: () => ({ ...mockState, refetch: jest.fn(), assign: mockAssign }), +})); + +let mockStartResult: { operationId: number | null; applied: boolean } = { operationId: 77, applied: false }; +jest.mock('../../Reorganize/ReorganizeDialog', () => ({ + __esModule: true, + default: function MockReorganizeDialog(props: { open: boolean; change: unknown; onApplied?: (result: unknown) => void }) { + const React = require('react'); + return props.open + ? React.createElement('button', { 'data-testid': 'reorganize-dialog', onClick: () => props.onApplied?.(mockStartResult) }, JSON.stringify(props.change)) + : null; + }, +})); + +// The tracked operation and what runs when it ends. +const mockOutcome: { operationId: number | null; onFinished: (() => void) | null } = { operationId: null, onFinished: null }; +jest.mock('../../Reorganize/hooks/useReorganizeOutcome', () => ({ + useReorganizeOutcome: (_token: string | null, operationId: number | null, onFinished: () => void) => { + mockOutcome.operationId = operationId; + mockOutcome.onFinished = onFinished; + }, +})); + +import EpisodeAssignDialog from '../EpisodeAssignDialog'; + +const SHOWS: VideoEpisode['shows'] = [{ id: 3, name: 'Beyblade', seasonNames: { 2: 'V-Force' } }, { id: 4, name: 'Clips', seasonNames: {} }]; + +function episode(classification: VideoEpisode['classification'] = null, extra: Partial<VideoEpisode> = {}): VideoEpisode { + return { channelId: 'UC1', assignable: true, classification, shows: SHOWS, ...extra }; +} + +const titleEpisode = (extra = {}) => ({ + showId: 3, showName: 'Beyblade', kind: 'title' as const, status: 'assigned', season: 1, episode: 20, code: 'S01E20', + source: 'title', notAnEpisode: false, ...extra, +}); + +function renderDialog(props: Partial<React.ComponentProps<typeof EpisodeAssignDialog>> = {}) { + const onClose = jest.fn(); + const onSaved = jest.fn(); + render( + <EpisodeAssignDialog open token="token" youtubeId="abcdefghijk" videoTitle="Ep 20" onClose={onClose} onSaved={onSaved} {...props} /> + ); + return { onClose, onSaved }; +} + +describe('EpisodeAssignDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAssign.mockResolvedValue(undefined); + mockState = { data: episode(), loading: false, error: null }; + }); + + test('keeps Assign off while the next video loads', () => { + mockState.data = episode(titleEpisode()); + const { rerender } = render(<EpisodeAssignDialog open token="token" youtubeId="aaaaaaaaaaa" onClose={jest.fn()} />); + mockState = { data: null, loading: true, error: null }; + rerender(<EpisodeAssignDialog open token="token" youtubeId="bbbbbbbbbbb" onClose={jest.fn()} />); + expect(screen.getByRole('button', { name: 'Assign' })).toBeDisabled(); + }); + + test('keeps Assign off when the video could not be loaded', () => { + mockState.data = episode(titleEpisode()); + const { rerender } = render(<EpisodeAssignDialog open token="token" youtubeId="aaaaaaaaaaa" onClose={jest.fn()} />); + mockState = { data: null, loading: false, error: 'Failed to load the episode' }; + rerender(<EpisodeAssignDialog open token="token" youtubeId="bbbbbbbbbbb" onClose={jest.fn()} />); + expect(screen.getByRole('button', { name: 'Assign' })).toBeDisabled(); + }); + + test('starts the next video from its own episode, not the previous one\'s values', async () => { + mockState.data = episode(titleEpisode()); + const { rerender } = render(<EpisodeAssignDialog open token="token" youtubeId="aaaaaaaaaaa" onClose={jest.fn()} />); + await userEvent.clear(screen.getByLabelText('Episode')); + await userEvent.type(screen.getByLabelText('Episode'), '33'); + mockState = { data: null, loading: true, error: null }; + rerender(<EpisodeAssignDialog open token="token" youtubeId="bbbbbbbbbbb" onClose={jest.fn()} />); + mockState = { data: episode(null), loading: false, error: null }; + rerender(<EpisodeAssignDialog open token="token" youtubeId="bbbbbbbbbbb" onClose={jest.fn()} />); + expect(screen.getByLabelText('Episode')).toHaveValue(null); + }); + + test('says where the video is now', () => { + mockState.data = episode(titleEpisode()); + renderDialog(); + expect(screen.getByText('Now: S01E20 of Beyblade.')).toBeInTheDocument(); + }); + + test('starts from the current episode', () => { + mockState.data = episode(titleEpisode()); + renderDialog(); + expect(screen.getByLabelText('Season')).toHaveValue(1); + expect(screen.getByLabelText('Episode')).toHaveValue(20); + }); + + test('assigns the chosen season and episode', async () => { + const { onSaved } = renderDialog(); + await userEvent.type(screen.getByLabelText('Episode'), '51'); + await userEvent.click(screen.getByRole('button', { name: 'Assign' })); + await waitFor(() => expect(mockAssign).toHaveBeenCalledWith({ showId: 3, season: 1, episode: 51 })); + expect(onSaved).toHaveBeenCalled(); + }); + + test('marks the video as not an episode', async () => { + mockState.data = episode(titleEpisode()); + renderDialog(); + await userEvent.click(screen.getByRole('button', { name: 'Not an episode' })); + await waitFor(() => expect(mockAssign).toHaveBeenCalledWith({ notAnEpisode: true })); + }); + + test('returns a hand-numbered video to automatic classification', async () => { + mockState.data = episode(titleEpisode({ source: 'manual' })); + renderDialog(); + await userEvent.click(screen.getByRole('button', { name: 'Back to automatic' })); + await waitFor(() => expect(mockAssign).toHaveBeenCalledWith({ automatic: true })); + }); + + test('offers no way back for an automatic classification', () => { + mockState.data = episode(titleEpisode()); + renderDialog(); + expect(screen.queryByRole('button', { name: 'Back to automatic' })).not.toBeInTheDocument(); + }); + + test('refuses an episode number below 1', async () => { + renderDialog(); + await userEvent.type(screen.getByLabelText('Episode'), '0'); + expect(screen.getByRole('button', { name: 'Assign' })).toBeDisabled(); + }); + + test('assigns an upload-year season', async () => { + renderDialog(); + await userEvent.clear(screen.getByLabelText('Season')); + await userEvent.type(screen.getByLabelText('Season'), '2024'); + await userEvent.type(screen.getByLabelText('Episode'), '5'); + await userEvent.click(screen.getByRole('button', { name: 'Assign' })); + await waitFor(() => expect(mockAssign).toHaveBeenCalledWith({ showId: 3, season: 2024, episode: 5 })); + }); + + test('refuses a season between the title and year ranges', async () => { + renderDialog(); + await userEvent.clear(screen.getByLabelText('Season')); + await userEvent.type(screen.getByLabelText('Season'), '200'); + await userEvent.type(screen.getByLabelText('Episode'), '5'); + expect(screen.getByRole('button', { name: 'Assign' })).toBeDisabled(); + }); + + test('says which seasons can be assigned when the season is out of range', async () => { + renderDialog(); + await userEvent.clear(screen.getByLabelText('Season')); + await userEvent.type(screen.getByLabelText('Season'), '200'); + expect(screen.getByText('0-199, or a year 1928-2500')).toBeInTheDocument(); + }); + + test('says nothing about the range for an upload-year season', async () => { + renderDialog(); + await userEvent.clear(screen.getByLabelText('Season')); + await userEvent.type(screen.getByLabelText('Season'), '2024'); + expect(screen.queryByText('0-199, or a year 1928-2500')).not.toBeInTheDocument(); + }); + + test('explains a channel without shows', () => { + mockState.data = episode(null, { assignable: false, shows: [] }); + renderDialog(); + expect(screen.getByText(/Add a show in Channel Settings/)).toBeInTheDocument(); + }); + + test('shows the server\'s refusal', async () => { + mockAssign.mockRejectedValueOnce(new Error('The season must be between 0 and 199.')); + renderDialog(); + await userEvent.type(screen.getByLabelText('Episode'), '3'); + await userEvent.click(screen.getByRole('button', { name: 'Assign' })); + expect(await screen.findByText('The season must be between 0 and 199.')).toBeInTheDocument(); + }); + + describe('when the file has to move', () => { + const change = { type: 'titleShows' as const, channelId: 'UC1', shows: [], overrides: [] }; + + const startMove = async () => { + mockAssign.mockRejectedValueOnce(new ReorganizeRequiredError('Review the move first.', change)); + const { onSaved, onClose } = renderDialog(); + await userEvent.type(screen.getByLabelText('Episode'), '3'); + await userEvent.click(screen.getByRole('button', { name: 'Assign' })); + await userEvent.click(await screen.findByTestId('reorganize-dialog')); + return { onSaved, onClose }; + }; + + beforeEach(() => { + mockStartResult = { operationId: 77, applied: false }; + mockOutcome.operationId = null; + mockOutcome.onFinished = null; + }); + + test('waits for the move to end before reporting the episode saved', async () => { + const { onSaved } = await startMove(); + expect(onSaved).not.toHaveBeenCalled(); + }); + + test('follows the move it started', async () => { + await startMove(); + expect(mockOutcome.operationId).toBe(77); + }); + + test('reports the episode saved when the move ends', async () => { + const { onSaved } = await startMove(); + act(() => { mockOutcome.onFinished?.(); }); + expect(onSaved).toHaveBeenCalled(); + }); + + test('reports a change applied without a move as saved at once', async () => { + mockStartResult = { operationId: null, applied: true }; + const { onSaved } = await startMove(); + expect(onSaved).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/components/shared/EpisodeAssign/__tests__/useVideoEpisode.test.ts b/client/src/components/shared/EpisodeAssign/__tests__/useVideoEpisode.test.ts new file mode 100644 index 000000000..1c11ccc8f --- /dev/null +++ b/client/src/components/shared/EpisodeAssign/__tests__/useVideoEpisode.test.ts @@ -0,0 +1,59 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; + +jest.mock('axios', () => ({ + get: jest.fn(), + put: jest.fn(), + isAxiosError: (e: unknown) => Boolean(e && (e as { isAxiosError?: boolean }).isAxiosError), +})); + +const axios = require('axios'); + +import { useVideoEpisode } from '../useVideoEpisode'; +import { ReorganizeRequiredError } from '../../Reorganize/reorganizeErrors'; + +const HEADERS = { headers: { 'x-access-token': 'token' } }; +const EPISODE = { channelId: 'UC1', assignable: true, classification: null, shows: [{ id: 3, name: 'Beyblade', seasonNames: {} }] }; + +describe('useVideoEpisode', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: EPISODE }); + }); + + test('loads the video\'s episode', async () => { + const { result } = renderHook(() => useVideoEpisode('abcdefghijk', 'token')); + await waitFor(() => expect(result.current.data).toEqual(EPISODE)); + expect(axios.get).toHaveBeenCalledWith('/api/videos/abcdefghijk/episode', HEADERS); + }); + + test('loads nothing without a video', () => { + renderHook(() => useVideoEpisode(null, 'token')); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('assigns an episode and keeps the answer', async () => { + const after = { ...EPISODE, classification: { showId: 3, code: 'S01E20' } }; + axios.put.mockResolvedValueOnce({ data: after }); + const { result } = renderHook(() => useVideoEpisode('abcdefghijk', 'token')); + await waitFor(() => expect(result.current.data).toEqual(EPISODE)); + await act(async () => { await result.current.assign({ showId: 3, season: 1, episode: 20 }); }); + expect(axios.put).toHaveBeenCalledWith('/api/videos/abcdefghijk/episode', { showId: 3, season: 1, episode: 20 }, HEADERS); + expect(result.current.data).toEqual(after); + }); + + test('throws a reorganize request when the file has to move', async () => { + const change = { type: 'titleShows', channelId: 'UC1', shows: [], overrides: [] }; + axios.put.mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { error: 'Review', reorganizeRequired: true, change } } }); + const { result } = renderHook(() => useVideoEpisode('abcdefghijk', 'token')); + await waitFor(() => expect(result.current.data).toEqual(EPISODE)); + let thrown: unknown; + await act(async () => { try { await result.current.assign({ notAnEpisode: true }); } catch (err) { thrown = err; } }); + expect(thrown).toBeInstanceOf(ReorganizeRequiredError); + }); + + test('reports a failed load', async () => { + axios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 500, data: { error: 'Failed to load the episode' } } }); + const { result } = renderHook(() => useVideoEpisode('abcdefghijk', 'token')); + await waitFor(() => expect(result.current.error).toBe('Failed to load the episode')); + }); +}); diff --git a/client/src/components/shared/EpisodeAssign/useVideoEpisode.ts b/client/src/components/shared/EpisodeAssign/useVideoEpisode.ts new file mode 100644 index 000000000..6df7589ff --- /dev/null +++ b/client/src/components/shared/EpisodeAssign/useVideoEpisode.ts @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { EpisodeAssignment, VideoEpisode } from '../../../types/titleShows'; +import { serverMessageOf, toRequestError } from '../Reorganize/reorganizeErrors'; + +export interface UseVideoEpisodeResult { + data: VideoEpisode | null; + loading: boolean; + error: string | null; + refetch: () => Promise<void>; + /** + * Assign the episode, mark "Not an episode" or return to automatic + * classification. Throws a ReorganizeRequiredError when the downloaded file + * has to move, else an Error with the server's message. + */ + assign: (assignment: EpisodeAssignment) => Promise<void>; +} + +/** A video's title show episode (GET/PUT /api/videos/:youtubeId/episode). */ +export function useVideoEpisode(youtubeId: string | null, token: string | null): UseVideoEpisodeResult { + const [data, setData] = useState<VideoEpisode | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const requestSeq = useRef(0); + const url = youtubeId ? `/api/videos/${encodeURIComponent(youtubeId)}/episode` : null; + + const refetch = useCallback(async () => { + if (!url || !token) return; + const seq = ++requestSeq.current; + setLoading(true); + setError(null); + try { + const response = await axios.get<VideoEpisode>(url, { headers: { 'x-access-token': token } }); + if (seq === requestSeq.current) setData(response.data); + } catch (err) { + if (seq === requestSeq.current) setError(serverMessageOf(err, 'Failed to load the episode')); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [url, token]); + + useEffect(() => { + setData(null); + void refetch(); + }, [refetch]); + + const assign = useCallback(async (assignment: EpisodeAssignment) => { + if (!url || !token) throw new Error('Not signed in'); + const seq = ++requestSeq.current; + try { + const response = await axios.put<VideoEpisode>(url, assignment, { headers: { 'x-access-token': token } }); + if (seq === requestSeq.current) setData(response.data); + } catch (err) { + throw toRequestError(err, 'Failed to save the episode'); + } + }, [url, token]); + + return { data, loading, error, refetch, assign }; +} + +export default useVideoEpisode; diff --git a/client/src/components/shared/EpisodeChip.tsx b/client/src/components/shared/EpisodeChip.tsx index eaa651857..b7d927681 100644 --- a/client/src/components/shared/EpisodeChip.tsx +++ b/client/src/components/shared/EpisodeChip.tsx @@ -7,24 +7,27 @@ import type { EpisodeInfo } from '../../types/tvShows'; interface EpisodeChipProps { episode: EpisodeInfo | null | undefined; compact?: boolean; + /** The episode a title show gives a video that isn't downloaded yet */ + planned?: boolean; } -/** SxxEyy of a video saved as a TV episode; nothing for other videos. */ -function EpisodeChip({ episode, compact = false }: EpisodeChipProps) { +/** SxxEyy of a video saved (or planned) as a TV episode; nothing for other videos. */ +function EpisodeChip({ episode, compact = false, planned = false }: EpisodeChipProps) { if (!episode) return null; const description = episode.showName ? `${episode.showName}, ${episode.code}` : episode.code; const style = compact ? { ...SHARED_STATUS_CHIP_SMALL_STYLE, ...SHARED_COMPACT_CHIP_OVERRIDES } : SHARED_STATUS_CHIP_SMALL_STYLE; + const label = planned ? `Planned TV episode ${description}, once downloaded` : `TV episode ${description}`; return ( - <Tooltip title={`TV episode: ${description}`}> + <Tooltip title={planned ? `Planned TV episode: ${description}, once downloaded` : `TV episode: ${description}`}> <Chip size="small" icon={<TvIcon size={14} />} label={episode.code} variant="outlined" - style={style} - aria-label={`TV episode ${description}`} + style={planned ? { ...style, borderStyle: 'dashed' } : style} + aria-label={label} data-testid="episode-chip" /> </Tooltip> diff --git a/client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx b/client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx index 3133ac666..bcd2a6202 100644 --- a/client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx +++ b/client/src/components/shared/Reorganize/ReorganizeOperationBody.tsx @@ -21,12 +21,16 @@ function resultAlert(operation: ReorganizeOperation) { return <Alert severity="success">Moved {countOf(done, 'video')}.</Alert>; } if (operation.status === 'partial') { - return ( - <Alert severity="warning"> - Moved {countOf(done, 'video')}. {countOf(failed, 'video')} could not be moved and - {' '}{agree(failed, 'stays where it was', 'stay where they were')}. - </Alert> - ); + const unfinished = (operation.failedItems ?? []).filter((item) => item.filesMoved).length; + const stayed = Math.max(failed - unfinished, 0); + const parts = [`Moved ${countOf(done, 'video')}.`]; + if (stayed > 0) { + parts.push(`${countOf(stayed, 'video')} could not be moved and ${agree(stayed, 'stays where it was', 'stay where they were')}.`); + } + if (unfinished > 0) { + parts.push(`${countOf(unfinished, 'video')} ${agree(unfinished, 'was', 'were')} moved but did not finish.`); + } + return <Alert severity="warning">{parts.join(' ')}</Alert>; } return <Alert severity="error">{operation.error || 'The move failed.'}</Alert>; } diff --git a/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx index c86f30014..629e6b30d 100644 --- a/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx +++ b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx @@ -98,6 +98,11 @@ function problemLines(preview: ReorganizePreview): string[] { if (totals.unsafeName > 0) { lines.push(`${countOf(totals.unsafeName, 'video')} would land outside the downloads folder and ${stays(totals.unsafeName)}.`); } + const episodeTaken = totals.episodeTaken ?? 0; + if (episodeTaken > 0) { + lines.push(`${countOf(episodeTaken, 'video')} waiting for ${agree(episodeTaken, 'its', 'their')} upload year ` + + `${agree(episodeTaken, 'has an episode number', 'have episode numbers')} another video holds and ${stays(episodeTaken)}.`); + } if (totals.collisions > 0) { lines.push(`${countOf(totals.collisions, 'file')} would replace a file that is already there. ` + `${agree(totals.collisions, 'Its video fails', 'Those videos fail')} until it is removed.`); diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx index e3f17ffcd..4b18494e7 100644 --- a/client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizeOperationBody.test.tsx @@ -42,6 +42,29 @@ describe('ReorganizeOperationBody', () => { expect(onRetry).toHaveBeenCalled(); }); + test('does not say a video whose files moved stays where it was', () => { + renderBody(operation({ + status: 'partial', done: 1, failed: 1, + failedItems: [{ id: 9, youtubeId: 'b2', title: 'Second', channelId: 'UC1', error: 'finishing failed', filesMoved: true }], + })); + + expect(screen.getByText('Moved 1 video. 1 video was moved but did not finish.')).toBeInTheDocument(); + }); + + test('tells apart the videos that stayed and the ones that did not finish', () => { + renderBody(operation({ + status: 'partial', done: 1, failed: 2, + failedItems: [ + { id: 8, youtubeId: 'a1', title: 'First', channelId: 'UC1', error: 'A file already exists', filesMoved: false }, + { id: 9, youtubeId: 'b2', title: 'Second', channelId: 'UC1', error: 'finishing failed', filesMoved: true }, + ], + })); + + expect(screen.getByText( + 'Moved 1 video. 1 video could not be moved and stays where it was. 1 video was moved but did not finish.' + )).toBeInTheDocument(); + }); + test('shows why a move failed', () => { renderBody(operation({ status: 'failed', done: 0, error: 'No video could be moved, so the settings change was undone.' })); diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx index 8316119f6..610d0d7ef 100644 --- a/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx @@ -34,6 +34,20 @@ describe('ReorganizePreviewBody', () => { expect(screen.getByText('1 video is already where it belongs.')).toBeInTheDocument(); }); + test('says when a title episode\'s number is held by another video', () => { + render(<ReorganizePreviewBody preview={preview({ totals: { ...TOTALS, episodeTaken: 1 } })} />); + + expect(screen.getByText(/1 video waiting for its upload year has an episode number another video holds/)).toBeInTheDocument(); + }); + + test('names a new title show', () => { + render(<ReorganizePreviewBody preview={preview({ + shows: [{ name: 'Beyblade', libraryFolder: 'TV', folderName: 'Beyblade', action: 'create', kind: 'title' }], + })} />); + + expect(screen.getByText('New show: __TV/Beyblade')).toBeInTheDocument(); + }); + test('lists each move with its old and new path and episode', () => { render(<ReorganizePreviewBody preview={preview()} />); diff --git a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx index 771b88979..77ea1a93b 100644 --- a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx +++ b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx @@ -49,6 +49,16 @@ const watchStatusReturn = { loading: false, }; const watchStatusCalls: unknown[][] = []; +// The episode section fetches /api/videos/:id/episode on its own; it has its own tests. +const mockEpisodeSection: { props: { onChanged?: (youtubeId: string) => void } | null } = { props: null }; +jest.mock('../components/VideoEpisodeSection', () => ({ + __esModule: true, + default: (props: { onChanged?: (youtubeId: string) => void }) => { + mockEpisodeSection.props = props; + return null; + }, +})); + jest.mock('../hooks/useWatchStatus', () => ({ useWatchStatus: (...args: unknown[]) => { watchStatusCalls.push(args); @@ -668,6 +678,15 @@ describe('VideoModal', () => { }); }); + describe('episode changes', () => { + test('tells its page when the episode section changes the episode', () => { + const onEpisodeChanged = jest.fn(); + renderModal({ onEpisodeChanged }); + act(() => { mockEpisodeSection.props?.onChanged?.('test123'); }); + expect(onEpisodeChanged).toHaveBeenCalledWith('test123'); + }); + }); + describe('handleRatingApply', () => { test('calls rating API and fires onRatingChanged on success', async () => { const onRatingChanged = jest.fn(); diff --git a/client/src/components/shared/VideoModal/components/VideoEpisodeSection.tsx b/client/src/components/shared/VideoModal/components/VideoEpisodeSection.tsx new file mode 100644 index 000000000..af298ea09 --- /dev/null +++ b/client/src/components/shared/VideoModal/components/VideoEpisodeSection.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { Box, Button, Typography } from '../../../ui'; +import { VideoEpisode } from '../../../../types/titleShows'; +import { useVideoEpisode } from '../../EpisodeAssign/useVideoEpisode'; +import EpisodeAssignDialog from '../../EpisodeAssign/EpisodeAssignDialog'; + +function episodeText(classification: VideoEpisode['classification']): string { + if (!classification) return 'Not in a show'; + if (classification.notAnEpisode) return 'Not an episode'; + if (classification.status === 'duplicate') return `Duplicate in ${classification.showName}`; + if (classification.status === 'pending_number') return `${classification.showName}, numbered when it downloads`; + if (classification.status === 'unsupported') return `${classification.showName} (not supported yet)`; + return classification.code ? `${classification.code} of ${classification.showName}` : `${classification.showName}`; +} + +interface VideoEpisodeSectionProps { + open: boolean; + token: string | null; + youtubeId: string; + title: string; + /** Called after a save, so the page can reload its planned episode chips. */ + onChanged?: (youtubeId: string) => void; +} + +/** The video's title show episode, and the way to change it by hand. Hidden for channels without title shows. */ +function VideoEpisodeSection({ open, token, youtubeId, title, onChanged }: VideoEpisodeSectionProps) { + const { data, refetch } = useVideoEpisode(open ? youtubeId : null, token); + const [assigning, setAssigning] = useState(false); + if (!data || !data.assignable) return null; + + return ( + <Box className="flex flex-wrap items-center gap-2"> + <Typography variant="body2" className="font-semibold">Episode</Typography> + <Typography variant="body2" color="text.secondary" className="flex-1">{episodeText(data.classification)}</Typography> + <Button size="small" variant="outlined" onClick={() => setAssigning(true)}>Change episode...</Button> + <EpisodeAssignDialog + open={assigning} + token={token} + youtubeId={youtubeId} + videoTitle={title} + onClose={() => setAssigning(false)} + onSaved={() => { void refetch(); onChanged?.(youtubeId); }} + /> + </Box> + ); +} + +export default VideoEpisodeSection; diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoEpisodeSection.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoEpisodeSection.test.tsx new file mode 100644 index 000000000..270d861ae --- /dev/null +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoEpisodeSection.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { VideoEpisode } from '../../../../../types/titleShows'; + +const mockRefetch = jest.fn(); +let mockData: VideoEpisode | null = null; +jest.mock('../../../EpisodeAssign/useVideoEpisode', () => ({ + useVideoEpisode: () => ({ data: mockData, loading: false, error: null, refetch: mockRefetch, assign: jest.fn() }), +})); +jest.mock('../../../EpisodeAssign/EpisodeAssignDialog', () => ({ + __esModule: true, + default: function MockAssign(props: { open: boolean; onSaved?: () => void }) { + const React = require('react'); + return props.open ? React.createElement('button', { 'data-testid': 'assign', onClick: () => props.onSaved?.() }, 'saved') : null; + }, +})); + +import VideoEpisodeSection from '../VideoEpisodeSection'; + +const SHOWS = [{ id: 3, name: 'Beyblade', seasonNames: {} }]; + +describe('VideoEpisodeSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockData = null; + }); + + test('shows nothing for a channel without shows', () => { + mockData = { channelId: 'UC1', assignable: false, classification: null, shows: [] }; + const { container } = render(<VideoEpisodeSection open token="token" youtubeId="abcdefghijk" title="T" />); + expect(container).toBeEmptyDOMElement(); + }); + + test('names the episode of a title show', () => { + mockData = { + channelId: 'UC1', assignable: true, shows: SHOWS, + classification: { showId: 3, showName: 'Beyblade', kind: 'title', status: 'assigned', season: 1, episode: 20, code: 'S01E20', source: 'title', notAnEpisode: false }, + }; + render(<VideoEpisodeSection open token="token" youtubeId="abcdefghijk" title="T" />); + expect(screen.getByText('S01E20 of Beyblade')).toBeInTheDocument(); + }); + + test('says when a video is in no show', () => { + mockData = { channelId: 'UC1', assignable: true, shows: SHOWS, classification: null }; + render(<VideoEpisodeSection open token="token" youtubeId="abcdefghijk" title="T" />); + expect(screen.getByText('Not in a show')).toBeInTheDocument(); + }); + + test('changes the episode and reloads it once saved', () => { + mockData = { channelId: 'UC1', assignable: true, shows: SHOWS, classification: null }; + render(<VideoEpisodeSection open token="token" youtubeId="abcdefghijk" title="T" />); + fireEvent.click(screen.getByRole('button', { name: 'Change episode...' })); + fireEvent.click(screen.getByTestId('assign')); + expect(mockRefetch).toHaveBeenCalled(); + }); + + test('tells its page the episode changed once saved', () => { + mockData = { channelId: 'UC1', assignable: true, shows: SHOWS, classification: null }; + const onChanged = jest.fn(); + render(<VideoEpisodeSection open token="token" youtubeId="abcdefghijk" title="T" onChanged={onChanged} />); + fireEvent.click(screen.getByRole('button', { name: 'Change episode...' })); + fireEvent.click(screen.getByTestId('assign')); + expect(onChanged).toHaveBeenCalledWith('abcdefghijk'); + }); +}); diff --git a/client/src/components/shared/VideoModal/index.tsx b/client/src/components/shared/VideoModal/index.tsx index 82f40e373..aa78772ce 100644 --- a/client/src/components/shared/VideoModal/index.tsx +++ b/client/src/components/shared/VideoModal/index.tsx @@ -17,6 +17,7 @@ import VideoMetadata from './components/VideoMetadata'; import VideoActions from './components/VideoActions'; import VideoTechnical from './components/VideoTechnical'; import VideoWatchStatusSection from './components/VideoWatchStatusSection'; +import VideoEpisodeSection from './components/VideoEpisodeSection'; import { useVideoMetadata } from './hooks/useVideoMetadata'; import { useWatchStatus } from './hooks/useWatchStatus'; import { useVideoModalActions } from './hooks/useVideoModalActions'; @@ -42,6 +43,7 @@ function VideoModal({ onRatingChanged, onAvailabilityDetected, onPublishedDateDetected, + onEpisodeChanged, allowIgnore, }: VideoModalProps) { const isMobile = useMediaQuery('(max-width: 599px)'); @@ -281,6 +283,13 @@ function VideoModal({ loading={metadataLoading} onAddChannel={canAddChannel ? () => setAddChannelOpen(true) : undefined} /> + <VideoEpisodeSection + open={open} + token={token} + youtubeId={video.youtubeId} + title={displayVideo.title} + onChanged={onEpisodeChanged} + /> <VideoWatchStatusSection statuses={watchStatuses} /> <VideoTechnical video={displayVideo} diff --git a/client/src/components/shared/VideoModal/types.ts b/client/src/components/shared/VideoModal/types.ts index 6cbed0ece..d7ad06052 100644 --- a/client/src/components/shared/VideoModal/types.ts +++ b/client/src/components/shared/VideoModal/types.ts @@ -39,6 +39,7 @@ export interface VideoModalProps { onRatingChanged?: (youtubeId: string, rating: string | null) => void; onAvailabilityDetected?: (youtubeId: string, availability: string) => void; onPublishedDateDetected?: (youtubeId: string, isoDate: string) => void; + onEpisodeChanged?: (youtubeId: string) => void; allowIgnore?: boolean; } diff --git a/client/src/components/shared/__tests__/EpisodeChip.test.tsx b/client/src/components/shared/__tests__/EpisodeChip.test.tsx index 869c972e9..79f3601f9 100644 --- a/client/src/components/shared/__tests__/EpisodeChip.test.tsx +++ b/client/src/components/shared/__tests__/EpisodeChip.test.tsx @@ -20,4 +20,9 @@ describe('EpisodeChip', () => { render(<EpisodeChip episode={null} />); expect(screen.queryByTestId('episode-chip')).not.toBeInTheDocument(); }); + + test('marks the episode a title show gives a video not downloaded yet', () => { + render(<EpisodeChip episode={{ showName: 'Beyblade', season: 1, episode: 20, code: 'S01E20' }} planned />); + expect(screen.getByLabelText('Planned TV episode Beyblade, S01E20, once downloaded')).toBeInTheDocument(); + }); }); diff --git a/client/src/hooks/__tests__/useDownloadListingsRefresh.test.tsx b/client/src/hooks/__tests__/useDownloadListingsRefresh.test.tsx index 4c2c742f2..6aaf23caa 100644 --- a/client/src/hooks/__tests__/useDownloadListingsRefresh.test.tsx +++ b/client/src/hooks/__tests__/useDownloadListingsRefresh.test.tsx @@ -90,6 +90,26 @@ describe('useDownloadListingsRefresh', () => { expect(onRefresh).toHaveBeenCalledTimes(1); }); + test.each(['completed', 'partial', 'failed'])('calls onRefresh when a reorganize ends as %s', (status) => { + const onRefresh = jest.fn(); + renderHook(() => useDownloadListingsRefresh(onRefresh), { wrapper }); + + emitMessage({ destination: 'broadcast', type: 'tvReorganizeProgress', payload: { operationId: 7, status } }); + jest.advanceTimersByTime(1000); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + test('ignores a reorganize that is still running', () => { + const onRefresh = jest.fn(); + renderHook(() => useDownloadListingsRefresh(onRefresh), { wrapper }); + + emitMessage({ destination: 'broadcast', type: 'tvReorganizeProgress', payload: { operationId: 7, status: 'running' } }); + jest.advanceTimersByTime(1000); + + expect(onRefresh).not.toHaveBeenCalled(); + }); + test('ignores unrelated message types', () => { const onRefresh = jest.fn(); renderHook(() => useDownloadListingsRefresh(onRefresh), { wrapper }); diff --git a/client/src/hooks/useDownloadListingsRefresh.ts b/client/src/hooks/useDownloadListingsRefresh.ts index 8fd1a75b2..ebbb06ff0 100644 --- a/client/src/hooks/useDownloadListingsRefresh.ts +++ b/client/src/hooks/useDownloadListingsRefresh.ts @@ -2,17 +2,27 @@ import { useContext, useEffect, useRef } from 'react'; import WebSocketContext from '../contexts/WebSocketContext'; const REFRESH_DEBOUNCE_MS = 1000; +const REORGANIZE_PROGRESS_TYPE = 'tvReorganizeProgress'; +// A reorganize broadcasts its progress; these statuses mean it ended. +const REORGANIZE_RUNNING_STATUSES = new Set(['starting', 'running']); interface BroadcastMessage { destination?: string; type?: string; + payload?: { status?: string }; +} + +function isReorganizeEnd(message: BroadcastMessage): boolean { + return message.type === REORGANIZE_PROGRESS_TYPE + && !REORGANIZE_RUNNING_STATUSES.has(message.payload?.status ?? 'running'); } /** * Calls onRefresh (debounced) when a download job is enqueued or starts - * (jobsUpdated), a download batch persists a video (videosUpdated), or a - * job finishes (downloadComplete), so listing pages can refetch without - * a manual reload. + * (jobsUpdated), a download batch persists a video (videosUpdated), a job + * finishes (downloadComplete), or a reorganize ends (files and episodes + * moved, or the change was undone), so listing pages can refetch without a + * manual reload. */ export function useDownloadListingsRefresh(onRefresh: () => void): void { const wsContext = useContext(WebSocketContext); @@ -31,7 +41,8 @@ export function useDownloadListingsRefresh(onRefresh: () => void): void { message.destination === 'broadcast' && (message.type === 'videosUpdated' || message.type === 'downloadComplete' || - message.type === 'jobsUpdated'); + message.type === 'jobsUpdated' || + isReorganizeEnd(message)); const callback = () => { if (debounceTimer) { diff --git a/client/src/types/Channel.ts b/client/src/types/Channel.ts index 7d4b4143a..cda65464a 100644 --- a/client/src/types/Channel.ts +++ b/client/src/types/Channel.ts @@ -40,4 +40,6 @@ export interface Channel { tab_download_stats?: TabDownloadStatsByTab; // Layout of the folder the channel downloads to. Only on /getchannels entries. layout?: LibraryLayout; + // Active title shows, when the channel has any. Only on /getchannels entries. + titleShows?: number; } diff --git a/client/src/types/ChannelVideo.ts b/client/src/types/ChannelVideo.ts index 5de17d817..fe17b488a 100644 --- a/client/src/types/ChannelVideo.ts +++ b/client/src/types/ChannelVideo.ts @@ -1,4 +1,5 @@ import type { EpisodeInfo } from './tvShows'; +import type { PlannedEpisode } from './titleShows'; /*{ "title": "Five Nights at Freddy’s SCARY Truth…", "id": "WEywwDLeZE0", @@ -43,4 +44,6 @@ export interface ChannelVideo { watchedBy?: string[]; // Show and SxxEyy when the downloaded file is a TV episode episode?: EpisodeInfo | null; + // The episode a title show gives the video before it downloads + plannedEpisode?: PlannedEpisode | null; } diff --git a/client/src/types/reorganize.ts b/client/src/types/reorganize.ts index 8df0f6058..159419b58 100644 --- a/client/src/types/reorganize.ts +++ b/client/src/types/reorganize.ts @@ -1,13 +1,19 @@ +import type { EpisodeAssignment, TitleShowDraft } from './titleShows'; + +/** An episode assignment within a title show change */ +export type EpisodeOverride = { youtubeId: string } & EpisodeAssignment; + /** A settings change that moves downloaded files (POST /api/tv/reorganize[/preview]). */ export type ReorganizeChange = | { type: 'channelLayout'; channelId: string; layout: 'videos' | 'tv'; folder?: string } | { type: 'channel'; channelId: string; subFolder: string | null; previousSubFolder?: string | null } | { type: 'folderLayout'; folder: string; layout: 'videos' | 'tv'; previousLayout?: 'videos' | 'tv' } - | { type: 'defaultSubfolder'; value: string; previousValue?: string }; + | { type: 'defaultSubfolder'; value: string; previousValue?: string } + | { type: 'titleShows'; channelId: string; shows: TitleShowDraft[]; overrides?: EpisodeOverride[] }; export type ReorganizeFlag = 'override-placed' | 'adopted' | 'upload-date-only' | 'download-time' | 'movie-tags'; -export type ReorganizeProblemKind = 'missing' | 'collision' | 'no-name' | 'no-date' | 'unsafe-name'; +export type ReorganizeProblemKind = 'missing' | 'collision' | 'no-name' | 'no-date' | 'unsafe-name' | 'episode-taken'; export interface ReorganizePreviewItem { youtubeId: string; @@ -34,6 +40,8 @@ export interface ReorganizePreviewShow { libraryFolder: string; folderName: string; action: 'create' | 'move' | 'keep'; + /** A channel's show, or a title show */ + kind?: 'channel' | 'title'; } export interface ReorganizeTotals { @@ -47,6 +55,8 @@ export interface ReorganizeTotals { noName: number; noDate: number; unsafeName: number; + /** Title episodes waiting for their upload year whose number another video holds */ + episodeTaken?: number; overridePlaced: number; adopted: number; uploadDateOnly: number; @@ -97,6 +107,8 @@ export interface ReorganizeFailedItem { title: string | null; channelId: string | null; error: string | null; + /** Its files reached their destination, but finishing failed */ + filesMoved?: boolean; } /** GET /api/tv/operations/:id */ diff --git a/client/src/types/titleShows.ts b/client/src/types/titleShows.ts new file mode 100644 index 000000000..b309aab27 --- /dev/null +++ b/client/src/types/titleShows.ts @@ -0,0 +1,219 @@ +/** Title shows: shows defined on a channel by title patterns. */ + +export type PatternKind = 'simple' | 'regex'; +export type SeasonSource = 'title' | 'fixed' | 'year'; +export type EpisodeSource = 'title' | 'date' | 'order'; + +/** One title pattern of a show, as the user writes it. */ +export interface TitlePatternDraft { + text: string; + kind: PatternKind; + seasonSource: SeasonSource; + seasonFixed?: number | null; + episodeSource: EpisodeSource; +} + +/** A show as the editor sends it (an existing show carries its id). */ +export interface TitleShowDraft { + id?: number; + name: string; + folderName?: string; + /** '' = main folder; omitted for the default TV folder */ + libraryFolder?: string; + excludeTerms?: string[]; + /** Season names by season number */ + seasonNames?: Record<string, string>; + patterns: TitlePatternDraft[]; +} + +export interface TitleShowCounts { + episodes: number; + downloaded: number; + duplicates: number; + unsupported: number; +} + +/** A stored title show from GET /api/channels/:channelId/tv/shows */ +export interface TitleShow { + id: number; + name: string; + folderName: string; + libraryFolder: string; + position: number; + retired: boolean; + excludeTerms: string[]; + seasonNames: Record<string, string>; + patterns: Array<TitlePatternDraft & { compiledRegex: string }>; + counts: TitleShowCounts | null; +} + +export interface TitleShowConflict { + youtubeId: string; + kind: 'duplicate' | 'classification_error'; + showId: number | null; + duplicateOf: string | null; + season: number | null; + episode: number | null; + message: string | null; + /** Youtarr ignored the video and suppressed it in the download archive */ + suppressed: boolean; + title: string | null; + downloaded: boolean; + /** The videos row, for a downloaded copy */ + videoId: number | null; + duplicateOfTitle: string | null; +} + +/** GET /api/channels/:channelId/tv/shows */ +export interface ChannelTitleShows { + shows: TitleShow[]; + conflicts: TitleShowConflict[]; + showOnlyDownloads: boolean; + tvFolders: string[]; + /** Where a new show goes when none is chosen; null when one must be chosen */ + defaultLibraryFolder: string | null; +} + +export type EpisodeDownloadState = 'downloaded' | 'queued' | 'not_downloaded'; + +export interface PreviewEpisode { + youtubeId: string; + title: string; + season: number | null; + episode: number | null; + code: string | null; + status: 'assigned' | 'pending_number'; + episodeTitle: string | null; + patternIndex: number; + downloadState: EpisodeDownloadState; +} + +export interface PreviewShow { + key: string; + id: number | null; + name: string; + folderName: string; + libraryFolder: string; + counts: { episodes: number; downloaded: number; pending: number; duplicates: number; unsupported: number }; + episodes: PreviewEpisode[]; + truncated: boolean; +} + +export interface PreviewDuplicate { + youtubeId: string; + title: string | null; + showKey: string; + showName: string | null; + season: number; + episode: number; + code: string | null; + duplicateOf: string; + duplicateOfTitle: string | null; + downloaded: boolean; +} + +export interface PreviewGap { + showKey: string; + season: number; + have: number; + highest: number; + missing: number[]; + truncated: boolean; +} + +export type UnsupportedReason = 'compilation' | 'part' | 'number-out-of-range' | 'missing-number'; + +export interface PreviewUnsupported { + youtubeId: string; + title: string | null; + showKey: string; + showName: string | null; + reason: UnsupportedReason; + season: number | null; + episode: number | null; + episodeEnd: number | null; + part: number | null; +} + +export interface PreviewEpisodeRef { + showKey: string; + showName: string | null; + code: string | null; + status: string; +} + +export interface PreviewChange { + youtubeId: string; + title: string; + downloaded: boolean; + from: PreviewEpisodeRef | null; + to: PreviewEpisodeRef | null; +} + +/** POST /api/channels/:channelId/tv/preview */ +export interface TitleShowPreview { + knownVideos: number; + shows: PreviewShow[]; + duplicates: PreviewDuplicate[]; + gaps: PreviewGap[]; + unsupported: PreviewUnsupported[]; + unmatched: { count: number; videos: Array<{ youtubeId: string; title: string; downloaded: boolean }> }; + changes: PreviewChange[]; + changeCount: number; + /** Downloads outside the downloads folder: their files stay where they are */ + staysOutside: number; + filesToMove: number; + retired: Array<{ key: string; name: string | null }>; + relocated: Array<{ key: string; name: string | null }>; + /** Each draft's patterns compiled to Python regexes, for regex mode */ + compiled: Array<{ key: string; patterns: string[] }>; +} + +/** A manual episode assignment, "Not an episode", or back to automatic. */ +export type EpisodeAssignment = + | { showId: number; season: number; episode: number } + | { notAnEpisode: true } + | { automatic: true }; + +/** GET /api/videos/:youtubeId/episode */ +export interface VideoEpisode { + channelId: string | null; + assignable: boolean; + classification: { + showId: number; + showName: string | null; + kind: 'channel' | 'title' | null; + status: string; + season: number | null; + episode: number | null; + code: string | null; + source: string | null; + notAnEpisode: boolean; + } | null; + shows: Array<{ id: number; name: string; seasonNames: Record<string, string> }>; +} + +export interface MissingSeason { + season: number; + name: string | null; + episodes: number; + downloaded: number; + notDownloaded: Array<{ youtubeId: string; title: string | null; episode: number; code: string }>; + gaps: number[]; + gapsTruncated: boolean; +} + +/** GET /api/channels/:channelId/tv/shows/:showId/missing */ +export interface MissingEpisodes { + showId: number; + name: string; + seasons: MissingSeason[]; +} + +/** The planned episode of a listing video not downloaded yet */ +export interface PlannedEpisode { + showName: string; + season: number; + episode: number; + code: string; +} diff --git a/client/src/utils/__tests__/seasonNumbers.test.ts b/client/src/utils/__tests__/seasonNumbers.test.ts new file mode 100644 index 000000000..78f0e6589 --- /dev/null +++ b/client/src/utils/__tests__/seasonNumbers.test.ts @@ -0,0 +1,19 @@ +import { isAssignableSeason, MAX_SEASON, MAX_YEAR_SEASON, MIN_YEAR_SEASON, SEASON_RANGE_TEXT } from '../seasonNumbers'; + +describe('seasonNumbers', () => { + test('accepts title seasons 0 to 199', () => { + expect([isAssignableSeason(0), isAssignableSeason(MAX_SEASON)]).toEqual([true, true]); + }); + + test('accepts upload years 1928 to 2500', () => { + expect([isAssignableSeason(MIN_YEAR_SEASON), isAssignableSeason(2024), isAssignableSeason(MAX_YEAR_SEASON)]).toEqual([true, true, true]); + }); + + test('refuses numbers between the ranges, outside them, and non-integers', () => { + expect([200, 1927, 2501, -1, 1.5, NaN, null].map(isAssignableSeason)).toEqual([false, false, false, false, false, false, false]); + }); + + test('describes both ranges', () => { + expect(SEASON_RANGE_TEXT).toBe('0-199, or a year 1928-2500'); + }); +}); diff --git a/client/src/utils/seasonNumbers.ts b/client/src/utils/seasonNumbers.ts new file mode 100644 index 000000000..2e38c2ee1 --- /dev/null +++ b/client/src/utils/seasonNumbers.ts @@ -0,0 +1,16 @@ +/** + * Season numbers a video can be assigned to or a season name given for, the + * server's rule (patternCompiler.isAssignableSeason): a title or fixed season + * (0-199) or an upload year (1928-2500; Jellyfin reads 200-1927 and anything + * above 2500 as no season). + */ + +export const MAX_SEASON = 199; +export const MIN_YEAR_SEASON = 1928; +export const MAX_YEAR_SEASON = 2500; +export const SEASON_RANGE_TEXT = `0-${MAX_SEASON}, or a year ${MIN_YEAR_SEASON}-${MAX_YEAR_SEASON}`; + +export function isAssignableSeason(season: number | null | undefined): boolean { + if (typeof season !== 'number' || !Number.isInteger(season)) return false; + return (season >= 0 && season <= MAX_SEASON) || (season >= MIN_YEAR_SEASON && season <= MAX_YEAR_SEASON); +} diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 736000443..06e9c0bec 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -22,7 +22,7 @@ Youtarr uses MariaDB/MySQL for storing: ### Database Tables | Table | Model | Description | | :----------------- | :------------- | :-------------------------------- | -| `channels` | `Channel` | YouTube channel information. `m3u_enabled` (boolean, default false): generate a `.m3u` playlist file in the channel folder. `m3u_sort_order` (string, default `oldest_first`): `.m3u` entry order, `oldest_first` or `newest_first`. `auto_removal_protected` (boolean, default false): exclude every video of this channel from auto-removal; only applies while the channel is subscribed (`enabled`), and enabling it clears `auto_removal_keep_recent_count`. `auto_removal_keep_recent_count` (int, nullable): auto-removal always keeps this many of the channel's most recently downloaded videos; mutually exclusive with `auto_removal_protected`, dormant while the channel is unsubscribed. `additional_tags` (text, nullable, default null): adds additional, custom tags to videos downloaded from the channel. `tab_video_counts` (text, nullable): JSON of YouTube's public video totals per tab, keyed by media type (`{"video":{"total":449,"fetchedAt":"..."}}`), used for the download percentage (refreshed daily with a YouTube API key, every three days or more in capped batches through yt-dlp without one). `tab_counts_attempted_at` (datetime, nullable): when a tab count lookup last started for the channel, successful or not; yt-dlp bulk refreshes take the oldest attempts first and skip channels attempted within three days, and opening a channel page waits an hour after an attempt. Non-unique index on `channel_id` (some installs carry duplicate rows). | +| `channels` | `Channel` | YouTube channel information. `tv_show_only_downloads` (boolean, default false): channel downloads fetch only videos that belong to one of the channel's title shows (ignored while it has none). `m3u_enabled` (boolean, default false): generate a `.m3u` playlist file in the channel folder. `m3u_sort_order` (string, default `oldest_first`): `.m3u` entry order, `oldest_first` or `newest_first`. `auto_removal_protected` (boolean, default false): exclude every video of this channel from auto-removal; only applies while the channel is subscribed (`enabled`), and enabling it clears `auto_removal_keep_recent_count`. `auto_removal_keep_recent_count` (int, nullable): auto-removal always keeps this many of the channel's most recently downloaded videos; mutually exclusive with `auto_removal_protected`, dormant while the channel is unsubscribed. `additional_tags` (text, nullable, default null): adds additional, custom tags to videos downloaded from the channel. `tab_video_counts` (text, nullable): JSON of YouTube's public video totals per tab, keyed by media type (`{"video":{"total":449,"fetchedAt":"..."}}`), used for the download percentage (refreshed daily with a YouTube API key, every three days or more in capped batches through yt-dlp without one). `tab_counts_attempted_at` (datetime, nullable): when a tab count lookup last started for the channel, successful or not; yt-dlp bulk refreshes take the oldest attempts first and skip channels attempted within three days, and opening a channel page waits an hour after an attempt. Non-unique index on `channel_id` (some installs carry duplicate rows). | | `videos` | `Video` | Downloaded video metadata. `video_resolution` (VARCHAR(20), nullable): actual pixel dimensions of the downloaded file, e.g. `"1920x1080"`, measured by ffprobe at download time and backfilled by the filesystem rescan; the displayed tier label (e.g. "1080p") is derived client-side (`client/src/utils/videoResolution.ts`), so labeling rules can change without re-probing; NULL = not yet checked or audio-only, `"0x0"` = probe failed (never shown in the UI). Index on `channel_id`. | | `channelvideos` | `ChannelVideo` | Channel <-> video associations. `published_at_source` tracks `published_at` provenance: `exact` (.info.json), `approximate` (yt-dlp flat-playlist date), `estimated` (ordering-only placeholder assigned when YouTube returns a listing with no dates; never displayed), NULL (legacy, treated as approximate). Non-unique indexes on `(channel_id, youtube_id)` and `youtube_id`. | | `jobs` | `Job` | Download job queue. `aux_data` (MEDIUMTEXT, nullable): JSON snapshot of job data other than videos (failed downloads, diagnoses, skip counts, terminated channels), written on job save and merged back on startup load; NULL for jobs recorded before the column existed. | @@ -34,11 +34,14 @@ Youtarr uses MariaDB/MySQL for storing: | `playlistvideos` | `PlaylistVideo` | One row per (playlist, video) with the YouTube playlist position. `first_seen_at`: immutable local discovery time, backfilled from row creation on upgrade. `downloaded_at`: recorded download/job time, independently updated. `auto_download_requested`: explicitly selected existing videos pending successful download, saved only with auto-download enabled and an established baseline. `auto_download_last_attempt_at` (DATETIME(3), nullable): scheduling time for an explicit saved batch or a selected scheduled retry, written before queue submission. Used to rotate older saved requests, independently of discovery and download dates; legacy requests with no known attempt remain null and join the bounded retry pool. First initialization preserves pending requests; successful downloads clear their own flags, and an explicit starting-point reset clears the remaining requests. Legacy `added_at` is retained for pre-upgrade timestamp cutoffs; it is no longer overwritten on download. | | `playlist_sync_state` | `PlaylistSyncState` | Per-(playlist, server) sync state: server playlist id, last_synced_at, last_error | | `subfolders` | `Subfolder` | Durable registry of known subfolder names (id, name unique, created_at, updated_at). Backfilled from channels, playlists, and video file paths by the `add-subfolders-table` migration; kept current by register-on-create and register-on-download-override. `layout` (STRING(10) NOT NULL, default `'videos'`): `'videos'` saves movie-style, `'tv'` saves channels as TV shows; the main folder's layout is the `mainFolderLayout` config field. | -| `tv_shows` | `TvShow` | TV shows Youtarr writes into TV-layout folders. `channel_id` (owner's YouTube channel id; not a foreign key, untracked channels get shows too), `kind` (`'channel'`: the whole channel as one show, created on the channel's switch to TV or its first download into a TV folder; `'title'`: reserved for title-pattern shows), `name`, `folder_name` and `library_folder` (`''` = main folder): the show's location, pinned at creation so uploader renames and default-folder changes never start a second show folder, `external_key` (written to `tvshow.nfo`; the channel id for channel shows), `previous_videos_folder` (the channel's `sub_folder` before it switched to TV, used when switching back), plus `position`, `exclude_terms`, `poster_source`, `retired_at` for title shows. Unique `(library_folder, folder_name)` (case-insensitive under `utf8mb4_unicode_ci`); a taken or invalid folder name gets the channel id appended. | -| `video_classifications` | `VideoClassification` | One row per video that belongs to a show (`youtube_id` primary key): `channel_id`, `show_id` (foreign key `video_classifications_show_id_fk` to `tv_shows`, RESTRICT), `status` (`assigned`; `pending_number`/`duplicate`/`unsupported`/`error` are reserved for title shows), `season`/`episode` (channel shows: UTC upload year and MMDDHHMM, bumped to the next free number on a collision), `source` (`date` for channel shows), `timestamp_source` (`timestamp` or `upload_date`), `pattern_id`, `episode_title`, `file_stem` (the on-disk name, reused on re-download), `title_opt_out`. Unique `(show_id, season, episode)`; rows without a number hold NULLs and never collide. Rows are kept when a video's files are deleted, so a re-download returns to the same number. | +| `tv_shows` | `TvShow` | TV shows Youtarr writes into TV-layout folders. `channel_id` (owner's YouTube channel id; not a foreign key, untracked channels get shows too), `kind` (`'channel'`: the whole channel as one show, created on the channel's switch to TV or its first download into a TV folder; `'title'`: a series built from title patterns over one channel's videos, see `tv_show_patterns`), `name`, `folder_name` and `library_folder` (`''` = main folder): the show's location, pinned at creation so uploader renames and default-folder changes never start a second show folder, `external_key` (written to `tvshow.nfo`; the channel id for channel shows, a random UUID for title shows), `previous_videos_folder` (the channel's `sub_folder` before it switched to TV, used when switching back), plus, for title shows, `position` (classification order: the first show whose pattern matches takes the video), `exclude_terms` (JSON list; a title containing one never matches), `poster_source` (unused so far) and `retired_at` (a removed show keeps its row and folder name so it can be restored). Unique `(library_folder, folder_name)` (case-insensitive under `utf8mb4_unicode_ci`); a taken or invalid channel show folder name gets the channel id appended, while a title show with a taken name is refused with a suggested one. | +| `video_classifications` | `VideoClassification` | One row per video that belongs to a show (`youtube_id` primary key): `channel_id`, `show_id` (foreign key `video_classifications_show_id_fk` to `tv_shows`, RESTRICT), `status` (`assigned`; for title shows also `pending_number` (matched, numbered from the upload time at download), `duplicate` (another upload holds the number, see `episode_conflicts`), `unsupported` (a compilation, a part, or a season or episode number out of range or missing from the title), `opted_out` (the user marked it "Not an episode"; `show_id` keeps the title show it left) and `error`), `season`/`episode` (channel shows: UTC upload year and MMDDHHMM, bumped to the next free number on a collision; title shows: from the title, the pattern's fixed season, the upload year, the upload date, or the next `order` number past the season's high-water mark), `source` (`date`, `title`, `order`, `manual` or `adopted` (a Plex TV Series preset code kept by a reorganize)), `timestamp_source` (`timestamp` or `upload_date`), `pattern_id` (the matching `tv_show_patterns` row; informational, no foreign key, since patterns are replaced on every save), `episode_title`, `file_stem` (the on-disk name, reused on re-download), `title_opt_out` (kept when an opted-out video of a TV channel becomes a channel show episode, so classification never puts it back in a title show). Unique `(show_id, season, episode)`; rows without a number hold NULLs and never collide. Rows are kept when a video's files are deleted, so a re-download returns to the same number. | | `tv_reorganize_operations` | `TvReorganizeOperation` | One row per reorganize: moving downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts. `change_type` (`channel`, `folderLayout`, `defaultSubfolder`), `scope`, `settings_change` (MEDIUMTEXT JSON: the approved change, its label, and the shows it pins), `settings_applied`, `revision` (the preview token it was started with), `status` (`running`, `completed`, `partial`, `failed`), `total_items`/`done_items`/`failed_items`, `error`, `started_at`/`finished_at`. A row left `running` by a restart is resumed at startup. | | `tv_reorganize_items` | `TvReorganizeItem` | One row per video of an operation (foreign key `tv_reorganize_items_operation_id_fk`, CASCADE): `youtube_id`, `video_id`, `channel_id` (owner), `title`, `files` (JSON: every file's source and destination, the old and new video and audio paths, the layouts), `classification` (JSON episode assignment, or NULL when the video becomes movie-style), `status` (`pending`, `done`, `failed`), `files_moved` (the files reached their destination; true for every done item and for a failed item whose metadata or row update failed after the move, which a retry finishes), `error`. Failed items, and pending ones of an operation that ended early, can be retried. | | `watch_status_holds` | `WatchStatusHold` | Watch state Youtarr protects while a reorganize moves a video: one row per (`video_id`, `server_type`, `server_user_id`) (unique) with a `snapshot` (JSON: played, play count, resume position, percent, last watched, and `fromPath`, the file path before the move), `operation_id`, `state` (`pending`, `restored`, `failed` after 14 days, `dismissed`), `attempts`, `last_attempt_at`, `last_error` (push-back attempts), `last_pushed_at` (the last attempt that actually wrote the state to the server, as opposed to finding it there) and `expires_at`. While a hold is pending or failed, watch status sync never downgrades that `video_watch_status` row. | +| `tv_show_patterns` | `TvShowPattern` | The title patterns of a title show, tried in `position` order (foreign key `tv_show_patterns_show_id_fk`, CASCADE): `pattern_text` and `pattern_kind` (`simple` or `regex`) as the user wrote them, `compiled_regex` (the Python regex classification runs, with named groups) and `filter_regex` (the same regex as a yt-dlp match filter: groups unnamed, flags scoped), `season_source` (`title`, `fixed` with `season_fixed`, or `year`) and `episode_source` (`title`, `date` or `order`). Replaced on every save of the show. | +| `tv_show_seasons` | `TvShowSeason` | Per title show and season (foreign key `tv_show_seasons_show_id_fk`, CASCADE; unique `(show_id, season)`): `name` (written as `<namedseason>` in `tvshow.nfo` and to the season folder's `season.nfo`) and `order_high_water` (the highest `order`-numbered episode ever given in the season; it never goes down, so an order number is never reissued). | +| `episode_conflicts` | `EpisodeConflict` | A video that lost its episode number to another upload of the same episode, or whose title could not be classified (`youtube_id` primary key): `channel_id` (owner), `show_id` (foreign key `episode_conflicts_show_id_fk`; NULL for a classification error), `kind` (`duplicate`, `classification_error`, or `released` while a release still has an archive write pending), `duplicate_of` (the video holding the number), `youtarr_ignored` (Youtarr set the video's ignore flag, so only then does Youtarr take it back), `archive_suppressed` (Youtarr wrote the video's `complete.list` line; false when the line was already there, such as a deleted download's, which a release never removes), `archive_pending` (`add` or `remove`: a `complete.list` write waiting until no download job is running), `details` (JSON for display). | | `video_watch_status` | `VideoWatchStatus` | Per-video, per-media-server, per-user watch state pulled by the watch status sync. Absence of a row means never synced/unknown, not unwatched. Columns: `video_id`, `server_type` (`plex`/`jellyfin`/`emby`), `server_user_id` (Plex owner is `'1'`), `played`, `play_count`, `position_ms`, `percent_watched`, `last_watched_at`, `last_synced_at`. Unique index on `(video_id, server_type, server_user_id)`. | | `media_server_users` | `MediaServerUser` | Media-server account directory populated during watch status sync: `server_type`, `server_user_id`, `server_user_name`. Unique index on `(server_type, server_user_id)`. Used to display which users watched a video. | | `watch_status_sync_cursors` | `WatchStatusSyncCursor` | Durable per-server watch-status sync cursor (unique `server_type`, `cursor` DATETIME). Today only Plex uses it: the newest play-history event scanned, so incremental pulls never permanently skip events. Deleting a row forces a full history re-scan on the next sync. | diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index e1ef1899f..bc089c71a 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -492,6 +492,30 @@ Click **Move N videos** to start. While the files move, a banner shows on every The same review opens when you change the default subfolder (Settings -> Core) to a folder with the other layout while channels on the default have downloads. Save again afterwards to apply your other changes. +### Title shows: series inside a channel + +Some channels upload real series: "Hermitcraft 10: Episode 5 - ...", "BEYBLADE EN Episode 20: ...". A **title show** turns the videos whose titles match a pattern into a show of their own, numbered the way the titles number them. The channel can stay a Videos channel: only the matching videos go to the show, and everything else downloads as before (or to the channel show, if the channel is a TV channel). + +Open the channel page, click the settings icon (gear), open **TV Show**, and under **Shows in this channel** click **Add show**: + +- **Name** and **Folder name** (the show folder; the name by default). A title show needs a TV folder: Youtarr uses the channel's TV folder, else the default subfolder if it is a TV folder, else your only TV folder, and asks when there is more than one. Two shows can't share a folder name in one TV folder; Youtarr suggests `<Name> (<Channel>)`, or offers to restore a removed show that used the name. +- **Title patterns**, tried in order. Text matches ignoring case and spaces match any spacing; `*` matches any text; a pattern matches anywhere in the title unless it starts with `^`. Placeholders capture the numbers and the episode title: `{season}`, `{episode}` and `{title}`. For example `Hermitcraft {season}: Episode {episode} - {title}`. **Edit as regular expression** shows the pattern as a Python regular expression with the named groups `season`, `episode` and `title`. +- **Season** and **Episode** per pattern: the season from the title, a fixed season (0 is specials), or the upload year; the episode from the title, the next number in the season (given once, oldest upload first, and never reused), or the upload time (with upload-year seasons only, like channel shows). A video not downloaded yet gets its upload-year season when it downloads; a downloaded video's year is already known, so the preview shows its number. +- **Exclude titles containing**: a title with any of these words never joins the show, for example `Official Clip`. +- **Season names**: written to the show's NFO files, so the media server shows "Season 2: V-Force" instead of "Season 2". Upload-year seasons (such as 2024) can be named too. + +The preview updates as you type: **Episodes** (with the episode each video would get, and whether it's downloaded), **Duplicates**, **Gaps** (numbers no video has), **Unmatched videos**, and **Not supported** (compilations such as `Ep.19 ... Ep.20` and parts such as `Episode 1 Part 2`, which Youtarr can't place yet, and titles missing a number the pattern needs). Downloads whose files are outside the downloads folder join a show but their files stay where they are. When several shows match a title, the first in the list takes the video; the arrows in the list reorder the shows. + +When saving would move downloaded videos (into the show, between shows, or out of one), the same **Review the move** dialog opens first. Changing a show that only affects videos you haven't downloaded saves right away, and a new show name or season name is written to the show's NFO files on save. **Remove** retires a show: its downloaded videos move back to the channel's layout. **Restore** (under **Removed shows**) brings it back. + +**Duplicates**: when two uploads claim the same episode (a re-upload, a remaster), the earliest upload still on YouTube keeps the number. A copy you haven't downloaded is ignored, so automatic downloads skip it. A copy you already downloaded is never deleted: it stays where it is, listed under **Duplicates and errors** with **Delete this copy**, **Use this copy instead** (it takes the number) and **Not a duplicate**. + +**Fix one video**: in the video's details (click its thumbnail), **Change episode...** in the **Episode** section lets you pick a show, season and episode, or mark the video **Not an episode** (it leaves the title shows for good, even after pattern edits). **Back to automatic** undoes either. + +**Only download videos that belong to a show**: with this switch on, the channel's automatic downloads and **Download All** fetch only videos that match one of its shows. It does nothing while the channel has no shows. + +On the channel page, a **N shows** chip appears in the header and on Subscriptions, and the video list gets a show filter (**All videos** or one show) with **Missing episodes**: per season, the episodes not downloaded yet and the numbers no video has. + ## Browse and Filter Channel Videos Explore all videos available from your subscribed channels, even if you haven't downloaded them yet. This feature uses yt-dlp to fetch channel information directly from YouTube - no API key required. diff --git a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md index 461ffe6b0..4f67e8a51 100644 --- a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md +++ b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md @@ -89,6 +89,26 @@ YouTube Downloads/ Switching a channel or a folder that already has downloads between Videos and TV shows moves its files into the other layout; you review the move first. See [Save Channels as TV Shows](USAGE_GUIDE.md#save-channels-as-tv-shows). +### Title shows + +A [title show](USAGE_GUIDE.md#title-shows-series-inside-a-channel) is a series inside a channel, numbered from its video titles. It is a show folder like any other in a TV folder, with two-digit numbers and, for named seasons, a `season.nfo`: + +``` +__TV Shows/ +├── Hermitcraft/ # A title show of the Grian channel +│ ├── tvshow.nfo # Show metadata, season names +│ ├── Season 10/ +│ │ ├── season.nfo # Only for a named season +│ │ └── S10E05 - Title [id].mp4 +│ └── Season 11/ +│ └── S11E01 - Title [id].mp4 +└── Grian/ # The channel show, if the channel is a TV channel + └── Season 2026/ + └── S2026E09281530 - Title [id].mp4 +``` + +Videos that match no title show stay where the channel puts them: its Videos folder, or the channel show for a TV channel. + ## Layout For Channels with Flat File Structure (No Video Subfolders) You can use a flat file structure, where video files are placed directly in the channel folder instead of individual video subfolders. It only affects new downloads. There are three places to set it: diff --git a/migrations/20261004184140-add-tv-title-shows.js b/migrations/20261004184140-add-tv-title-shows.js new file mode 100644 index 000000000..f9b0b68ab --- /dev/null +++ b/migrations/20261004184140-add-tv-title-shows.js @@ -0,0 +1,127 @@ +'use strict'; + +const { + createTableIfNotExists, + dropTableIfExists, + addColumnIfMissing, + removeColumnIfExists, + addIndexIfMissing, +} = require('./helpers'); + +const TABLE_OPTIONS = { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' }; +// Named explicitly: MariaDB 12.1+ names an unnamed constraint `1`. +const PATTERN_SHOW_FK_NAME = 'tv_show_patterns_show_id_fk'; +const SEASON_SHOW_FK_NAME = 'tv_show_seasons_show_id_fk'; +const CONFLICT_SHOW_FK_NAME = 'episode_conflicts_show_id_fk'; + +async function hasConstraint(queryInterface, tableName, constraintName) { + const [rows] = await queryInterface.sequelize.query( + `SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = :tableName AND CONSTRAINT_NAME = :constraintName`, + { replacements: { tableName, constraintName } } + ); + return rows.length > 0; +} + +async function addShowForeignKey(queryInterface, tableName, name, onDelete) { + if (await hasConstraint(queryInterface, tableName, name)) return; + await queryInterface.addConstraint(tableName, { + fields: ['show_id'], + type: 'foreign key', + name, + references: { table: 'tv_shows', field: 'id' }, + onUpdate: 'cascade', + onDelete, + }); +} + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Title patterns of a title show, tried in position order (the first + // matching show of a channel wins, then its first matching pattern). + await createTableIfNotExists(queryInterface, 'tv_show_patterns', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + show_id: { type: Sequelize.INTEGER, allowNull: false }, + position: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + // What the user wrote, in the simple syntax or as a regular expression. + pattern_text: { type: Sequelize.TEXT, allowNull: false }, + // simple | regex + pattern_kind: { type: Sequelize.STRING(8), allowNull: false }, + // The Python regex classification runs (named groups kept). + compiled_regex: { type: Sequelize.TEXT, allowNull: false }, + // The same regex as a yt-dlp match filter: groups unnamed, flags scoped. + filter_regex: { type: Sequelize.TEXT, allowNull: false }, + // title | fixed | year + season_source: { type: Sequelize.STRING(8), allowNull: false }, + season_fixed: { type: Sequelize.INTEGER, allowNull: true }, + // title | date | order + episode_source: { type: Sequelize.STRING(8), allowNull: false }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + await addShowForeignKey(queryInterface, 'tv_show_patterns', PATTERN_SHOW_FK_NAME, 'cascade'); + + // Per show and season: its name (<namedseason>, season.nfo) and the + // highest order-numbered episode ever given, which never goes down. + await createTableIfNotExists(queryInterface, 'tv_show_seasons', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + show_id: { type: Sequelize.INTEGER, allowNull: false }, + season: { type: Sequelize.INTEGER, allowNull: false }, + name: { type: Sequelize.STRING(255), allowNull: true }, + order_high_water: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + await addShowForeignKey(queryInterface, 'tv_show_seasons', SEASON_SHOW_FK_NAME, 'cascade'); + await addIndexIfMissing(queryInterface, 'tv_show_seasons', ['show_id', 'season'], { + name: 'tv_show_seasons_show_season_unique', + unique: true, + }); + + // A video that lost its episode number to another upload, or whose title + // could not be classified. + await createTableIfNotExists(queryInterface, 'episode_conflicts', { + youtube_id: { type: Sequelize.STRING(20), primaryKey: true, allowNull: false }, + // Owner channel's YouTube id. + channel_id: { type: Sequelize.STRING(64), allowNull: false }, + // Null for a classification error (no show could be decided). + show_id: { type: Sequelize.INTEGER, allowNull: true }, + // duplicate | classification_error | released + kind: { type: Sequelize.STRING(24), allowNull: false }, + // The video holding the number. + duplicate_of: { type: Sequelize.STRING(20), allowNull: true }, + // Youtarr set the video's ignore flag (so only Youtarr takes it back). + youtarr_ignored: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + // Youtarr wrote the video's complete.list line (it wasn't there before). + archive_suppressed: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + // A complete.list write waiting for the download queue to be idle: add | remove + archive_pending: { type: Sequelize.STRING(8), allowNull: true }, + // JSON: season, episode and other details for display. + details: { type: Sequelize.TEXT, allowNull: true }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, TABLE_OPTIONS); + await addShowForeignKey(queryInterface, 'episode_conflicts', CONFLICT_SHOW_FK_NAME, 'restrict'); + await addIndexIfMissing(queryInterface, 'episode_conflicts', ['channel_id'], { + name: 'episode_conflicts_channel_id_idx', + }); + await addIndexIfMissing(queryInterface, 'episode_conflicts', ['archive_pending'], { + name: 'episode_conflicts_archive_pending_idx', + }); + + // "Only download videos that belong to a title show". + await addColumnIfMissing(queryInterface, 'channels', 'tv_show_only_downloads', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + }, + + async down(queryInterface) { + await removeColumnIfExists(queryInterface, 'channels', 'tv_show_only_downloads'); + await dropTableIfExists(queryInterface, 'episode_conflicts'); + await dropTableIfExists(queryInterface, 'tv_show_seasons'); + await dropTableIfExists(queryInterface, 'tv_show_patterns'); + }, +}; diff --git a/server/__tests__/server.additional-routes.test.js b/server/__tests__/server.additional-routes.test.js index 8d1695f07..f082115e8 100644 --- a/server/__tests__/server.additional-routes.test.js +++ b/server/__tests__/server.additional-routes.test.js @@ -205,6 +205,7 @@ const createServerModule = ({ const jobModuleMock = { onJobAbandoned: jest.fn(), onJobEnded: jest.fn(), + onBeforeNextJob: jest.fn(), getJob: jest.fn(), getRunningJobs: jest.fn(() => []), getRunningJobsWithFreshVideos: jest.fn().mockResolvedValue([]), @@ -289,6 +290,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/tvShows/titleShowService', () => ({})); + jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.apikeys.test.js b/server/__tests__/server.apikeys.test.js index 9cb9491ba..03aba89e5 100644 --- a/server/__tests__/server.apikeys.test.js +++ b/server/__tests__/server.apikeys.test.js @@ -258,7 +258,8 @@ const createServerModule = ({ getRunningJobs: jest.fn(() => []), getRunningJobsWithFreshVideos: jest.fn().mockResolvedValue([]), onJobAbandoned: jest.fn(), - onJobEnded: jest.fn() + onJobEnded: jest.fn(), + onBeforeNextJob: jest.fn() })); jest.doMock('../modules/videosModule', () => ({})); jest.doMock('../modules/videoMetadataModule', () => ({ @@ -299,6 +300,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/tvShows/titleShowService', () => ({})); + jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.auth-sessions.test.js b/server/__tests__/server.auth-sessions.test.js index 4e1318b7f..df20d5e3a 100644 --- a/server/__tests__/server.auth-sessions.test.js +++ b/server/__tests__/server.auth-sessions.test.js @@ -216,7 +216,8 @@ const createServerModule = ({ getRunningJobs: jest.fn(() => []), getRunningJobsWithFreshVideos: jest.fn().mockResolvedValue([]), onJobAbandoned: jest.fn(), - onJobEnded: jest.fn() + onJobEnded: jest.fn(), + onBeforeNextJob: jest.fn() })); jest.doMock('../modules/videosModule', () => ({})); jest.doMock('../modules/videoMetadataModule', () => ({ @@ -257,6 +258,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/tvShows/titleShowService', () => ({})); + jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.core.test.js b/server/__tests__/server.core.test.js index cced5d86a..3b32b64ef 100644 --- a/server/__tests__/server.core.test.js +++ b/server/__tests__/server.core.test.js @@ -177,6 +177,7 @@ const createServerModule = ({ const jobModuleMock = { onJobAbandoned: jest.fn(), onJobEnded: jest.fn(), + onBeforeNextJob: jest.fn(), getJob: jest.fn(), getRunningJobs: jest.fn(() => []), getRunningJobsWithFreshVideos: jest.fn().mockResolvedValue([]), @@ -279,6 +280,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/tvShows/titleShowService', () => ({})); + jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.plex-routes.test.js b/server/__tests__/server.plex-routes.test.js index 5e371d804..d889556e9 100644 --- a/server/__tests__/server.plex-routes.test.js +++ b/server/__tests__/server.plex-routes.test.js @@ -136,7 +136,8 @@ const setupServer = async ({ authEnabled = 'false', passwordHash = null } = {}) getRunningJobs: jest.fn(() => []), getRunningJobsWithFreshVideos: jest.fn().mockResolvedValue([]), onJobAbandoned: jest.fn(), - onJobEnded: jest.fn() + onJobEnded: jest.fn(), + onBeforeNextJob: jest.fn() })); jest.doMock('../modules/videosModule', () => ({ getVideos: jest.fn().mockResolvedValue([]) @@ -175,6 +176,8 @@ const setupServer = async ({ authEnabled = 'false', passwordHash = null } = {}) })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/tvShows/titleShowService', () => ({})); + jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 888904f56..218bd4e70 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -222,6 +222,7 @@ const createServerModule = ({ const jobModuleMock = { onJobAbandoned: jest.fn(), onJobEnded: jest.fn(), + onBeforeNextJob: jest.fn(), getJob: jest.fn((jobId) => { if (jobId === 'existing-job') { return { id: jobId, status: 'In Progress' }; @@ -441,6 +442,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/tvShows/titleShowService', () => ({})); + jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ getChannelTvState: jest.fn(), resolveLayoutTarget: jest.fn(), @@ -1088,7 +1091,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'off', // default watchedFilter - null // default maxRating + null, // default maxRating + null // default showId ); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ @@ -1158,7 +1162,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'only', // watchedFilter - null // default maxRating + null, // default maxRating + null // default showId ); expect(res.statusCode).toBe(200); }); @@ -1202,11 +1207,42 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'off', // default watchedFilter - null // default maxRating + null, // default maxRating + null // default showId ); expect(res.statusCode).toBe(200); }); + test('passes a show filter to channel module', async () => { + const { app, channelModuleMock } = await createServerModule(); + + const handlers = findRouteHandlers(app, 'get', '/getchannelvideos/:channelId'); + const getVideosHandler = handlers[handlers.length - 1]; + + const req = createMockRequest({ params: { channelId: 'channel-1' }, query: { showId: '7' } }); + const res = createMockResponse(); + + await getVideosHandler(req, res); + + const args = channelModuleMock.getChannelVideos.mock.calls[0]; + expect(args[17]).toBe(7); + }); + + test('rejects a show filter that is not a show id with 400', async () => { + const { app, channelModuleMock } = await createServerModule(); + + const handlers = findRouteHandlers(app, 'get', '/getchannelvideos/:channelId'); + const getVideosHandler = handlers[handlers.length - 1]; + + const req = createMockRequest({ params: { channelId: 'channel-1' }, query: { showId: 'abc' } }); + const res = createMockResponse(); + + await getVideosHandler(req, res); + + expect(res.statusCode).toBe(400); + expect(channelModuleMock.getChannelVideos).not.toHaveBeenCalled(); + }); + test('passes maxRating to channel module', async () => { const { app, channelModuleMock } = await createServerModule(); @@ -1222,7 +1258,7 @@ describe('server routes - channels', () => { await getVideosHandler(req, res); const args = channelModuleMock.getChannelVideos.mock.calls[0]; - expect(args[args.length - 1]).toBe('TV-14'); + expect(args[16]).toBe('TV-14'); }); test('rejects an unknown maxRating with 400', async () => { diff --git a/server/models/channel.js b/server/models/channel.js index 59057d0c5..a50584643 100644 --- a/server/models/channel.js +++ b/server/models/channel.js @@ -124,6 +124,12 @@ Channel.init( defaultValue: false, comment: 'When true, no videos from this channel are ever auto-removed', }, + tv_show_only_downloads: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'When true, channel downloads only fetch videos that belong to one of its title shows', + }, auto_removal_keep_recent_count: { type: DataTypes.INTEGER, allowNull: true, diff --git a/server/models/episodeconflict.js b/server/models/episodeconflict.js new file mode 100644 index 000000000..f1aff219f --- /dev/null +++ b/server/models/episodeconflict.js @@ -0,0 +1,31 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class EpisodeConflict extends Model {} + +EpisodeConflict.init( + { + youtube_id: { type: DataTypes.STRING(20), primaryKey: true, allowNull: false }, + channel_id: { type: DataTypes.STRING(64), allowNull: false }, + show_id: { type: DataTypes.INTEGER, allowNull: true }, + // duplicate | classification_error | released + kind: { type: DataTypes.STRING(24), allowNull: false }, + duplicate_of: { type: DataTypes.STRING(20), allowNull: true }, + // Youtarr set the video's ignore flag (so only Youtarr takes it back). + youtarr_ignored: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + // Youtarr wrote the video's complete.list line (it wasn't there before). + archive_suppressed: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + // add | remove: a complete.list write waiting for the download queue to be idle + archive_pending: { type: DataTypes.STRING(8), allowNull: true }, + details: { type: DataTypes.TEXT, allowNull: true }, + }, + { + sequelize, + modelName: 'EpisodeConflict', + tableName: 'episode_conflicts', + timestamps: true, + underscored: true, + } +); + +module.exports = EpisodeConflict; diff --git a/server/models/index.js b/server/models/index.js index 5d0972b58..8e954e73b 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -19,6 +19,9 @@ const VideoClassification = require('./videoclassification'); const TvReorganizeOperation = require('./tvreorganizeoperation'); const TvReorganizeItem = require('./tvreorganizeitem'); const WatchStatusHold = require('./watchstatushold'); +const TvShowPattern = require('./tvshowpattern'); +const TvShowSeason = require('./tvshowseason'); +const EpisodeConflict = require('./episodeconflict'); Job.hasMany(JobVideo, { foreignKey: 'job_id', as: 'jobVideos' }); Job.hasMany(JobVideoDownload, { foreignKey: 'job_id', as: 'jobVideoDownloads' }); @@ -41,6 +44,10 @@ VideoWatchStatus.belongsTo(Video, { foreignKey: 'video_id', as: 'video' }); TvShow.hasMany(VideoClassification, { foreignKey: 'show_id', as: 'classifications' }); VideoClassification.belongsTo(TvShow, { foreignKey: 'show_id', as: 'show' }); +TvShow.hasMany(TvShowPattern, { foreignKey: 'show_id', as: 'patterns' }); +TvShowPattern.belongsTo(TvShow, { foreignKey: 'show_id', as: 'show' }); +TvShow.hasMany(TvShowSeason, { foreignKey: 'show_id', as: 'seasons' }); +TvShowSeason.belongsTo(TvShow, { foreignKey: 'show_id', as: 'show' }); TvReorganizeOperation.hasMany(TvReorganizeItem, { foreignKey: 'operation_id', as: 'items' }); TvReorganizeItem.belongsTo(TvReorganizeOperation, { foreignKey: 'operation_id', as: 'operation' }); @@ -66,4 +73,7 @@ module.exports = { TvReorganizeOperation, TvReorganizeItem, WatchStatusHold, + TvShowPattern, + TvShowSeason, + EpisodeConflict, }; diff --git a/server/models/tvshowpattern.js b/server/models/tvshowpattern.js new file mode 100644 index 000000000..f78e78496 --- /dev/null +++ b/server/models/tvshowpattern.js @@ -0,0 +1,31 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class TvShowPattern extends Model {} + +TvShowPattern.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + show_id: { type: DataTypes.INTEGER, allowNull: false }, + position: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + pattern_text: { type: DataTypes.TEXT, allowNull: false }, + // simple | regex + pattern_kind: { type: DataTypes.STRING(8), allowNull: false }, + compiled_regex: { type: DataTypes.TEXT, allowNull: false }, + filter_regex: { type: DataTypes.TEXT, allowNull: false }, + // title | fixed | year + season_source: { type: DataTypes.STRING(8), allowNull: false }, + season_fixed: { type: DataTypes.INTEGER, allowNull: true }, + // title | date | order + episode_source: { type: DataTypes.STRING(8), allowNull: false }, + }, + { + sequelize, + modelName: 'TvShowPattern', + tableName: 'tv_show_patterns', + timestamps: true, + underscored: true, + } +); + +module.exports = TvShowPattern; diff --git a/server/models/tvshowseason.js b/server/models/tvshowseason.js new file mode 100644 index 000000000..30e74b2f3 --- /dev/null +++ b/server/models/tvshowseason.js @@ -0,0 +1,24 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class TvShowSeason extends Model {} + +TvShowSeason.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + show_id: { type: DataTypes.INTEGER, allowNull: false }, + season: { type: DataTypes.INTEGER, allowNull: false }, + name: { type: DataTypes.STRING(255), allowNull: true }, + // Highest order-numbered episode ever given in the season; never goes down. + order_high_water: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + }, + { + sequelize, + modelName: 'TvShowSeason', + tableName: 'tv_show_seasons', + timestamps: true, + underscored: true, + } +); + +module.exports = TvShowSeason; diff --git a/server/modules/__tests__/channelDownloadAllModule.test.js b/server/modules/__tests__/channelDownloadAllModule.test.js index 19ef4a140..d1c7806af 100644 --- a/server/modules/__tests__/channelDownloadAllModule.test.js +++ b/server/modules/__tests__/channelDownloadAllModule.test.js @@ -4,6 +4,7 @@ jest.mock('../../models', () => ({ Channel: { findOne: jest.fn() }, })); jest.mock('../downloadModule', () => ({ doSpecificDownloads: jest.fn() })); +jest.mock('../tvShows/titleShowQueries', () => ({ showEpisodeIds: jest.fn() })); jest.mock('../../logger', () => ({ info: jest.fn(), debug: jest.fn(), @@ -104,6 +105,39 @@ describe('getDownloadableVideos', () => { }); }); +describe('channels downloading only their title shows', () => { + const titleShowQueries = require('../tvShows/titleShowQueries'); + const showOnly = { ...channelRow, tv_show_only_downloads: true }; + + beforeEach(() => { + ChannelVideo.findAll.mockResolvedValue([cv('ep1'), cv('loose')]); + }); + + it('keeps only videos classified into a title show', async () => { + titleShowQueries.showEpisodeIds.mockResolvedValue(new Set(['ep1'])); + const videos = await channelDownloadAllModule.getDownloadableVideos(CHANNEL_ID, 'videos', showOnly); + expect(videos.map((video) => video.youtube_id)).toEqual(['ep1']); + }); + + it('keeps every video while the channel has no title show', async () => { + titleShowQueries.showEpisodeIds.mockResolvedValue(null); + const videos = await channelDownloadAllModule.getDownloadableVideos(CHANNEL_ID, 'videos', showOnly); + expect(videos).toHaveLength(2); + }); + + it('keeps every video of a channel with the switch off', async () => { + const videos = await channelDownloadAllModule.getDownloadableVideos(CHANNEL_ID, 'videos', channelRow); + expect([videos.length, titleShowQueries.showEpisodeIds]).toEqual([2, expect.any(Function)]); + expect(titleShowQueries.showEpisodeIds).not.toHaveBeenCalled(); + }); + + it('previews only the title show episodes', async () => { + Channel.findOne.mockResolvedValue(showOnly); + titleShowQueries.showEpisodeIds.mockResolvedValue(new Set(['ep1'])); + expect((await channelDownloadAllModule.getPreview(CHANNEL_ID, 'videos')).count).toBe(1); + }); +}); + describe('getPreview', () => { it('returns count, total duration, and missing-duration count', async () => { ChannelVideo.findAll.mockResolvedValue([ diff --git a/server/modules/__tests__/channelDownloadGrouper.test.js b/server/modules/__tests__/channelDownloadGrouper.test.js index 0d414b6a7..da27ccf14 100644 --- a/server/modules/__tests__/channelDownloadGrouper.test.js +++ b/server/modules/__tests__/channelDownloadGrouper.test.js @@ -18,7 +18,10 @@ jest.mock('../configModule', () => ({ getDefaultSubfolder: jest.fn().mockReturnValue(null) })); +jest.mock('../tvShows/titleShowStore', () => ({ showFiltersByChannel: jest.fn().mockResolvedValue(new Map()) })); + const channelDownloadGrouper = require('../channelDownloadGrouper'); +const titleShowStore = require('../tvShows/titleShowStore'); const Channel = require('../../models/channel'); const configModule = require('../configModule'); const path = require('path'); @@ -166,6 +169,35 @@ describe('ChannelDownloadGrouper', () => { }); }); + describe('title show filters', () => { + const SHOWS = [{ filterRegex: '(?i:a)', excludeRegexes: [] }]; + + it('counts title show filters as grouping criteria', () => { + expect(new ChannelFilterConfig(null, null, null, null, false, SHOWS).hasGroupingCriteria()).toBe(true); + }); + + it('tells channels with different show filters apart', () => { + const other = [{ filterRegex: '(?i:b)', excludeRegexes: [] }]; + expect(new ChannelFilterConfig(null, null, null, null, false, SHOWS).buildFilterKey()) + .not.toBe(new ChannelFilterConfig(null, null, null, null, false, other).buildFilterKey()); + }); + + it('applies show filters to a channel downloading only its title shows', () => { + const filterConfig = ChannelFilterConfig.fromChannel({ tv_show_only_downloads: true }, {}, SHOWS); + expect(filterConfig.showFilters).toEqual(SHOWS); + }); + + it('ignores show filters of a channel downloading every video', () => { + const filterConfig = ChannelFilterConfig.fromChannel({ tv_show_only_downloads: false }, {}, SHOWS); + expect(filterConfig.showFilters).toBeNull(); + }); + + it('ignores the switch while the channel has no title shows', () => { + const filterConfig = ChannelFilterConfig.fromChannel({ tv_show_only_downloads: true }, {}, []); + expect(filterConfig.hasGroupingCriteria()).toBe(false); + }); + }); + describe('fromChannel with global flat default', () => { const baseChannel = { min_duration: null, @@ -240,7 +272,8 @@ describe('ChannelDownloadGrouper', () => { 'max_duration', 'title_filter_regex', 'audio_format', - 'skip_video_folder' + 'skip_video_folder', + 'tv_show_only_downloads' ] }); expect(result).toEqual(mockChannels); @@ -737,6 +770,26 @@ describe('ChannelDownloadGrouper', () => { }); describe('generateDownloadGroups', () => { + it('loads the show filters of channels downloading only their title shows', async () => { + Channel.findAll.mockResolvedValue([ + { channel_id: 'shows', sub_folder: null, tv_show_only_downloads: true }, + { channel_id: 'all', sub_folder: null, tv_show_only_downloads: false }, + ]); + await channelDownloadGrouper.generateDownloadGroups(); + expect(titleShowStore.showFiltersByChannel).toHaveBeenCalledWith(['shows']); + }); + + it('downloads a channel limited to its title shows in its own group', async () => { + Channel.findAll.mockResolvedValue([ + { channel_id: 'shows', sub_folder: null, tv_show_only_downloads: true }, + { channel_id: 'all', sub_folder: null, tv_show_only_downloads: false }, + ]); + titleShowStore.showFiltersByChannel.mockResolvedValueOnce(new Map([['shows', [{ filterRegex: '(?i:a)', excludeRegexes: [] }]]])); + const groups = await channelDownloadGrouper.generateDownloadGroups(); + expect(groups.map((group) => [group.channels.map((channel) => channel.channel_id), group.filterConfig.showFilters])) + .toEqual([[['shows'], [{ filterRegex: '(?i:a)', excludeRegexes: [] }]], [['all'], null]]); + }); + it('should generate groups with output paths', async () => { const mockChannels = [ { diff --git a/server/modules/__tests__/channelModule.test.js b/server/modules/__tests__/channelModule.test.js index 93cc668aa..88cad7d12 100644 --- a/server/modules/__tests__/channelModule.test.js +++ b/server/modules/__tests__/channelModule.test.js @@ -58,6 +58,12 @@ jest.mock('../ytDlpRunner', () => ({ const flushConstructorTasks = () => new Promise(setImmediate); jest.mock('../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); +jest.mock('../tvShows/titleShowSaver', () => ({ classifyNew: jest.fn() })); +jest.mock('../tvShows/titleShowQueries', () => ({ + countActiveByChannel: jest.fn().mockResolvedValue(new Map()), + plannedEpisodes: jest.fn().mockResolvedValue(new Map()), + youtubeIdsForShow: jest.fn().mockResolvedValue(new Set()), +})); describe('channelModule facade', () => { describe('constructor', () => { @@ -92,7 +98,7 @@ describe('channelModule facade', () => { ['getChannelsPaginated', '../channel/channelCatalog', 'getChannelsPaginated', [{ page: 2, pageSize: 10 }], 'async'], ['writeChannels', '../channel/channelCatalog', 'writeChannels', [['https://www.youtube.com/@a']], 'async'], ['updateChannelsByDelta', '../channel/channelCatalog', 'updateChannelsByDelta', [{ enableUrls: ['https://www.youtube.com/@a'], disableUrls: [] }], 'async'], - ['getChannelVideos', '../channel/channelVideosService', 'getChannelVideos', ['UC1', 2, 25, 'only', 'q', 'title', 'asc', 'shorts', 60, 600, '2026-01-01', '2026-02-01', 'only', 'exclude', 'off', 'only', 'PG'], 'async'], + ['getChannelVideos', '../channel/channelVideosService', 'getChannelVideos', ['UC1', 2, 25, 'only', 'q', 'title', 'asc', 'shorts', 60, 600, '2026-01-01', '2026-02-01', 'only', 'exclude', 'off', 'only', 'PG', 7], 'async'], ['fetchAllChannelVideos', '../channel/channelVideosService', 'fetchAllChannelVideos', ['UC1', 2, 25, 'only', 'shorts'], 'async'], ['getChannelAvailableTabs', '../channel/tabManager', 'getChannelAvailableTabs', ['UC1'], 'async'], ['updateAutoDownloadForTab', '../channel/tabManager', 'updateAutoDownloadForTab', ['UC1', 'shorts', true], 'async'], diff --git a/server/modules/__tests__/downloadModule.test.js b/server/modules/__tests__/downloadModule.test.js index ee01762c0..d6514fe99 100644 --- a/server/modules/__tests__/downloadModule.test.js +++ b/server/modules/__tests__/downloadModule.test.js @@ -41,6 +41,8 @@ jest.mock('../../models/channelvideo', () => ({ findAll: jest.fn() })); jest.mock('../videoValidationModule', () => ({ getCachedChannelId: jest.fn(() => null) })); +jest.mock('../archiveModule', () => ({ removeVideoFromArchive: jest.fn().mockResolvedValue(true) })); +jest.mock('../tvShows/episodeConflicts', () => ({ noteArchiveLinesRemoved: jest.fn().mockResolvedValue() })); jest.mock('../channelDownloadGrouper', () => ({ generateDownloadGroups: jest.fn() })); @@ -1674,6 +1676,16 @@ describe('DownloadModule', () => { jobModuleMock.addOrUpdateJob.mockResolvedValue(mockJobId); }); + it('tells title shows which ignored videos lost their archive line', async () => { + jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); + require('../../models/channelvideo').findAll.mockResolvedValueOnce([{ youtube_id: 'abc12345678' }]); + const episodeConflicts = require('../tvShows/episodeConflicts'); + + await downloadModule.doSpecificDownloads({ body: { urls: ['https://youtube.com/watch?v=abc12345678'] } }); + + expect(episodeConflicts.noteArchiveLinesRemoved).toHaveBeenCalledWith(['abc12345678']); + }); + it('should handle request object with body', async () => { jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); const request = { diff --git a/server/modules/__tests__/jobModule.test.js b/server/modules/__tests__/jobModule.test.js index 05ac38b2c..2efb9bded 100644 --- a/server/modules/__tests__/jobModule.test.js +++ b/server/modules/__tests__/jobModule.test.js @@ -854,6 +854,26 @@ describe('JobModule', () => { }); + test('runs the before-next-job listeners before starting the next job', async () => { + const order = []; + JobModule.onBeforeNextJob(async () => { order.push('listener'); }); + JobModule.jobs = { 'job-1': { status: 'Pending', action: () => { order.push('job'); } } }; + + await JobModule.startNextJob(); + + expect(order).toEqual(['listener', 'job']); + }); + + test('starts the next job when a before-next-job listener fails', async () => { + const mockAction = jest.fn(); + JobModule.onBeforeNextJob(async () => { throw new Error('archive busy'); }); + JobModule.jobs = { 'job-1': { status: 'Pending', action: mockAction } }; + + await JobModule.startNextJob(); + + expect(mockAction).toHaveBeenCalled(); + }); + test('should do nothing if no pending jobs', async () => { JobModule.jobs = { 'job-1': { status: 'Complete' }, diff --git a/server/modules/__tests__/sidecarWriter.test.js b/server/modules/__tests__/sidecarWriter.test.js index a3f434d1d..0b5c5fa7a 100644 --- a/server/modules/__tests__/sidecarWriter.test.js +++ b/server/modules/__tests__/sidecarWriter.test.js @@ -1,6 +1,6 @@ jest.mock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); jest.mock('../configModule', () => ({ getConfig: jest.fn(), getImagePath: jest.fn() })); -jest.mock('../tvShows/episodePlacement', () => ({ earliestEpisodeDate: jest.fn() })); +jest.mock('../tvShows/episodePlacement', () => ({ earliestEpisodeDate: jest.fn(), seasonNamesOf: jest.fn().mockResolvedValue({}) })); const fs = require('fs'); const os = require('os'); @@ -139,5 +139,23 @@ describe('sidecarWriter', () => { expect(nfo).toContain('<uniqueid type="custom">UC1</uniqueid>'); expect(fs.existsSync(path.join(showDir, 'poster.jpg'))).toBe(true); }); + + it('writes a title show\'s tvshow.nfo with its Youtarr id, season names and season.nfo files', async () => { + const showDir = path.join(root, 'Beyblade'); + fs.mkdirSync(path.join(showDir, 'Season 02'), { recursive: true }); + require('../tvShows/episodePlacement').seasonNamesOf.mockResolvedValue({ 2: 'V-Force' }); + + await sidecarWriter.writeShowMetadata({ + show: { id: 4, kind: 'title', name: 'Beyblade', channel_id: 'UC1', external_key: 'uuid-4' }, + showDir, + plot: 'Channel description', + }); + + const nfo = fs.readFileSync(path.join(showDir, 'tvshow.nfo'), 'utf8'); + expect(nfo).toContain('<uniqueid type="youtarr" default="true">uuid-4</uniqueid>'); + expect(nfo).toContain('<namedseason number="2">V-Force</namedseason>'); + expect(nfo).not.toContain('Channel description'); + expect(fs.readFileSync(path.join(showDir, 'Season 02', 'season.nfo'), 'utf8')).toContain('<title>V-Force'); + }); }); }); diff --git a/server/modules/__tests__/titleFilterRegex.test.js b/server/modules/__tests__/titleFilterRegex.test.js index 9c1112e23..29d395761 100644 --- a/server/modules/__tests__/titleFilterRegex.test.js +++ b/server/modules/__tests__/titleFilterRegex.test.js @@ -49,4 +49,84 @@ describe('titleFilterRegex', () => { await expect(titleFilterRegex.matchTitles('[unclosed', ['x'])).rejects.toThrow(/Invalid regex pattern/); }); }); + + describe('checkPatterns', () => { + test('reports null for each pattern that compiles', async () => { + await expect(titleFilterRegex.checkPatterns(['(?i)Ep(?P[0-9]+)', '(?i:Ep(?:[0-9]+))'])) + .resolves.toEqual([null, null]); + }); + + test('reports the compile error of a pattern that does not', async () => { + const [error] = await titleFilterRegex.checkPatterns(['[unclosed']); + expect(error).toMatch(/unterminated character set/); + }); + + test('resolves an empty list without spawning Python', async () => { + await expect(titleFilterRegex.checkPatterns([])).resolves.toEqual([]); + }); + }); + + describe('classifyTitles', () => { + const patterns = [ + { regex: '(?i)Episode (?P[0-9]+): (?P.+)$', excludes: ['(?i:official clip)'] }, + { regex: '(?i)(?P<title>.+?) \\| Official Clip' }, + ]; + + test('returns the first matching pattern with its named groups', async () => { + const [result] = await titleFilterRegex.classifyTitles(patterns, ['Episode 20: It\'s All Relative']); + expect(result).toEqual({ index: 0, groups: { episode: '20', title: 'It\'s All Relative' } }); + }); + + test('passes a title an exclude term rejects on to the next pattern', async () => { + const [result] = await titleFilterRegex.classifyTitles(patterns, ['Episode 1: Bel battles Quadra | Official Clip']); + expect(result.index).toBe(1); + }); + + test('returns null for a title no pattern matches', async () => { + await expect(titleFilterRegex.classifyTitles(patterns, ['Behind the scenes'])).resolves.toEqual([null]); + }); + + test('returns null for a group that did not take part in the match', async () => { + const [result] = await titleFilterRegex.classifyTitles( + [{ regex: 'Ep(?P<episode>[0-9]+)(?: Part (?P<part>[0-9]+))?' }], + ['Ep4'] + ); + expect(result.groups).toEqual({ episode: '4', part: null }); + }); + + test('does not read non-ASCII digits as numbers', async () => { + await expect(titleFilterRegex.classifyTitles(patterns, ['Episode 20: Title'])).resolves.toEqual([null]); + }); + + test('rejects when a pattern does not compile', async () => { + await expect(titleFilterRegex.classifyTitles([{ regex: '[unclosed' }], ['x'])).rejects.toThrow(/Invalid regex pattern/); + }); + + test('resolves an empty title list without spawning Python', async () => { + await expect(titleFilterRegex.classifyTitles(patterns, [])).resolves.toEqual([]); + }); + }); +}); + +describe('titleFilterRegex timeouts', () => { + afterEach(() => { + jest.useRealTimers(); + jest.dontMock('child_process'); + }); + + it('refuses a pattern that runs too long as a bad request, with a readable message', async () => { + jest.useFakeTimers(); + const { EventEmitter } = require('events'); + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), stderr: new EventEmitter(), stdin: { end: jest.fn(), on: jest.fn() }, kill: jest.fn(), + }); + let slow; + jest.isolateModules(() => { + jest.doMock('child_process', () => ({ spawn: jest.fn(() => child), execFileSync: jest.fn() })); + slow = require('../titleFilterRegex'); + }); + const result = slow.classifyTitles([{ regex: '(a+)+$' }], ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!']); + jest.advanceTimersByTime(15000); + await expect(result).rejects.toMatchObject({ status: 400, message: expect.stringMatching(/took too long/) }); + }); }); diff --git a/server/modules/channel/__tests__/channelCatalog.test.js b/server/modules/channel/__tests__/channelCatalog.test.js index e5e5c568f..da35a331e 100644 --- a/server/modules/channel/__tests__/channelCatalog.test.js +++ b/server/modules/channel/__tests__/channelCatalog.test.js @@ -18,6 +18,7 @@ jest.mock('../tabDownloadStats', () => ({ getForChannels: jest.fn().mockResolved jest.mock('../../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); jest.mock('../../tvShows/libraryLayouts', () => ({ getLayoutResolver: jest.fn().mockResolvedValue(() => 'videos'), listTvFolders: jest.fn().mockResolvedValue([]) })); jest.mock('../../tvShows/showStore', () => ({ findChannelShow: jest.fn().mockResolvedValue(null) })); +jest.mock('../../tvShows/titleShowQueries', () => ({ countActiveByChannel: jest.fn().mockResolvedValue(new Map()) })); describe('channelCatalog', () => { let channelCatalog; @@ -222,6 +223,27 @@ describe('channelCatalog', () => { expect(result.channels.map((c) => c.layout)).toEqual(['tv', 'videos']); }); + test('counts the title shows of channels that have some', async () => { + require('../../tvShows/titleShowQueries').countActiveByChannel.mockResolvedValueOnce(new Map([['UC1', 3]])); + Channel.findAndCountAll.mockResolvedValueOnce({ + rows: [{ channel_id: 'UC1', sub_folder: 'TV' }, { channel_id: 'UC2', sub_folder: 'Kids' }], + count: 2, + }); + + const result = await channelCatalog.getChannelsPaginated(); + + expect(result.channels.map((c) => c.titleShows)).toEqual([3, undefined]); + }); + + test('still lists channels when the title shows can\'t be counted', async () => { + require('../../tvShows/titleShowQueries').countActiveByChannel.mockRejectedValueOnce(new Error('db')); + Channel.findAndCountAll.mockResolvedValueOnce({ rows: [{ channel_id: 'UC1', sub_folder: 'TV' }], count: 1 }); + + const result = await channelCatalog.getChannelsPaginated(); + + expect(result.channels).toHaveLength(1); + }); + test('filters to channels in TV folders', async () => { const libraryLayouts = require('../../tvShows/libraryLayouts'); libraryLayouts.listTvFolders.mockResolvedValueOnce(['TV']); diff --git a/server/modules/channel/__tests__/channelVideoFetcher.test.js b/server/modules/channel/__tests__/channelVideoFetcher.test.js index 401e60468..644666f17 100644 --- a/server/modules/channel/__tests__/channelVideoFetcher.test.js +++ b/server/modules/channel/__tests__/channelVideoFetcher.test.js @@ -8,6 +8,7 @@ jest.mock('../../../models/channel', () => mockFactories.mockChannelModel()); jest.mock('../../../models/channelvideo', () => mockFactories.mockChannelVideoModel()); jest.mock('../../configModule', () => mockFactories.mockConfigModule()); jest.mock('../../../db', () => mockFactories.mockDb()); +jest.mock('../../tvShows/titleShowSaver', () => ({ classifyNew: jest.fn() })); jest.mock('../../youtubeApi', () => mockFactories.mockYoutubeApi()); describe('channelVideoFetcher', () => { diff --git a/server/modules/channel/__tests__/channelVideoQuery.test.js b/server/modules/channel/__tests__/channelVideoQuery.test.js index 7c535ebab..729877ec2 100644 --- a/server/modules/channel/__tests__/channelVideoQuery.test.js +++ b/server/modules/channel/__tests__/channelVideoQuery.test.js @@ -10,6 +10,7 @@ jest.mock('../../../db', () => mockFactories.mockDb()); jest.mock('../../fileCheckModule', () => mockFactories.mockFileCheckModule()); jest.mock('../../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); +jest.mock('../../tvShows/titleShowQueries', () => ({ plannedEpisodes: jest.fn().mockResolvedValue(new Map()) })); describe('channelVideoQuery', () => { let channelVideoQuery; @@ -28,6 +29,32 @@ describe('channelVideoQuery', () => { channelVideoQuery = require('../channelVideoQuery'); }); + describe('title show episodes in the listing', () => { + const titleShowQueries = () => require('../../tvShows/titleShowQueries'); + const listing = () => [ + { youtube_id: 'video1', toJSON: () => ({ youtube_id: 'video1' }) }, + { youtube_id: 'video2', toJSON: () => ({ youtube_id: 'video2' }) }, + ]; + + test('gives a video not downloaded yet its planned episode', async () => { + const Video = require('../../../models/video'); + Video.findAll = jest.fn().mockResolvedValue([]); + titleShowQueries().plannedEpisodes.mockResolvedValue(new Map([['video1', { showName: 'Beyblade', code: 'S01E20' }]])); + const result = await channelVideoQuery.enrichVideosWithDownloadStatus(listing()); + expect(result.map((video) => video.plannedEpisode)).toEqual([{ showName: 'Beyblade', code: 'S01E20' }, null]); + }); + + test('lists only the videos of the chosen show', async () => { + const Video = require('../../../models/video'); + Video.findAll = jest.fn().mockResolvedValue([]); + ChannelVideo.findAll.mockResolvedValue(listing()); + const result = await channelVideoQuery.fetchNewestVideosFromDb( + 'UC123', 50, 0, 'off', '', 'date', 'desc', false, 'video', null, null, null, null, 'off', 'off', 'off', 'off', null, new Set(['video2']) + ); + expect(result.map((video) => video.youtube_id)).toEqual(['video2']); + }); + }); + describe('enrichVideosWithDownloadStatus', () => { test('should add download status to videos based on Videos table', async () => { const Video = require('../../../models/video'); diff --git a/server/modules/channel/__tests__/channelVideoWriter.test.js b/server/modules/channel/__tests__/channelVideoWriter.test.js index 5d251c679..5b816ce37 100644 --- a/server/modules/channel/__tests__/channelVideoWriter.test.js +++ b/server/modules/channel/__tests__/channelVideoWriter.test.js @@ -4,6 +4,8 @@ const mockFactories = require('./mockFactories'); jest.mock('../../../logger'); jest.mock('../../../models/channelvideo', () => mockFactories.mockChannelVideoModel()); +jest.mock('../../../models/channel', () => ({ findOne: jest.fn() })); +jest.mock('../../tvShows/titleShowSaver', () => ({ classifyNew: jest.fn() })); describe('channelVideoWriter', () => { let channelVideoWriter; @@ -346,4 +348,41 @@ describe('channelVideoWriter', () => { expect(ChannelVideo.update).not.toHaveBeenCalled(); }); }); + + describe('title show classification of new videos', () => { + let Channel; + let titleShowSaver; + + beforeEach(() => { + Channel = require('../../../models/channel'); + titleShowSaver = require('../../tvShows/titleShowSaver'); + let nextId = 1; + ChannelVideo.findOrCreate.mockImplementation(async ({ where, defaults }) => ( + where.youtube_id === 'old' + ? [{ id: 0, publishedAt: '2020-01-01T00:00:00Z', published_at_source: 'approximate', update: jest.fn() }, false] + : [{ id: nextId++, publishedAt: defaults.publishedAt, published_at_source: defaults.published_at_source, update: jest.fn() }, true] + )); + }); + + test('classifies the videos the refresh added for a subscribed channel', async () => { + const channel = { channel_id: 'UC123', enabled: true }; + Channel.findOne.mockResolvedValue(channel); + await channelVideoWriter.insertVideosIntoDb([ + { ...mockVideoData, youtube_id: 'new' }, { ...mockVideoData, youtube_id: 'old' }, + ], 'UC123'); + expect(titleShowSaver.classifyNew).toHaveBeenCalledWith({ channel, youtubeIds: ['new'] }); + }); + + test('skips a channel that is not subscribed', async () => { + Channel.findOne.mockResolvedValue({ channel_id: 'UC123', enabled: false }); + await channelVideoWriter.insertVideosIntoDb([{ ...mockVideoData, youtube_id: 'new' }], 'UC123'); + expect(titleShowSaver.classifyNew).not.toHaveBeenCalled(); + }); + + test('never fails the refresh when classification fails', async () => { + Channel.findOne.mockResolvedValue({ channel_id: 'UC123', enabled: true }); + titleShowSaver.classifyNew.mockRejectedValue(new Error('python crashed')); + await expect(channelVideoWriter.insertVideosIntoDb([{ ...mockVideoData, youtube_id: 'new' }], 'UC123')).resolves.toBeUndefined(); + }); + }); }); diff --git a/server/modules/channel/__tests__/channelVideosService.test.js b/server/modules/channel/__tests__/channelVideosService.test.js index 9320e3168..d7e44459c 100644 --- a/server/modules/channel/__tests__/channelVideosService.test.js +++ b/server/modules/channel/__tests__/channelVideosService.test.js @@ -20,6 +20,11 @@ jest.mock('../../../db', () => mockFactories.mockDb()); const toEntryLines = (entries) => entries.map((entry) => `${JSON.stringify(entry)}\n`).join(''); jest.mock('../../tvShows/episodeInfo', () => ({ getEpisodeInfoMap: jest.fn().mockResolvedValue(new Map()) })); +jest.mock('../../tvShows/titleShowSaver', () => ({ classifyNew: jest.fn() })); +jest.mock('../../tvShows/titleShowQueries', () => ({ + plannedEpisodes: jest.fn().mockResolvedValue(new Map()), + youtubeIdsForShow: jest.fn().mockResolvedValue(new Set()), +})); describe('channelVideosService', () => { let channelVideosService; @@ -316,6 +321,34 @@ describe('channelVideosService', () => { }); }); + describe('show filter', () => { + test('lists and counts only the episodes of the chosen show', async () => { + const Video = require('../../../models/video'); + const fileCheckModule = require('../../fileCheckModule'); + fileCheckModule.checkVideoFiles.mockImplementation(async (videos) => ({ videos, updates: [] })); + Channel.findOne.mockResolvedValue({ + ...mockChannelData, + lastFetchedByTab: JSON.stringify({ video: new Date().toISOString() }), + auto_download_enabled_tabs: 'video', + }); + const checkedAt = new Date(); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'episode', publishedAt: new Date().toISOString(), youtube_removed_checked_at: checkedAt, toJSON() { return this; } }, + { youtube_id: 'loose', publishedAt: new Date().toISOString(), youtube_removed_checked_at: checkedAt, toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([]); + require('../../tvShows/titleShowQueries').youtubeIdsForShow.mockResolvedValue(new Set(['episode'])); + + const result = await channelVideosService.getChannelVideos( + 'UC123', 1, 50, 'off', '', 'date', 'desc', 'videos', + null, null, null, null, 'off', 'off', 'off', 'off', null, 3 + ); + + expect([result.videos.map((v) => v.youtube_id), result.totalCount]).toEqual([['episode'], 1]); + expect(require('../../tvShows/titleShowQueries').youtubeIdsForShow).toHaveBeenCalledWith(3); + }); + }); + test('should skip auto-refresh when fetch already in progress', async () => { const Video = require('../../../models/video'); const mockChannel = { ...mockChannelData, lastFetchedByTab: null, auto_download_enabled_tabs: 'video' }; diff --git a/server/modules/channel/channelCatalog.js b/server/modules/channel/channelCatalog.js index 4ab679efd..29fb7686e 100644 --- a/server/modules/channel/channelCatalog.js +++ b/server/modules/channel/channelCatalog.js @@ -7,6 +7,7 @@ const channelProvisioning = require('./channelProvisioning'); const tabDownloadStats = require('./tabDownloadStats'); const m3uGenerator = require('../m3uGenerator'); const channelFolders = require('../tvShows/channelFolders'); +const titleShowQueries = require('../tvShows/titleShowQueries'); const { getLayoutResolver } = require('../tvShows/libraryLayouts'); const { LAYOUT_TV, LAYOUT_VIDEOS } = require('../tvShows/constants'); @@ -215,6 +216,13 @@ class ChannelCatalog { return () => LAYOUT_VIDEOS; }); + // The "N shows" chip, decoration as well. + const titleShowCounts = await titleShowQueries.countActiveByChannel(rows.map((channel) => channel.channel_id)) + .catch((err) => { + logger.warn({ err }, 'Failed to count channel title shows'); + return new Map(); + }); + const totalPages = count > 0 ? Math.ceil(count / safePageSize) : 0; const normalizedSubFolders = distinctSubFolders .map((entry) => entry.sub_folder) @@ -230,6 +238,7 @@ class ChannelCatalog { channels: rows.map((channel) => ({ ...channelMappers.mapChannelListEntry(channel, statsByChannel.get(channel.channel_id)), layout: layoutOf(channelFolders.effectiveLibraryFolder(channel.sub_folder)), + ...(titleShowCounts.get(channel.channel_id) ? { titleShows: titleShowCounts.get(channel.channel_id) } : {}), })), total: count, page: safePage, diff --git a/server/modules/channel/channelVideoQuery.js b/server/modules/channel/channelVideoQuery.js index 2c65539cd..3cb9dda38 100644 --- a/server/modules/channel/channelVideoQuery.js +++ b/server/modules/channel/channelVideoQuery.js @@ -4,6 +4,7 @@ const fileCheckModule = require('../fileCheckModule'); const ratingMapper = require('../ratingMapper'); const { PUBLISHED_AT_SOURCE } = require('../constants/publishedAtSource'); const episodeInfo = require('../tvShows/episodeInfo'); +const titleShowQueries = require('../tvShows/titleShowQueries'); class ChannelVideoQuery { /** @@ -94,6 +95,11 @@ class ChannelVideoQuery { const episodesByVideoId = await episodeInfo.getEpisodeInfoMap( downloadedVideos.map((v) => ({ youtubeId: v.youtubeId, filePath: v.filePath })) ); + // A title show numbers an episode before it downloads; its chip shows that number. + const notDownloaded = videos + .map((video) => video.youtube_id || video.youtubeId) + .filter((id) => !downloadStatusMap.has(id) || downloadStatusMap.get(id).removed); + const plannedByVideoId = await titleShowQueries.plannedEpisodes(notDownloaded); return videos.map((video) => { const plainVideoObject = video.toJSON ? video.toJSON() : video; @@ -119,6 +125,7 @@ class ChannelVideoQuery { : null; plainVideoObject.watchedBy = watchedByVideoId.get(status.id) || []; plainVideoObject.episode = episodesByVideoId.get(videoId) || null; + plainVideoObject.plannedEpisode = plannedByVideoId.get(videoId) || null; } else { // Video never downloaded plainVideoObject.added = false; @@ -131,6 +138,7 @@ class ChannelVideoQuery { plainVideoObject.video_resolution = null; plainVideoObject.watchedBy = []; plainVideoObject.episode = null; + plainVideoObject.plannedEpisode = plannedByVideoId.get(videoId) || null; } // Replace thumbnail with template format (unless video is removed from YouTube) @@ -220,6 +228,16 @@ class ChannelVideoQuery { * @param {string} watchedMode - 'off' | 'only' | 'exclude' * @returns {Array} - Filtered array of videos */ + /** + * Keep only the episodes of one title show. + * @param {Array} videos + * @param {Set<string>|null} showFilter - The show's video ids, or null for every video + */ + _applyShowFilter(videos, showFilter) { + if (!showFilter) return videos; + return videos.filter((video) => showFilter.has(video.youtube_id || video.youtubeId)); + } + _applyStatusFilters(videos, protectedMode, missingMode, ignoredMode, watchedMode) { let filtered = videos; @@ -268,7 +286,7 @@ class ChannelVideoQuery { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Array>} - Array of video objects with download status */ - async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null) { + async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null, showFilter = null) { // First get all videos to enrich with download status const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -305,6 +323,8 @@ class ChannelVideoQuery { filteredVideos = this._applyRatingFilter(filteredVideos, ratingFilter); + filteredVideos = this._applyShowFilter(filteredVideos, showFilter); + // Apply sorting filteredVideos.sort((a, b) => { let comparison = 0; @@ -389,9 +409,9 @@ class ChannelVideoQuery { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Object>} - Object with totalCount and oldestVideoDate */ - async getChannelVideoStats(channelId, downloadedFilter = 'off', searchQuery = '', mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null) { + async getChannelVideoStats(channelId, downloadedFilter = 'off', searchQuery = '', mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null, showFilter = null) { // If we have search or filter, we need to get all videos - if (downloadedFilter !== 'off' || searchQuery || minDuration !== null || maxDuration !== null || dateFrom || dateTo || protectedFilter !== 'off' || missingFilter !== 'off' || ignoredFilter !== 'off' || watchedFilter !== 'off' || ratingFilter) { + if (downloadedFilter !== 'off' || searchQuery || minDuration !== null || maxDuration !== null || dateFrom || dateTo || protectedFilter !== 'off' || missingFilter !== 'off' || ignoredFilter !== 'off' || watchedFilter !== 'off' || ratingFilter || showFilter) { // Need to filter by download status and/or search const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -427,6 +447,8 @@ class ChannelVideoQuery { filteredVideos = this._applyRatingFilter(filteredVideos, ratingFilter); + filteredVideos = this._applyShowFilter(filteredVideos, showFilter); + // Estimated dates are ordering-only placeholders; never surface them. const oldest = filteredVideos.length > 0 ? filteredVideos[filteredVideos.length - 1] : null; return { diff --git a/server/modules/channel/channelVideoWriter.js b/server/modules/channel/channelVideoWriter.js index 77d27c432..6e35f31d8 100644 --- a/server/modules/channel/channelVideoWriter.js +++ b/server/modules/channel/channelVideoWriter.js @@ -1,5 +1,8 @@ const ChannelVideo = require('../../models/channelvideo'); +const Channel = require('../../models/channel'); +const logger = require('../../logger'); const channelVideoReanchor = require('../channelVideoReanchor'); +const titleShowSaver = require('../tvShows/titleShowSaver'); const { PUBLISHED_AT_SOURCE } = require('../constants/publishedAtSource'); class ChannelVideoWriter { @@ -32,6 +35,7 @@ class ChannelVideoWriter { // ordering correct around scattered exact dates, since a fetched/existing // approximate date newer than a nearby exact date must be clamped below it. const entries = []; + const createdIds = []; for (const video of videos) { const [videoRecord, created] = await ChannelVideo.findOrCreate({ where: { @@ -47,6 +51,7 @@ class ChannelVideoWriter { }, }); + if (created) createdIds.push(video.youtube_id); if (!created) { const updates = { title: video.title, @@ -120,6 +125,24 @@ class ChannelVideoWriter { await ChannelVideo.update(fields, { where: { id: entry.id } }); } } + + await this.classifyNewVideos(channelId, createdIds); + } + + /** + * Put videos a refresh found into the channel's title shows (only new + * ones: an existing video keeps its episode whatever its title says now). + * Never fails the refresh. + */ + async classifyNewVideos(channelId, youtubeIds) { + if (youtubeIds.length === 0) return; + try { + const channel = await Channel.findOne({ where: { channel_id: channelId } }); + if (!channel || !channel.enabled) return; + await titleShowSaver.classifyNew({ channel, youtubeIds }); + } catch (err) { + logger.warn({ err, channelId }, 'Could not classify new videos into the channel\'s title shows'); + } } } diff --git a/server/modules/channel/channelVideosService.js b/server/modules/channel/channelVideosService.js index d87333e29..a0d6a7fec 100644 --- a/server/modules/channel/channelVideosService.js +++ b/server/modules/channel/channelVideosService.js @@ -7,6 +7,7 @@ const channelYtdlpExecutor = require('./channelYtdlpExecutor'); const videoEntryParser = require('./videoEntryParser'); const channelVideoWriter = require('./channelVideoWriter'); const channelVideoQuery = require('./channelVideoQuery'); +const titleShowQueries = require('../tvShows/titleShowQueries'); const channelVideoFetcher = require('./channelVideoFetcher'); const fetchRegistry = require('./fetchRegistry'); const tabState = require('./tabState'); @@ -131,7 +132,7 @@ class ChannelVideosService { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Object>} - Response object with videos and metadata */ - async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null) { + async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null, showId = null) { const channel = await Channel.findOne({ where: { channel_id: channelId }, }); @@ -144,6 +145,8 @@ class ChannelVideosService { const mediaType = MEDIA_TAB_TYPE_MAP[tabType] || 'video'; const autoDownloadsEnabled = channel.auto_download_enabled_tabs.split(',').includes(mediaType); const ratingFilter = maxRating ? { maxRating, channelDefaultRating: channel.default_rating } : null; + // The episodes of one title show (the channel page's show filter). + const showFilter = showId ? await titleShowQueries.youtubeIdsForShow(showId) : null; // Check if the requested tab exists in available_tabs // If available_tabs is populated and the requested tab doesn't exist, don't try to fetch from YouTube @@ -198,7 +201,7 @@ class ChannelVideosService { // Now fetch the requested page of videos with file checking enabled const offset = (page - 1) * pageSize; - const paginatedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const paginatedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, showFilter); // Check if videos still exist on YouTube and mark as removed if they don't const videoValidationModule = require('../videoValidationModule'); @@ -268,7 +271,7 @@ class ChannelVideosService { } // Get stats for the response - const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, showFilter); return { ...this.buildChannelVideosResponse(paginatedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType), @@ -278,8 +281,8 @@ class ChannelVideosService { } catch (error) { logger.error({ err: error, channelId }, 'Error fetching channel videos'); const offset = (page - 1) * pageSize; - const cachedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); - const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const cachedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, showFilter); + const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, showFilter); const response = this.buildChannelVideosResponse(cachedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType); // Only surface a user-visible error when we have nothing to show. // Silent recovery when cached results exist; the filter-aware empty diff --git a/server/modules/channelDownloadAllModule.js b/server/modules/channelDownloadAllModule.js index f1e05b54a..ae876021f 100644 --- a/server/modules/channelDownloadAllModule.js +++ b/server/modules/channelDownloadAllModule.js @@ -5,13 +5,20 @@ const { channelDownloadAllJobLabel } = require('./download/jobTypes'); const { MEDIA_TAB_TYPE_MAP } = require('./tabsUtils'); const logger = require('../logger'); const videoActivity = require('./download/videoActivity'); +const titleShowQueries = require('./tvShows/titleShowQueries'); const WATCH_URL_PREFIX = 'https://www.youtube.com/watch?v='; // "Download all videos for a channel" (one tab at a time). Assumes the caller // already ran the fetch-all ("Load More") flow, so channelvideos is complete. class ChannelDownloadAllModule { - async getDownloadableVideos(channelId, tabType) { + /** + * @param {string} channelId + * @param {string} tabType + * @param {Object|null} [channel] - channels row; with tv_show_only_downloads only title show + * episodes are downloadable (while the channel has a title show) + */ + async getDownloadableVideos(channelId, tabType, channel = null) { const mediaType = MEDIA_TAB_TYPE_MAP[tabType] || 'video'; const rows = await ChannelVideo.findAll({ @@ -47,14 +54,19 @@ class ChannelDownloadAllModule { // preview count would overstate. const downloaded = new Set(existing.map((video) => video.youtubeId)); - return candidates - .filter((row) => !downloaded.has(row.youtube_id) && !videoActivity.isActive(row.youtube_id)) + const downloadable = candidates + .filter((row) => !downloaded.has(row.youtube_id) && !videoActivity.isActive(row.youtube_id)); + const episodes = channel && channel.tv_show_only_downloads + ? await titleShowQueries.showEpisodeIds(channelId, downloadable.map((row) => row.youtube_id)) + : null; + return downloadable + .filter((row) => !episodes || episodes.has(row.youtube_id)) .map((row) => ({ youtube_id: row.youtube_id, duration: row.duration })); } async getPreview(channelId, tabType) { - await this.findChannelOrThrow(channelId); - const videos = await this.getDownloadableVideos(channelId, tabType); + const channel = await this.findChannelOrThrow(channelId); + const videos = await this.getDownloadableVideos(channelId, tabType, channel); let totalDurationSeconds = 0; let missingDurations = 0; @@ -71,7 +83,7 @@ class ChannelDownloadAllModule { async startDownloadAll(channelId, tabType, overrideSettings = {}) { const channel = await this.findChannelOrThrow(channelId); - const videos = await this.getDownloadableVideos(channelId, tabType); + const videos = await this.getDownloadableVideos(channelId, tabType, channel); if (videos.length === 0) { return { queued: 0 }; diff --git a/server/modules/channelDownloadGrouper.js b/server/modules/channelDownloadGrouper.js index d72ddf2c2..f7f6045e2 100644 --- a/server/modules/channelDownloadGrouper.js +++ b/server/modules/channelDownloadGrouper.js @@ -3,17 +3,23 @@ const configModule = require('./configModule'); const channelSettingsModule = require('./channelSettingsModule'); const { buildOutputTemplate, buildThumbnailTemplate } = require('./filesystem'); const downloadSettingsResolver = require('./download/downloadSettingsResolver'); +const titleShowStore = require('./tvShows/titleShowStore'); /** * Encapsulates channel filter settings for download filtering */ class ChannelFilterConfig { - constructor(minDuration = null, maxDuration = null, titleFilterRegex = null, audioFormat = null, skipVideoFolder = false) { + /** + * @param {Array<{filterRegex: string, excludeRegexes: string[]}>|null} [showFilters] - One per title show + * when the channel downloads only its title shows + */ + constructor(minDuration = null, maxDuration = null, titleFilterRegex = null, audioFormat = null, skipVideoFolder = false, showFilters = null) { this.minDuration = minDuration; this.maxDuration = maxDuration; this.titleFilterRegex = titleFilterRegex; this.audioFormat = audioFormat; this.skipVideoFolder = !!skipVideoFolder; + this.showFilters = showFilters && showFilters.length > 0 ? showFilters : null; } /** @@ -23,13 +29,15 @@ class ChannelFilterConfig { */ buildFilterKey() { // Use JSON to safely encode null values without collision risk - return JSON.stringify({ + const key = { min: this.minDuration, max: this.maxDuration, regex: this.titleFilterRegex, audio: this.audioFormat, skipVF: this.skipVideoFolder - }); + }; + if (this.showFilters) key.shows = this.showFilters; + return JSON.stringify(key); } /** @@ -45,22 +53,26 @@ class ChannelFilterConfig { this.audioFormat !== null || // skipVideoFolder affects download path structure, so channels with // different settings must be in separate download groups - this.skipVideoFolder; + this.skipVideoFolder || + this.showFilters !== null; } /** * Create a ChannelFilterConfig from a channel record * @param {Object} channel - Channel record from database * @param {Object} [config] - Global config (defaults to configModule.config) + * @param {Array<Object>|null} [showFilters] - The channel's title show filters; applied only when the + * channel downloads just its title shows * @returns {ChannelFilterConfig} - New filter config instance */ - static fromChannel(channel, config = configModule.config) { + static fromChannel(channel, config = configModule.config, showFilters = null) { return new ChannelFilterConfig( channel.min_duration, channel.max_duration, channel.title_filter_regex, channel.audio_format, - downloadSettingsResolver.resolveSkipVideoFolder({ channel, config }) + downloadSettingsResolver.resolveSkipVideoFolder({ channel, config }), + channel.tv_show_only_downloads ? showFilters : null ); } } @@ -87,7 +99,8 @@ class ChannelDownloadGrouper { 'max_duration', 'title_filter_regex', 'audio_format', - 'skip_video_folder' + 'skip_video_folder', + 'tv_show_only_downloads' ] }); @@ -99,9 +112,10 @@ class ChannelDownloadGrouper { * Channels with identical settings can be downloaded together in a single yt-dlp invocation * @param {Array} channels - Array of channel records * @param {string} globalQuality - Global quality setting (fallback) + * @param {Map<string, Array<Object>>} [showFilters] - Title show filters by channel id * @returns {Array} - Array of groups, each with { quality, subfolder, filterConfig, channels } */ - groupChannels(channels, globalQuality) { + groupChannels(channels, globalQuality, showFilters = new Map()) { const groups = new Map(); for (const channel of channels) { @@ -112,7 +126,7 @@ class ChannelDownloadGrouper { const subFolder = channelSettingsModule.resolveEffectiveSubfolder(channel.sub_folder); // Create filter config for this channel - const filterConfig = ChannelFilterConfig.fromChannel(channel); + const filterConfig = ChannelFilterConfig.fromChannel(channel, undefined, showFilters.get(channel.channel_id)); // Create group key including filter settings const groupKey = `${quality}|${subFolder || 'root'}|${filterConfig.buildFilterKey()}`; @@ -160,10 +174,13 @@ class ChannelDownloadGrouper { async generateDownloadGroups(overrideQuality = null) { const channels = await this.getEnabledChannelsWithSettings(); const globalQuality = overrideQuality || configModule.config.preferredResolution || '1080'; + // Channels downloading only their title shows get one match filter per show. + const showOnly = channels.filter((channel) => channel.tv_show_only_downloads).map((channel) => channel.channel_id); + const showFilters = showOnly.length > 0 ? await titleShowStore.showFiltersByChannel(showOnly) : new Map(); // If override quality is specified, use it for ALL channels (ignore per-channel settings) if (overrideQuality) { - const groups = this.groupChannelsBySubfolderOnly(channels); + const groups = this.groupChannelsBySubfolderOnly(channels, showFilters); return groups.map(group => ({ ...group, quality: overrideQuality, @@ -173,7 +190,7 @@ class ChannelDownloadGrouper { } // Otherwise, respect per-channel quality settings - const groups = this.groupChannels(channels, globalQuality); + const groups = this.groupChannels(channels, globalQuality, showFilters); return groups.map(group => ({ ...group, @@ -186,9 +203,10 @@ class ChannelDownloadGrouper { * Group channels by subfolder and filters (for use with quality override) * Quality override should not affect duration/title filters * @param {Array} channels - Array of channel records + * @param {Map<string, Array<Object>>} [showFilters] - Title show filters by channel id * @returns {Array} - Array of groups by subfolder and filter config */ - groupChannelsBySubfolderOnly(channels) { + groupChannelsBySubfolderOnly(channels, showFilters = new Map()) { const groups = new Map(); for (const channel of channels) { @@ -196,7 +214,7 @@ class ChannelDownloadGrouper { const subFolder = channelSettingsModule.resolveEffectiveSubfolder(channel.sub_folder); // Create filter config for this channel (filters still apply with quality override) - const filterConfig = ChannelFilterConfig.fromChannel(channel); + const filterConfig = ChannelFilterConfig.fromChannel(channel, undefined, showFilters.get(channel.channel_id)); // Group by both subfolder and filter settings const groupKey = `${subFolder || 'root'}|${filterConfig.buildFilterKey()}`; diff --git a/server/modules/channelModule.js b/server/modules/channelModule.js index 56c9c32e3..f5e2af266 100644 --- a/server/modules/channelModule.js +++ b/server/modules/channelModule.js @@ -201,8 +201,8 @@ class ChannelModule { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Object>} - Response object with videos and metadata */ - async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null) { - return channelVideosService.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating); + async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null, showId = null) { + return channelVideosService.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating, showId); } /** diff --git a/server/modules/download/__tests__/ytdlpCommandBuilder.test.js b/server/modules/download/__tests__/ytdlpCommandBuilder.test.js index 29520cacd..8d3e04a23 100644 --- a/server/modules/download/__tests__/ytdlpCommandBuilder.test.js +++ b/server/modules/download/__tests__/ytdlpCommandBuilder.test.js @@ -687,6 +687,60 @@ describe('YtdlpCommandBuilder', () => { }); }); + describe('buildMatchFilterList', () => { + const BASE = 'availability!=subscriber_only & !is_live & live_status!=is_upcoming'; + + it('holds the one channel filter when the channel downloads every video', () => { + expect(YtdlpCommandBuilder.buildMatchFilterList({ hasGroupingCriteria: () => true, minDuration: 60 })) + .toEqual([`${BASE} & duration >= 60`]); + }); + + it('holds the base filter without a filter config', () => { + expect(YtdlpCommandBuilder.buildMatchFilterList(null)).toEqual([BASE]); + }); + + it('holds one filter per title show, each with the channel\'s own filters', () => { + const filterConfig = { + hasGroupingCriteria: () => true, + minDuration: 60, + titleFilterRegex: 'EN', + showFilters: [ + { filterRegex: '(?i:BEYBLADE\\s+EN\\s+Episode\\s+(?:[0-9]+))', excludeRegexes: ['(?i:Official\\s+Clip)'] }, + { filterRegex: '(?i:Clip)', excludeRegexes: [] }, + ], + }; + expect(YtdlpCommandBuilder.buildMatchFilterList(filterConfig)).toEqual([ + `${BASE} & duration >= 60 & title ~= 'EN' & title ~= '(?i:BEYBLADE\\s+EN\\s+Episode\\s+(?:[0-9]+))' & title !~= '(?i:Official\\s+Clip)'`, + `${BASE} & duration >= 60 & title ~= 'EN' & title ~= '(?i:Clip)'`, + ]); + }); + + it('escapes quotes and ampersands in show filters and exclude terms', () => { + const filterConfig = { + hasGroupingCriteria: () => true, + showFilters: [{ filterRegex: '(?i:Rock\\s+&\\s+Roll\'s)', excludeRegexes: ['(?i:Q&A\'s)'] }], + }; + expect(YtdlpCommandBuilder.buildMatchFilterList(filterConfig)).toEqual([ + `${BASE} & title ~= '(?i:Rock\\s+\\&\\s+Roll\\'s)' & title !~= '(?i:Q\\&A\\'s)'`, + ]); + }); + }); + + describe('getBaseCommandArgs with title shows', () => { + it('passes one --match-filter per title show', () => { + const filterConfig = { + hasGroupingCriteria: () => true, + showFilters: [{ filterRegex: '(?i:a)', excludeRegexes: [] }, { filterRegex: '(?i:b)', excludeRegexes: [] }], + }; + const result = YtdlpCommandBuilder.getBaseCommandArgs('1080', false, null, filterConfig); + const filters = result.filter((arg, index) => result[index - 1] === '--match-filter'); + expect(filters).toEqual([ + 'availability!=subscriber_only & !is_live & live_status!=is_upcoming & title ~= \'(?i:a)\'', + 'availability!=subscriber_only & !is_live & live_status!=is_upcoming & title ~= \'(?i:b)\'', + ]); + }); + }); + describe('getBaseCommandArgs', () => { it('should build basic command args with default values', () => { const result = YtdlpCommandBuilder.getBaseCommandArgs(); diff --git a/server/modules/download/ytdlpCommandBuilder.js b/server/modules/download/ytdlpCommandBuilder.js index 047d96a77..da764cd7d 100644 --- a/server/modules/download/ytdlpCommandBuilder.js +++ b/server/modules/download/ytdlpCommandBuilder.js @@ -492,13 +492,7 @@ class YtdlpCommandBuilder { // Add title regex filter if specified if (filterConfig.titleFilterRegex) { - // yt-dlp's match_str splits filters on unescaped '&' and, inside a quoted - // value, only unescapes the quote character. Backslashes pass through - // untouched, so doubling them would turn `\d` into a literal backslash. - const escapedRegex = filterConfig.titleFilterRegex - .replace(/'/g, '\\\'') - .replace(/&/g, '\\&'); - additionalFilters.push(`title ~= '${escapedRegex}'`); + additionalFilters.push(`title ~= '${this.escapeMatchFilterValue(filterConfig.titleFilterRegex)}'`); } // Combine all filters @@ -506,6 +500,40 @@ class YtdlpCommandBuilder { return allFilters.join(' & '); } + /** + * A regex as a quoted match filter value. yt-dlp's match_str splits filters + * on unescaped '&' and, inside a quoted value, only unescapes the quote + * character. Backslashes pass through untouched, so doubling them would + * turn `\d` into a literal backslash. + * @param {string} regex + * @returns {string} + */ + static escapeMatchFilterValue(regex) { + return regex.replace(/'/g, '\\\'').replace(/&/g, '\\&'); + } + + /** + * The match filters of a channel download, each passed as its own + * --match-filter (yt-dlp downloads a video any one of them accepts). A + * channel downloading only its title shows gets one filter per show: its + * own filters, the show's patterns, and that show's exclude terms (so a + * term excluded by one show never blocks another show). Patterns of + * different shows are never joined into one regex: repeated group names or + * flags would crash yt-dlp mid-run. + * @param {Object} filterConfig - ChannelFilterConfig (showFilters: [{ filterRegex, excludeRegexes }]) + * @returns {string[]} + */ + static buildMatchFilterList(filterConfig = null) { + const channelFilter = this.buildMatchFilters(filterConfig); + const shows = filterConfig && Array.isArray(filterConfig.showFilters) ? filterConfig.showFilters : []; + if (shows.length === 0) return [channelFilter]; + return shows.map((show) => [ + channelFilter, + `title ~= '${this.escapeMatchFilterValue(show.filterRegex)}'`, + ...(show.excludeRegexes || []).map((exclude) => `title !~= '${this.escapeMatchFilterValue(exclude)}'`), + ].join(' & ')); + } + static buildSearchArgs(query, count) { // youtubetab:approximate_date populates an approximate upload_date/timestamp from "X years ago" text without paying for full per-video extraction. const config = configModule.getConfig(); @@ -605,8 +633,8 @@ class YtdlpCommandBuilder { args.push('--download-archive', archiveModule.getArchivePath()); } - // Build match filter with any channel-specific filtering - const matchFilter = this.buildMatchFilters(filterConfig); + // Build match filters with any channel-specific filtering + const matchFilters = this.buildMatchFilterList(filterConfig).flatMap((filter) => ['--match-filter', filter]); args.push( '--ignore-errors', @@ -614,7 +642,7 @@ class YtdlpCommandBuilder { '--write-info-json', '--no-write-playlist-metafiles', '--extractor-args', 'youtubetab:tab=videos;sort=dd', - '--match-filter', matchFilter, + ...matchFilters, '-o', outputPath, '--datebefore', 'now', '-o', `thumbnail:${thumbnailPath}`, diff --git a/server/modules/downloadModule.js b/server/modules/downloadModule.js index 0ea3ffe16..970b8c851 100644 --- a/server/modules/downloadModule.js +++ b/server/modules/downloadModule.js @@ -13,6 +13,7 @@ const ChannelVideo = require('../models/channelvideo'); const logger = require('../logger'); const playlistDownloadModule = require('./playlistDownloadModule'); const storageGuard = require('./storageGuard'); +const episodeConflicts = require('./tvShows/episodeConflicts'); const DEFAULT_FILES_TO_DOWNLOAD = 5; // Statuses that end a grouped channel download before its remaining groups run. @@ -902,6 +903,9 @@ class DownloadModule { await archiveModule.removeVideoFromArchive(video.youtube_id); logger.info({ youtubeId: video.youtube_id }, 'Removed ignored video from archive for manual download'); } + // A title-show duplicate Youtarr suppressed gets its line back + // after the job unless it downloads. + await episodeConflicts.noteArchiveLinesRemoved(ignoredVideos.map((video) => video.youtube_id)); } } catch (err) { logger.error({ err }, 'Error removing ignored videos from archive'); diff --git a/server/modules/jobModule.js b/server/modules/jobModule.js index 2249e8c7a..0424f03f9 100644 --- a/server/modules/jobModule.js +++ b/server/modules/jobModule.js @@ -48,6 +48,8 @@ class JobModule { this.jobAbandonedListeners = new Set(); // Told whenever updateJob gives a job a final status; see onJobEnded. this.jobEndedListeners = new Set(); + // Awaited before the queue starts its next job; see onBeforeNextJob. + this.beforeNextJobListeners = new Set(); if (!fs.existsSync(this.jobsDir)) { fs.mkdirSync(this.jobsDir, { recursive: true }); @@ -503,6 +505,7 @@ class JobModule { logger.info('Downloads are being reorganized; holding queued jobs'); return; } + await this.runBeforeNextJob(); const jobs = this.getAllJobs(); for (let id in jobs) { if (jobs[id].status !== 'Pending') continue; @@ -576,6 +579,28 @@ class JobModule { return () => this.jobEndedListeners.delete(listener); } + /** + * Run work that must happen between download jobs (title shows write + * complete.list only while no job runs). Awaited before the next queued + * job starts; a failing listener never holds the queue. + * @param {Function} listener - async, called with no arguments + * @returns {Function} unsubscribe + */ + onBeforeNextJob(listener) { + this.beforeNextJobListeners.add(listener); + return () => this.beforeNextJobListeners.delete(listener); + } + + async runBeforeNextJob() { + for (const listener of this.beforeNextJobListeners) { + try { + await listener(); + } catch (err) { + logger.warn({ err }, 'Before-next-job listener failed'); + } + } + } + notifyJobEnded(event) { for (const listener of this.jobEndedListeners) { try { diff --git a/server/modules/reorganize/__tests__/changeContext.test.js b/server/modules/reorganize/__tests__/changeContext.test.js index 4047c5a91..015a0d293 100644 --- a/server/modules/reorganize/__tests__/changeContext.test.js +++ b/server/modules/reorganize/__tests__/changeContext.test.js @@ -8,7 +8,9 @@ jest.mock('../../tvShows/layoutGuards', () => ({ assertNoMp3Users: jest.fn(), usersOfFolder: jest.fn().mockResolvedValue({ channels: [], playlists: [] }), usersOfGlobalDefault: jest.fn().mockResolvedValue({ channels: [], playlists: [] }), + assertNoTitleShows: jest.fn(), })); +jest.mock('../../tvShows/titleShowSaver', () => ({ prepare: jest.fn() })); jest.mock('../../tvShows/channelLayout', () => ({ MESSAGES: { mp3: 'TV shows are video-only.' }, resolveLayoutTarget: jest.fn(), @@ -44,6 +46,46 @@ describe('reorganize changeContext', () => { changeContext = require('../changeContext'); }); + describe('title show changes', () => { + const change = { type: 'titleShows', channelId: 'UC1', shows: [{ name: 'Beyblade' }], overrides: [] }; + + beforeEach(() => { + Channel.findOne.mockResolvedValue(channel({ title: 'BEYBLADE Official' })); + require('../../tvShows/titleShowSaver').prepare.mockResolvedValue({ drafts: [{ key: 'new:0' }], plan: { entries: [] } }); + }); + + it('plans the channel\'s title shows', async () => { + const context = await changeContext.resolveChange(change); + expect(require('../../tvShows/titleShowSaver').prepare).toHaveBeenCalledWith({ + channel: expect.objectContaining({ channel_id: 'UC1' }), rawShows: change.shows, rawOverrides: [], + }); + expect([context.type, context.drafts, context.titlePlan]).toEqual(['titleShows', [{ key: 'new:0' }], { entries: [] }]); + }); + + it('keeps the channel where it is', async () => { + const context = await changeContext.resolveChange(change); + expect([context.fromFolder, context.toFolder, context.folderAfter({ sub_folder: 'Kids' })]).toEqual(['Kids', 'Kids', 'Kids']); + }); + + it('stores the change as requested', async () => { + const context = await changeContext.resolveChange(change); + expect([context.stored, context.scope, context.label]).toEqual([change, 'UC1', 'BEYBLADE Official: shows']); + }); + + it('refuses shows that are not a list', async () => { + await expect(changeContext.resolveChange({ ...change, shows: 'x' })).rejects.toMatchObject({ status: 400 }); + }); + }); + + describe('a folder holding title shows', () => { + it('refuses to switch it to Videos', async () => { + layoutGuards.assertNoTitleShows.mockRejectedValue(Object.assign(new Error('Move its title shows first.'), { status: 400 })); + await expect(changeContext.resolveChange({ type: 'folderLayout', folder: 'TV Shows', layout: 'videos' })) + .rejects.toMatchObject({ status: 400 }); + expect(layoutGuards.assertNoTitleShows).toHaveBeenCalledWith('TV Shows'); + }); + }); + describe('channel changes', () => { it('resolves a move from a Videos folder to a TV folder', async () => { Channel.findOne.mockResolvedValue(channel()); diff --git a/server/modules/reorganize/__tests__/changeScope.test.js b/server/modules/reorganize/__tests__/changeScope.test.js index 21de5abbd..5149df0e7 100644 --- a/server/modules/reorganize/__tests__/changeScope.test.js +++ b/server/modules/reorganize/__tests__/changeScope.test.js @@ -111,6 +111,24 @@ describe('reorganize changeScope', () => { }); }); + describe('a title show change', () => { + const titleContext = (entries) => context({ type: 'titleShows', titlePlan: { entries } }); + + it('selects the downloaded videos its plan moves', async () => { + Video.findAll.mockResolvedValue([video(1, 'aaaaaaaaaaa', '/data/__Kids/Chan/A [aaaaaaaaaaa].mp4')]); + const { subjects } = await changeScope.selectSubjects(titleContext([ + { youtubeId: 'aaaaaaaaaaa', moves: true }, { youtubeId: 'bbbbbbbbbbb', moves: false }, + ])); + expect(Video.findAll.mock.calls[0][0].where).toEqual({ youtubeId: ['aaaaaaaaaaa'], removed: false }); + expect(subjects.map((subject) => [subject.video.id, subject.ownerChannelId])).toEqual([[1, 'UC1']]); + }); + + it('selects nothing when nothing moves', async () => { + const { subjects } = await changeScope.selectSubjects(titleContext([{ youtubeId: 'aaaaaaaaaaa', moves: false }])); + expect([subjects, Video.findAll.mock.calls.length]).toEqual([[], 0]); + }); + }); + it('selects the videos of every channel that follows the default subfolder', async () => { const onDefault = { ...CHANNEL, sub_folder: '##USE_GLOBAL_DEFAULT##' }; Channel.findAll.mockResolvedValue([onDefault, { ...CHANNEL, channel_id: 'UC2', sub_folder: 'Kids' }]); diff --git a/server/modules/reorganize/__tests__/destinationPlanner.test.js b/server/modules/reorganize/__tests__/destinationPlanner.test.js index 52344260c..9a01a51d3 100644 --- a/server/modules/reorganize/__tests__/destinationPlanner.test.js +++ b/server/modules/reorganize/__tests__/destinationPlanner.test.js @@ -3,6 +3,7 @@ jest.mock('../../configModule', () => ({ getConfig: jest.fn(() => ({})), directo jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); jest.mock('../../videoInfoStore', () => ({ readInfoOrFallback: jest.fn() })); jest.mock('../movieNameRenderer', () => ({ renderMovieNames: jest.fn() })); +jest.mock('../../tvShows/titleShowStore', () => ({ highWaterMarks: jest.fn(async () => new Map()) })); jest.mock('../showPlanner', () => { const path = require('path'); return { @@ -140,6 +141,82 @@ describe('reorganize destinationPlanner', () => { }); }); + describe('Videos to a title show', () => { + const titleShow = { key: 'title:3', kind: 'title', ownerChannelId: 'UC1', showId: 3, name: 'Beyblade', libraryFolder: 'TV', folderName: 'Beyblade' }; + const target = { libraryFolder: 'TV', layout: 'tv', showKey: 'title:3' }; + const planTitle = (subject, titleTarget, context = tvContext) => planner.planDestinations({ + subjects: [subject], + context, + targets: new Map([[subject.video.id, target]]), + shows: new Map([['title:3', titleShow]]), + titleTargets: new Map([[subject.video.id, titleTarget]]), + }); + const assigned = { showKey: 'title:3', status: 'assigned', season: 1, episode: 20, source: 'title', episodeTitle: 'Relative' }; + + it('moves the video into the show\'s season folder under its title number', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + const { items, problems } = await planTitle(subjectFor(videoPath), { showKey: 'title:3', after: assigned, pattern: null, stored: null }); + expect(problems).toEqual([]); + expect(items[0].newVideoPath).toBe(path.join(root, '__TV', 'Beyblade', 'Season 01', `S01E20 - Relative [${ID}].mp4`)); + expect(items[0].classification).toMatchObject({ showKey: 'title:3', kind: 'title', season: 1, episode: 20, source: 'title' }); + }); + + it('numbers a video waiting for an upload-time number from its info', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + const { items } = await planTitle(subjectFor(videoPath), { + showKey: 'title:3', after: { ...assigned, status: 'pending_number', season: null, episode: null }, pattern: { seasonSource: 'year', episodeSource: 'date' }, stored: null, + }); + expect(items[0].classification).toMatchObject({ season: 2024, episode: 3151200, source: 'date' }); + }); + + it('keeps clear of numbers the title plan gives other videos', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + const context = { + ...tvContext, + type: 'titleShows', + titlePlan: { entries: [{ youtubeId: 'other000000', after: { showKey: 'title:3', status: 'assigned', season: 2024, episode: 3151200 } }] }, + }; + const { items } = await planTitle(subjectFor(videoPath), { + showKey: 'title:3', after: { ...assigned, status: 'pending_number', season: null, episode: null }, pattern: { seasonSource: 'year', episodeSource: 'date' }, stored: null, + }, context); + expect(items[0].classification.episode).toBe(3151201); + }); + + it('reports a waiting video with no time at all', async () => { + videoInfoStore.readInfoOrFallback.mockResolvedValue({ id: ID, title: 'Big Build' }); + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + const { problems } = await planTitle(subjectFor(videoPath), { + showKey: 'title:3', after: { ...assigned, status: 'pending_number', season: null, episode: null }, pattern: { seasonSource: 'year', episodeSource: 'date' }, stored: null, + }); + expect(problems.map((problem) => problem.problem)).toEqual(['no-date']); + }); + + it('does not count an episode of a title show as placed by a download override', async () => { + const videoPath = touch(`__TV/Beyblade/Season 01/S01E19 - Relative [${ID}].mp4`); + const subject = subjectFor(videoPath, { subject: { libraryFolder: 'TV', currentLayout: 'tv' } }); + const context = { + ...tvContext, + type: 'titleShows', + titlePlan: { entries: [], stored: new Map([[ID, { showKey: 'title:3', showKind: 'title', status: 'assigned', season: 1, episode: 19 }]]) }, + }; + const { items } = await planTitle(subject, { showKey: 'title:3', after: assigned, pattern: null, stored: null }, context); + expect(items[0].flags).not.toContain('override-placed'); + }); + + it('reports a waiting title episode whose number another video holds', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + const context = { + ...tvContext, + type: 'titleShows', + titlePlan: { entries: [{ youtubeId: 'other000000', after: { showKey: 'title:3', status: 'assigned', season: 2024, episode: 4 } }] }, + }; + const { problems } = await planTitle(subjectFor(videoPath), { + showKey: 'title:3', after: { ...assigned, status: 'pending_number', season: null, episode: 4 }, pattern: { seasonSource: 'year', episodeSource: 'title' }, stored: null, + }, context); + expect(problems.map((problem) => problem.problem)).toEqual(['episode-taken']); + }); + }); + describe('TV to Videos', () => { const tvSubject = (videoPath) => subjectFor(videoPath, { subject: { libraryFolder: 'TV', currentLayout: 'tv' } }); @@ -158,6 +235,25 @@ describe('reorganize destinationPlanner', () => { expect(items[0].classification).toBeNull(); }); + it('counts a video another folder holds as placed by a download override', async () => { + const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + const { items } = await planTo(tvSubject(videoPath), { libraryFolder: 'Kids', layout: 'videos' }); + expect(items[0].flags).toContain('override-placed'); + }); + + it('does not count an episode leaving a title show as placed by a download override', async () => { + const videoPath = touch(`__TV/Beyblade/Season 01/S01E19 - Relative [${ID}].mp4`); + const context = { + ...tvContext, + type: 'titleShows', + titlePlan: { entries: [], stored: new Map([[ID, { showKey: 'title:3', showKind: 'title', status: 'assigned', season: 1, episode: 19 }]]) }, + }; + const { items } = await planner.planDestinations({ + subjects: [tvSubject(videoPath)], context, targets: new Map([[1, { libraryFolder: 'Kids', layout: 'videos' }]]), shows: new Map(), + }); + expect(items[0].flags).not.toContain('override-placed'); + }); + it('moves the episode flat into the channel folder when the channel saves flat', async () => { const videoPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); const subject = tvSubject(videoPath); @@ -253,6 +349,16 @@ describe('reorganize destinationPlanner', () => { expect(problems).toEqual([expect.objectContaining({ problem: 'collision' })]); }); + it('reports a file where the destination folder belongs as a collision', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + touch('__TV/Chan/Season 2024', 'not a folder'); + + const { items, problems } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + + expect(items).toHaveLength(1); + expect(problems).toEqual([expect.objectContaining({ problem: 'collision' })]); + }); + it('leaves out leftovers of interrupted downloads and moves', async () => { const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4.part`); diff --git a/server/modules/reorganize/__tests__/followUp.test.js b/server/modules/reorganize/__tests__/followUp.test.js index 63f38ee01..3af44d324 100644 --- a/server/modules/reorganize/__tests__/followUp.test.js +++ b/server/modules/reorganize/__tests__/followUp.test.js @@ -3,7 +3,7 @@ jest.mock('../../configModule', () => ({ directoryPath: '/data', getConfig: jest jest.mock('../../plexModule', () => ({ refreshLibrariesForSubfolders: jest.fn() })); jest.mock('../../m3uGenerator', () => ({ generateChannelM3U: jest.fn(), generatePlaylistM3U: jest.fn() })); jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); -jest.mock('../../../models/tvshow', () => ({ findByPk: jest.fn() })); +jest.mock('../../../models/tvshow', () => ({ findByPk: jest.fn(), findAll: jest.fn(async () => []) })); jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); jest.mock('../../../models/playlistvideo', () => ({ findAll: jest.fn() })); jest.mock('../../sidecarWriter', () => ({ writeShowMetadata: jest.fn(), writeFolderArt: jest.fn() })); @@ -58,6 +58,22 @@ describe('reorganize followUp', () => { }); describe('finishFiles', () => { + it('writes the metadata of a title show that received episodes', async () => { + const toTitleShow = { ...toTv, classification: { ownerChannelId: 'UC1', showKey: 'title:7', kind: 'title' } }; + await followUp.finishFiles({ + items: [toTitleShow], + shows: [{ ownerChannelId: 'UC1', showId: 5 }, { key: 'title:7', kind: 'title', ownerChannelId: 'UC1', showId: 7 }], + }); + expect(require('../../../models/tvshow').findByPk.mock.calls.map(([id]) => id)).toEqual([7]); + }); + + it('rewrites every active title show of a channel whose title shows changed', async () => { + const renamed = { id: 8, kind: 'title', folder_name: 'Renamed' }; + require('../../../models/tvshow').findAll.mockResolvedValue([renamed]); + await followUp.finishFiles({ items: [], shows: [], titleShowChannelId: 'UC1' }); + expect(require('../../sidecarWriter').writeShowMetadata).toHaveBeenCalledWith({ show: renamed, showDir: '/data/__TV/Renamed' }); + }); + it('writes the metadata of shows that received episodes', async () => { await followUp.finishFiles({ items: [toTv], shows: [{ ownerChannelId: 'UC1', showId: 5, plot: 'About' }] }); diff --git a/server/modules/reorganize/__tests__/itemExecutor.test.js b/server/modules/reorganize/__tests__/itemExecutor.test.js index cea210fd4..3b12dc28a 100644 --- a/server/modules/reorganize/__tests__/itemExecutor.test.js +++ b/server/modules/reorganize/__tests__/itemExecutor.test.js @@ -7,6 +7,7 @@ jest.mock('../../videoInfoStore', () => ({ rewriteActualPaths: jest.fn().mockResolvedValue(true), })); jest.mock('../../sidecarWriter', () => ({ writeVideoSidecars: jest.fn().mockResolvedValue([]) })); +jest.mock('../../tvShows/titleShowStore', () => ({ raiseHighWater: jest.fn() })); const fs = require('fs'); const os = require('os'); @@ -84,6 +85,37 @@ describe('reorganize itemExecutor', () => { fs.rmSync(root, { recursive: true, force: true }); }); + describe('a title show episode', () => { + const titleRecord = (extra = {}) => { + const record = toTvRecord(); + return { + ...record, + classification: JSON.stringify({ ...classification, showKey: 'new:0', kind: 'title', season: 1, episode: 7, source: 'order', ...extra }), + }; + }; + + it('stores the episode in the title show found by its key, keeping the row\'s pattern', async () => { + const record = titleRecord(); + touch(record.plan.oldVideoPath, 'video'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + const stored = { update: jest.fn() }; + VideoClassification.findByPk.mockResolvedValue(stored); + const showIdFor = jest.fn((key) => (key === 'new:0' ? 9 : null)); + await executor.executeItem(record, { showIdFor }); + expect(showIdFor).toHaveBeenCalledWith('new:0'); + const values = stored.update.mock.calls[0][0]; + expect([values.show_id, values.season, values.episode, 'pattern_id' in values]).toEqual([9, 1, 7, false]); + }); + + it('raises the season\'s high-water mark for an order number', async () => { + const record = titleRecord(); + touch(record.plan.oldVideoPath, 'video'); + Video.findByPk.mockResolvedValue(videoRow(record.plan.oldVideoPath)); + await executor.executeItem(record, { showIdFor: () => 9 }); + expect(require('../../tvShows/titleShowStore').raiseHighWater).toHaveBeenCalledWith(9, 1, 7); + }); + }); + it('moves a movie-style video into its season folder as an episode', async () => { const record = toTvRecord(); touch(record.plan.oldVideoPath, 'video'); diff --git a/server/modules/reorganize/__tests__/operationRunner.test.js b/server/modules/reorganize/__tests__/operationRunner.test.js index 48ce1d21c..b3df04703 100644 --- a/server/modules/reorganize/__tests__/operationRunner.test.js +++ b/server/modules/reorganize/__tests__/operationRunner.test.js @@ -170,6 +170,48 @@ describe('reorganize operationRunner', () => { expect(lock.isActive()).toBe(false); }); + describe('a title show change', () => { + const TITLE_CHANGE = { type: 'titleShows', channelId: 'UC1', shows: [], overrides: [] }; + const titleShows = [{ key: 'new:0', kind: 'title', ownerChannelId: 'UC1', showId: null }]; + const snapshot = { shows: [], rows: [], conflicts: [] }; + + beforeEach(() => { + operation.settings_change = JSON.stringify({ change: TITLE_CHANGE, label: 'Chan: shows', shows: titleShows, snapshot }); + planner.buildPlan.mockResolvedValue({ + ...plan(), + shows: titleShows, + context: { type: 'titleShows', label: 'Chan: shows', stored: TITLE_CHANGE, channel: { channel_id: 'UC1' }, layoutBefore: () => 'videos' }, + }); + }); + + it('locks the channel whose shows change', async () => { + await runner.start(TITLE_CHANGE, 'rev'); + expect(lock.coversChannel('UC1')).toBe(true); + await flush(() => !lock.isActive()); + }); + + it('finds a title show\'s id by its key', async () => { + await runner.start(TITLE_CHANGE, 'rev'); + await flush(() => !lock.isActive()); + expect(executor.executeItem.mock.calls[0][1].showIdFor('new:0')).toBe(9); + }); + + it('has the follow-up rewrite the channel\'s title show metadata', async () => { + await runner.start(TITLE_CHANGE, 'rev'); + await flush(() => !lock.isActive()); + expect(followUp.finishFiles.mock.calls[0][0].titleShowChannelId).toBe('UC1'); + }); + + it('undoes the change from its snapshot when no video could move', async () => { + executor.executeItem.mockRejectedValue(new Error('EACCES')); + store.itemsWithStatus.mockImplementation(async (id, statuses) => (statuses.includes('pending') ? [item(1), item(2)] : [])); + store.refreshCounts.mockResolvedValue({ done: 0, failed: 2, pending: 0, moved: 0 }); + await runner.start(TITLE_CHANGE, 'rev'); + await flush(() => !lock.isActive()); + expect(applier.rollbackSettings).toHaveBeenCalledWith(expect.objectContaining({ change: TITLE_CHANGE, snapshot })); + }); + }); + it('records a partial run when some videos fail', async () => { executor.executeItem.mockRejectedValueOnce(new Error('EEXIST')); store.refreshCounts.mockResolvedValue({ done: 1, failed: 1, pending: 0 }); diff --git a/server/modules/reorganize/__tests__/operationStore.test.js b/server/modules/reorganize/__tests__/operationStore.test.js index a37f411f5..54b7550ff 100644 --- a/server/modules/reorganize/__tests__/operationStore.test.js +++ b/server/modules/reorganize/__tests__/operationStore.test.js @@ -37,6 +37,12 @@ describe('reorganize operationStore', () => { store = require('../operationStore'); }); + it('keeps a title show change\'s snapshot with the change', async () => { + const snapshot = { shows: [], rows: [{ youtube_id: 'abcdefghijk' }], conflicts: [] }; + const operation = await store.createOperation({ ...plan, snapshot }); + expect(store.settingsOf(operation).snapshot).toEqual(snapshot); + }); + it('records the operation and its items in one transaction', async () => { const operation = await store.createOperation(plan); @@ -109,6 +115,21 @@ describe('reorganize operationStore', () => { }); }); + it('says which failed videos already had their files moved', async () => { + TvReorganizeOperation.findByPk.mockResolvedValue({ + id: 3, change_type: 'titleShows', status: 'partial', total_items: 2, done_items: 0, failed_items: 2, error: null, + started_at: null, finished_at: null, settings_change: JSON.stringify({ change: { type: 'titleShows' }, label: 'Chan', shows: [] }), + }); + TvReorganizeItem.findAll.mockResolvedValue([ + { id: 8, youtube_id: 'abcdefghijk', title: 'T', channel_id: 'UC1', error: 'EEXIST', files_moved: false }, + { id: 9, youtube_id: 'bcdefghijkl', title: 'U', channel_id: 'UC1', error: 'finishing failed', files_moved: true }, + ]); + + const view = await store.getOperationView(3); + + expect(view.failedItems.map((item) => item.filesMoved)).toEqual([false, true]); + }); + it('returns null for an unknown operation', async () => { TvReorganizeOperation.findByPk.mockResolvedValue(null); diff --git a/server/modules/reorganize/__tests__/planner.test.js b/server/modules/reorganize/__tests__/planner.test.js index fa61c5d1c..205cc7be0 100644 --- a/server/modules/reorganize/__tests__/planner.test.js +++ b/server/modules/reorganize/__tests__/planner.test.js @@ -7,6 +7,7 @@ jest.mock('../changeContext', () => ({ resolveChange: jest.fn() })); jest.mock('../changeScope', () => ({ selectSubjects: jest.fn() })); jest.mock('../showPlanner', () => ({ planShows: jest.fn() })); jest.mock('../destinationPlanner', () => ({ planDestinations: jest.fn() })); +jest.mock('../titleSnapshot', () => ({ takeTitleSnapshot: jest.fn(async () => ({ shows: [], rows: [], conflicts: [] })) })); const item = (overrides = {}) => ({ videoId: 1, @@ -57,6 +58,58 @@ describe('reorganize planner', () => { expect(plan.revision).toMatch(/^[0-9a-f]{64}$/); }); + describe('a title show change', () => { + const titleItem = item({ + classification: { showKey: 'title:3', kind: 'title', season: 1, episode: 20, source: 'title', fileStem: 'S01E20 - A [abcdefghijk]' }, + }); + + beforeEach(() => { + require('../changeContext').resolveChange.mockResolvedValue({ + type: 'titleShows', label: 'Chan: shows', channel: { channel_id: 'UC1' }, + stored: { type: 'titleShows', channelId: 'UC1', shows: [], overrides: [] }, + }); + require('../showPlanner').planShows.mockResolvedValue({ + targets: new Map(), + shows: new Map([['title:3', { key: 'title:3', kind: 'title', ownerChannelId: 'UC1', action: 'keep', name: 'Beyblade', libraryFolder: 'TV', folderName: 'Beyblade' }]]), + titleTargets: new Map([[1, { showKey: 'title:3' }]]), + }); + require('../destinationPlanner').planDestinations.mockResolvedValue({ items: [titleItem], problems: [], unchanged: 0 }); + }); + + it('keeps the title show that receives a moved video', async () => { + const plan = await planner.buildPlan({ type: 'titleShows' }); + expect(plan.shows.map((show) => show.key)).toEqual(['title:3']); + }); + + it('hands the title targets to the destination planner', async () => { + await planner.buildPlan({ type: 'titleShows' }); + expect(require('../destinationPlanner').planDestinations.mock.calls[0][0].titleTargets).toEqual(new Map([[1, { showKey: 'title:3' }]])); + }); + + it('snapshots the channel\'s shows and episodes to undo the change', async () => { + const plan = await planner.buildPlan({ type: 'titleShows' }); + expect(require('../titleSnapshot').takeTitleSnapshot).toHaveBeenCalledWith('UC1'); + expect(plan.snapshot).toEqual({ shows: [], rows: [], conflicts: [] }); + }); + + it('shows a title episode code with two-digit numbers', async () => { + const summary = await planner.summarizePlan(await planner.buildPlan({ type: 'titleShows' })); + expect(summary.items[0].episode).toBe('S01E20'); + }); + }); + + it('takes no snapshot for other changes', async () => { + const plan = await planner.buildPlan({ type: 'channel' }); + expect([plan.snapshot, require('../titleSnapshot').takeTitleSnapshot.mock.calls.length]).toEqual([null, 0]); + }); + + it('passes the channels on to the show planner', async () => { + const channels = new Map([['UC1', {}]]); + require('../changeScope').selectSubjects.mockResolvedValue({ subjects: [], channels }); + await planner.buildPlan({ type: 'channel' }); + expect(require('../showPlanner').planShows.mock.calls[0][2]).toBe(channels); + }); + it('summarizes the plan for the preview with paths relative to the downloads folder', async () => { const plan = await planner.buildPlan({ type: 'channel' }); diff --git a/server/modules/reorganize/__tests__/settingsApplier.test.js b/server/modules/reorganize/__tests__/settingsApplier.test.js index 9a58e6058..d29dbe2f6 100644 --- a/server/modules/reorganize/__tests__/settingsApplier.test.js +++ b/server/modules/reorganize/__tests__/settingsApplier.test.js @@ -14,6 +14,8 @@ jest.mock('../../tvShows/channelLayout', () => ({ applyChannelFolderChange: jest jest.mock('../../tvShows/libraryFolders', () => ({ syncPlexIgnore: jest.fn() })); jest.mock('../showPlanner', () => ({ SHOW_ACTION: { KEEP: 'keep', CREATE: 'create', MOVE: 'move' } })); jest.mock('../changeContext', () => ({ libraryFolderOf: (value, def) => (value === '##USE_GLOBAL_DEFAULT##' ? def : value || '') })); +jest.mock('../../tvShows/titleShowSaver', () => ({ prepare: jest.fn(), applyPrepared: jest.fn() })); +jest.mock('../titleSnapshot', () => ({ restoreTitleSnapshot: jest.fn() })); const layoutOf = (folder) => (folder === 'TV' ? 'tv' : 'videos'); @@ -143,4 +145,42 @@ describe('reorganize settingsApplier', () => { expect(configModule.updateConfig).toHaveBeenCalledWith({ x: 1, defaultSubfolder: 'GlobalDefault' }); }); }); + + describe('title show changes', () => { + const change = { type: 'titleShows', channelId: 'UC1', shows: [{ name: 'Beyblade' }], overrides: [] }; + const planned = [ + { key: 'new:0', kind: 'title', ownerChannelId: 'UC1', showId: null, action: 'create', name: 'Beyblade', libraryFolder: 'TV', folderName: 'Beyblade' }, + { key: 'title:3', kind: 'title', ownerChannelId: 'UC1', showId: 3, action: 'keep', name: 'Old', libraryFolder: 'TV', folderName: 'Old' }, + ]; + let titleShowSaver; + + beforeEach(() => { + titleShowSaver = require('../../tvShows/titleShowSaver'); + Channel.findOne.mockResolvedValue({ channel_id: 'UC1' }); + titleShowSaver.prepare.mockResolvedValue({ drafts: ['drafts'], plan: { entries: [] } }); + titleShowSaver.applyPrepared.mockResolvedValue({ showIds: new Map([['new:0', 9], ['title:3', 3]]), patternIds: new Map() }); + }); + + it('saves the shows and every episode row of the channel', async () => { + await applier.applySettings({ change, shows: planned, layoutBefore: layoutOf }); + expect(titleShowSaver.prepare).toHaveBeenCalledWith({ channel: { channel_id: 'UC1' }, rawShows: change.shows, rawOverrides: [] }); + expect(titleShowSaver.applyPrepared).toHaveBeenCalledWith({ channel: { channel_id: 'UC1' }, drafts: ['drafts'], plan: { entries: [] } }); + }); + + it('gives new title shows their saved ids', async () => { + const pinned = await applier.applySettings({ change, shows: planned, layoutBefore: layoutOf }); + expect(pinned.map((show) => show.showId)).toEqual([9, 3]); + }); + + it('creates no channel show for a title show', async () => { + await applier.applySettings({ change, shows: planned, layoutBefore: layoutOf }); + expect(showStore.createChannelShowAt).not.toHaveBeenCalled(); + }); + + it('puts the shows and episodes back from the snapshot', async () => { + const snapshot = { shows: [], rows: [], conflicts: [] }; + await applier.rollbackSettings({ change, shows: planned, snapshot }); + expect(require('../titleSnapshot').restoreTitleSnapshot).toHaveBeenCalledWith({ channel_id: 'UC1' }, snapshot); + }); + }); }); diff --git a/server/modules/reorganize/__tests__/showPlanner.test.js b/server/modules/reorganize/__tests__/showPlanner.test.js index 50e7a03f4..2151fe0dd 100644 --- a/server/modules/reorganize/__tests__/showPlanner.test.js +++ b/server/modules/reorganize/__tests__/showPlanner.test.js @@ -5,6 +5,9 @@ jest.mock('../../tvShows/channelFolders', () => { showDirectory: (show) => path.join('/data', show.library_folder ? `__${show.library_folder}` : '', show.folder_name), }; }); +jest.mock('../titleTargets', () => ({ + resolveTitleTargets: jest.fn(async () => ({ targets: new Map(), shows: new Map() })), +})); jest.mock('../changeScope', () => { const path = require('path'); return { libraryRootOf: (folder) => (folder ? path.join('/data', `__${folder}`) : '/data') }; @@ -112,6 +115,38 @@ describe('reorganize showPlanner', () => { }); }); + describe('title shows', () => { + const titleShow = { key: 'title:3', kind: 'title', ownerChannelId: 'UC1', showId: 3, action: 'keep', name: 'Beyblade', libraryFolder: 'Anime', folderName: 'Beyblade' }; + + beforeEach(() => { + require('../titleTargets').resolveTitleTargets.mockResolvedValue({ + targets: new Map([[1, { showKey: 'title:3', after: { season: 1, episode: 20 } }]]), + shows: new Map([['title:3', titleShow]]), + }); + }); + + it('sends an episode of a title show to its show\'s folder', async () => { + const { targets } = await showPlanner.planShows([subject()], channelToTv, new Map()); + expect(targets.get(1)).toEqual({ libraryFolder: 'Anime', layout: 'tv', showKey: 'title:3' }); + }); + + it('plans the title show and no channel show for it', async () => { + const { shows } = await showPlanner.planShows([subject()], channelToTv, new Map()); + expect([...shows.keys()]).toEqual(['title:3']); + }); + + it('hands the title targets on for numbering', async () => { + const { titleTargets } = await showPlanner.planShows([subject()], channelToTv, new Map()); + expect(titleTargets.get(1).after).toEqual({ season: 1, episode: 20 }); + }); + + it('passes the channels to the title show lookup', async () => { + const channels = new Map([['UC1', OWNER]]); + await showPlanner.planShows([subject()], channelToTv, channels); + expect(require('../titleTargets').resolveTitleTargets).toHaveBeenCalledWith([subject()], channelToTv, channels); + }); + }); + it('gives the absolute folders of a planned show', () => { const planned = { libraryFolder: 'TV', folderName: 'Chan', previousLocation: { libraryFolder: '', folderName: 'Chan' } }; diff --git a/server/modules/reorganize/__tests__/titleAssignments.test.js b/server/modules/reorganize/__tests__/titleAssignments.test.js new file mode 100644 index 000000000..b89e7bb89 --- /dev/null +++ b/server/modules/reorganize/__tests__/titleAssignments.test.js @@ -0,0 +1,108 @@ +const { assignTitleEpisodes } = require('../titleAssignments'); +const { interpretMatch } = require('../../tvShows/titleMatcher'); +const { planNumbers } = require('../../tvShows/titleNumbering'); + +const SHOW = { key: 'title:3', name: 'Beyblade', ownerChannelId: 'UC1' }; +// 2020-08-09 12:30 UTC +const TIMESTAMP = 1596976200; + +function entry(youtubeId, after, extra = {}) { + return { + youtubeId, + title: `Video ${youtubeId}`, + info: { timestamp: TIMESTAMP }, + after: { showKey: 'title:3', status: 'assigned', season: 1, episode: 20, source: 'title', episodeTitle: 'Relative', ...after }, + pattern: { seasonSource: 'fixed', episodeSource: 'title' }, + stored: null, + ...extra, + }; +} + +const assign = (entries, { taken = new Map(), highWater = new Map() } = {}) => assignTitleEpisodes({ show: SHOW, entries, taken, highWater }); + +describe('reorganize titleAssignments', () => { + it('gives a numbered episode its planned number and a new stem', () => { + const { assignments } = assign([entry('aaaaaaaaaaa')]); + expect(assignments.get('aaaaaaaaaaa')).toEqual({ + showKey: 'title:3', kind: 'title', ownerChannelId: 'UC1', showTitle: 'Beyblade', season: 1, episode: 20, + source: 'title', timestampSource: null, episodeTitle: 'Relative', fileStem: 'S01E20 - Relative [aaaaaaaaaaa]', + }); + }); + + it('keeps the stored stem of an episode whose number stays', () => { + const stored = { showKey: 'title:3', season: 1, episode: 20, fileStem: 'S01E20 - Old [aaaaaaaaaaa]' }; + const { assignments } = assign([entry('aaaaaaaaaaa', {}, { stored })]); + expect(assignments.get('aaaaaaaaaaa').fileStem).toBe('S01E20 - Old [aaaaaaaaaaa]'); + }); + + it('numbers a waiting date episode from its upload time', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null, source: null }, { + pattern: { seasonSource: 'year', episodeSource: 'date' }, + }); + expect(assign([pending]).assignments.get('aaaaaaaaaaa')).toMatchObject({ + season: 2020, episode: 8091230, source: 'date', timestampSource: 'timestamp', fileStem: 'S2020E08091230 - Relative [aaaaaaaaaaa]', + }); + }); + + it('numbers a year-season episode with the episode its title gave the plan', () => { + const pattern = { seasonSource: 'year', episodeSource: 'title' }; + const match = { showKey: 'title:3', patternKey: 'title:3#0', ...interpretMatch(pattern, { episode: '7', title: 'Relative' }) }; + const { rows } = planNumbers({ + videos: [{ youtubeId: 'aaaaaaaaaaa', publishedAtMs: 0, available: true, downloaded: true }], + matches: new Map([['aaaaaaaaaaa', match]]), + stored: new Map(), + highWater: new Map(), + }); + const planned = { ...entry('aaaaaaaaaaa', {}, { pattern }), after: rows.get('aaaaaaaaaaa') }; + expect(assign([planned]).assignments.get('aaaaaaaaaaa')).toMatchObject({ + season: 2020, episode: 7, source: 'title', fileStem: 'S2020E07 - Relative [aaaaaaaaaaa]', + }); + }); + + it('bumps a date number another episode holds', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null }, { + pattern: { seasonSource: 'year', episodeSource: 'date' }, + }); + const taken = new Map([[2020, new Set([8091230])]]); + expect(assign([pending], { taken }).assignments.get('aaaaaaaaaaa').episode).toBe(8091231); + }); + + it('numbers a waiting title episode in its upload year', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: 4 }, { + pattern: { seasonSource: 'year', episodeSource: 'title' }, + }); + expect(assign([pending]).assignments.get('aaaaaaaaaaa')).toMatchObject({ season: 2020, episode: 4, source: 'title' }); + }); + + it('leaves out a waiting title episode whose number is taken', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: 4 }, { + pattern: { seasonSource: 'year', episodeSource: 'title' }, + }); + const result = assign([pending], { taken: new Map([[2020, new Set([4])]]) }); + expect([result.assignments.has('aaaaaaaaaaa'), [...result.taken]]).toEqual([false, ['aaaaaaaaaaa']]); + }); + + it('allocates order numbers in a year season past its high-water mark', () => { + const pending = (id, timestamp) => entry(id, { status: 'pending_number', season: null, episode: null }, { + info: { timestamp }, pattern: { seasonSource: 'year', episodeSource: 'order' }, + }); + const { assignments } = assign([pending('bbbbbbbbbbb', TIMESTAMP + 60), pending('aaaaaaaaaaa', TIMESTAMP)], { + highWater: new Map([[2020, 2]]), + }); + expect([assignments.get('aaaaaaaaaaa').episode, assignments.get('bbbbbbbbbbb').episode]).toEqual([3, 4]); + }); + + it('numbers a waiting episode without an upload time by its download time', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null }, { + info: {}, downloadedAt: '2021-01-02T03:04:00.000Z', pattern: { seasonSource: 'year', episodeSource: 'date' }, + }); + expect(assign([pending]).assignments.get('aaaaaaaaaaa')).toMatchObject({ season: 2021, episode: 1020304, timestampSource: null }); + }); + + it('leaves out a waiting episode with no time at all', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null }, { + info: {}, pattern: { seasonSource: 'year', episodeSource: 'date' }, + }); + expect([...assign([pending]).noDate]).toEqual(['aaaaaaaaaaa']); + }); +}); diff --git a/server/modules/reorganize/__tests__/titleSnapshot.test.js b/server/modules/reorganize/__tests__/titleSnapshot.test.js new file mode 100644 index 000000000..d02debca5 --- /dev/null +++ b/server/modules/reorganize/__tests__/titleSnapshot.test.js @@ -0,0 +1,171 @@ +jest.mock('../../../db', () => ({ sequelize: { transaction: jest.fn(async (fn) => fn('t')) } })); +jest.mock('../../../models', () => ({ + VideoClassification: { findAll: jest.fn(), update: jest.fn(), destroy: jest.fn(), findByPk: jest.fn(), create: jest.fn() }, + EpisodeConflict: { findAll: jest.fn(), update: jest.fn() }, + Video: { findAll: jest.fn() }, +})); +jest.mock('../../tvShows/titleShowStore', () => ({ + listTitleShows: jest.fn(), saveDefinitions: jest.fn(), titleShowIds: jest.fn(), deleteShows: jest.fn(), +})); +jest.mock('../../tvShows/episodeConflicts', () => ({ release: jest.fn(), recordDuplicate: jest.fn(), recordError: jest.fn() })); +jest.mock('../../tvShows/archiveSuppressor', () => ({ flush: jest.fn() })); + +const CHANNEL_ID = 'UC1'; + +const show = (id, extra = {}) => ({ + id, key: `title:${id}`, name: `Show ${id}`, folderName: `Show ${id}`, libraryFolder: 'TV', position: 0, retired: false, + excludeTerms: [], seasonNames: {}, patterns: [{ id: id * 10, key: `title:${id}#0`, position: 0, text: 't' }], ...extra, +}); + +const row = (youtubeId, extra = {}) => ({ + youtube_id: youtubeId, channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 20, source: 'title', + timestamp_source: null, pattern_id: 30, episode_title: 'x', file_stem: 's', title_opt_out: false, ...extra, +}); + +describe('reorganize titleSnapshot', () => { + let titleSnapshot; + let models; + let store; + let conflicts; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + store = require('../../tvShows/titleShowStore'); + conflicts = require('../../tvShows/episodeConflicts'); + titleSnapshot = require('../titleSnapshot'); + models.Video.findAll.mockResolvedValue([]); + }); + + describe('takeTitleSnapshot', () => { + it('keeps the channel\'s shows, episode rows and conflicts', async () => { + store.listTitleShows.mockResolvedValue([show(3)]); + models.VideoClassification.findAll.mockResolvedValue([{ ...row('aaaaaaaaaaa'), created_at: 'c', updated_at: 'u' }]); + models.EpisodeConflict.findAll.mockResolvedValue([{ youtube_id: 'dup', kind: 'duplicate', show_id: 3, duplicate_of: 'aaaaaaaaaaa', details: '{}' }]); + const snapshot = await titleSnapshot.takeTitleSnapshot(CHANNEL_ID); + expect(snapshot).toEqual({ + shows: [show(3)], + rows: [row('aaaaaaaaaaa')], + conflicts: [{ youtubeId: 'dup', kind: 'duplicate', showId: 3, duplicateOf: 'aaaaaaaaaaa', details: '{}' }], + }); + expect(store.listTitleShows).toHaveBeenCalledWith(CHANNEL_ID, { includeRetired: true }); + }); + }); + + describe('restoreTitleSnapshot', () => { + const channel = { channel_id: CHANNEL_ID }; + + beforeEach(() => { + store.saveDefinitions.mockResolvedValue({ showIds: new Map([['title:3', 3]]), patternIds: new Map([['title:3#0', 31]]) }); + models.VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'aaaaaaaaaaa' }, { youtube_id: 'new00000000' }]); + models.EpisodeConflict.findAll.mockResolvedValue([]); + store.titleShowIds.mockResolvedValue([3]); + }); + + it('saves the shows that were active', async () => { + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [show(3), show(5, { retired: true })], rows: [], conflicts: [] }); + expect(store.saveDefinitions).toHaveBeenCalledWith({ channelId: CHANNEL_ID, drafts: [show(3)], transaction: 't' }); + }); + + it('frees every number first, then puts the stored rows back with their patterns\' new ids', async () => { + const stored = { update: jest.fn() }; + models.VideoClassification.findByPk.mockResolvedValue(stored); + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [show(3)], rows: [row('aaaaaaaaaaa')], conflicts: [] }); + expect(models.VideoClassification.update).toHaveBeenCalledWith( + { season: null, episode: null }, { where: { channel_id: CHANNEL_ID }, transaction: 't' } + ); + expect(stored.update).toHaveBeenCalledWith(expect.objectContaining({ season: 1, episode: 20, pattern_id: 31 }), { transaction: 't' }); + }); + + it('deletes rows the change created', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ update: jest.fn() }); + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [show(3)], rows: [row('aaaaaaaaaaa')], conflicts: [] }); + expect(models.VideoClassification.destroy).toHaveBeenCalledWith({ where: { youtube_id: ['new00000000'] }, transaction: 't' }); + }); + + it('releases conflicts the change recorded', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([{ youtube_id: 'dup', kind: 'duplicate' }]); + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [], rows: [], conflicts: [] }); + expect(conflicts.release).toHaveBeenCalledWith('dup', { transaction: 't' }); + }); + + it('records again a duplicate the change released', async () => { + models.Video.findAll.mockResolvedValue([{ youtubeId: 'dup' }]); + await titleSnapshot.restoreTitleSnapshot(channel, { + shows: [show(3)], rows: [], + conflicts: [{ youtubeId: 'dup', kind: 'duplicate', showId: 3, duplicateOf: 'win', details: JSON.stringify({ season: 1, episode: 20 }) }], + }); + expect(conflicts.recordDuplicate).toHaveBeenCalledWith({ + youtubeId: 'dup', channelId: CHANNEL_ID, showId: 3, season: 1, episode: 20, duplicateOf: 'win', downloaded: true, transaction: 't', + }); + }); + + it('deletes the shows the change added, so a retry can create them again', async () => { + store.titleShowIds.mockResolvedValue([3, 5, 7]); + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [show(3), show(5, { retired: true })], rows: [], conflicts: [] }); + expect(store.deleteShows).toHaveBeenCalledWith([7], { transaction: 't' }); + }); + + it('detaches conflicts from the shows it deletes first', async () => { + store.titleShowIds.mockResolvedValue([3, 7]); + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [show(3)], rows: [], conflicts: [] }); + expect(models.EpisodeConflict.update).toHaveBeenCalledWith({ show_id: null }, { where: { show_id: [7] }, transaction: 't' }); + expect(models.EpisodeConflict.update.mock.invocationCallOrder[0]).toBeLessThan(store.deleteShows.mock.invocationCallOrder[0]); + }); + + it('deletes no show when the change added none', async () => { + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [show(3)], rows: [], conflicts: [] }); + expect(store.deleteShows).not.toHaveBeenCalled(); + }); + + it('puts back a conflict the change pointed at another episode or holder', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([ + { youtube_id: 'dup', kind: 'duplicate', show_id: 3, duplicate_of: 'other', details: JSON.stringify({ season: 1, episode: 21 }) }, + ]); + await titleSnapshot.restoreTitleSnapshot(channel, { + shows: [show(3)], rows: [], + conflicts: [{ youtubeId: 'dup', kind: 'duplicate', showId: 3, duplicateOf: 'win', details: JSON.stringify({ season: 1, episode: 20 }) }], + }); + expect(conflicts.recordDuplicate).toHaveBeenCalledWith(expect.objectContaining({ youtubeId: 'dup', episode: 20, duplicateOf: 'win' })); + }); + + it('releases a conflict whose kind the change changed before recording the old one', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([ + { youtube_id: 'err', kind: 'duplicate', show_id: 3, duplicate_of: 'win', details: JSON.stringify({ season: 1, episode: 20 }) }, + ]); + await titleSnapshot.restoreTitleSnapshot(channel, { + shows: [show(3)], rows: [], + conflicts: [{ youtubeId: 'err', kind: 'classification_error', showId: null, duplicateOf: null, details: JSON.stringify({ message: 'boom' }) }], + }); + expect(conflicts.release).toHaveBeenCalledWith('err', { transaction: 't' }); + expect(conflicts.recordError).toHaveBeenCalledWith({ youtubeId: 'err', channelId: CHANNEL_ID, message: 'boom', transaction: 't' }); + expect(conflicts.release.mock.invocationCallOrder[0]).toBeLessThan(conflicts.recordError.mock.invocationCallOrder[0]); + }); + + it('does not release a conflict the change only pointed elsewhere', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([ + { youtube_id: 'dup', kind: 'duplicate', show_id: 3, duplicate_of: 'other', details: JSON.stringify({ season: 1, episode: 21 }) }, + ]); + await titleSnapshot.restoreTitleSnapshot(channel, { + shows: [show(3)], rows: [], + conflicts: [{ youtubeId: 'dup', kind: 'duplicate', showId: 3, duplicateOf: 'win', details: JSON.stringify({ season: 1, episode: 20 }) }], + }); + expect(conflicts.release).not.toHaveBeenCalled(); + }); + + it('leaves a conflict the change did not touch as it is', async () => { + const details = JSON.stringify({ season: 1, episode: 20 }); + models.EpisodeConflict.findAll.mockResolvedValue([{ youtube_id: 'dup', kind: 'duplicate', show_id: 3, duplicate_of: 'win', details }]); + await titleSnapshot.restoreTitleSnapshot(channel, { + shows: [show(3)], rows: [], conflicts: [{ youtubeId: 'dup', kind: 'duplicate', showId: 3, duplicateOf: 'win', details }], + }); + expect(conflicts.recordDuplicate).not.toHaveBeenCalled(); + }); + + it('applies the archive changes after the restore', async () => { + await titleSnapshot.restoreTitleSnapshot(channel, { shows: [], rows: [], conflicts: [] }); + expect(require('../../tvShows/archiveSuppressor').flush).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/modules/reorganize/__tests__/titleTargets.test.js b/server/modules/reorganize/__tests__/titleTargets.test.js new file mode 100644 index 000000000..898ab91b6 --- /dev/null +++ b/server/modules/reorganize/__tests__/titleTargets.test.js @@ -0,0 +1,128 @@ +jest.mock('../../../models', () => ({ + VideoClassification: { findAll: jest.fn() }, + TvShow: { findAll: jest.fn() }, +})); + +const CHANNEL_ID = 'UC1'; + +function subject(id, youtubeId, extra = {}) { + return { video: { id, youtubeId }, ownerChannelId: CHANNEL_ID, libraryFolder: 'Kids', currentLayout: 'videos', ...extra }; +} + +describe('reorganize titleTargets', () => { + let titleTargets; + let models; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + titleTargets = require('../titleTargets'); + }); + + describe('a title show change', () => { + const draft = (key, extra = {}) => ({ + key, id: key.startsWith('title:') ? Number(key.slice(6)) : null, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV', + patterns: [{ key: `${key}#0`, seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }], ...extra, + }); + const context = (drafts, entries, storedShows = new Map(), stored = new Map()) => ({ + type: 'titleShows', + channel: { channel_id: CHANNEL_ID }, + drafts, + titlePlan: { entries, storedShows, stored }, + }); + const after = (showKey, extra = {}) => ({ showKey, status: 'assigned', season: 1, episode: 20, patternKey: `${showKey}#0`, ...extra }); + + it('sends a video the plan puts in a show to that show', async () => { + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([draft('new:0')], [{ youtubeId: 'aaaaaaaaaaa', after: after('new:0') }]) + ); + expect(result.targets.get(1)).toMatchObject({ showKey: 'new:0', after: { episode: 20 }, pattern: { episodeSource: 'title' } }); + }); + + it('plans a new show to be created', async () => { + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([draft('new:0')], [{ youtubeId: 'aaaaaaaaaaa', after: after('new:0') }]) + ); + expect(result.shows.get('new:0')).toEqual({ + key: 'new:0', kind: 'title', ownerChannelId: CHANNEL_ID, showId: null, action: 'create', + name: 'Beyblade', libraryFolder: 'TV', folderName: 'Beyblade', + }); + }); + + it('plans an existing show whose folder changes to move', async () => { + const storedShows = new Map([['title:3', { key: 'title:3', libraryFolder: 'TV', folderName: 'Old name' }]]); + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([draft('title:3')], [{ youtubeId: 'aaaaaaaaaaa', after: after('title:3') }], storedShows) + ); + expect(result.shows.get('title:3')).toMatchObject({ + action: 'move', showId: 3, previousLocation: { libraryFolder: 'TV', folderName: 'Old name' }, + }); + }); + + it('leaves a video that leaves its show to the channel layout', async () => { + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([], [{ youtubeId: 'aaaaaaaaaaa', after: null }]) + ); + expect(result.targets.has(1)).toBe(false); + }); + + it('leaves a duplicate to the channel layout', async () => { + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([draft('new:0')], [{ youtubeId: 'aaaaaaaaaaa', after: after('new:0', { status: 'duplicate', season: null, episode: null }) }]) + ); + expect(result.targets.has(1)).toBe(false); + }); + + it('passes the stored row along for its stem', async () => { + const stored = new Map([['aaaaaaaaaaa', { showKey: 'new:0', fileStem: 's' }]]); + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([draft('new:0')], [{ youtubeId: 'aaaaaaaaaaa', after: after('new:0') }], new Map(), stored) + ); + expect(result.targets.get(1).stored).toEqual({ showKey: 'new:0', fileStem: 's' }); + }); + }); + + describe('any other change', () => { + const channels = new Map([[CHANNEL_ID, { channel_id: CHANNEL_ID, enabled: true }]]); + const row = (extra = {}) => ({ + youtube_id: 'aaaaaaaaaaa', channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 20, + source: 'title', episode_title: 'Relative', file_stem: 'S01E20 - Relative [aaaaaaaaaaa]', ...extra, + }); + const titleShow = { id: 3, kind: 'title', channel_id: CHANNEL_ID, name: 'Beyblade', library_folder: 'TV', folder_name: 'Beyblade' }; + + it('keeps an episode of an active title show in its show', async () => { + models.VideoClassification.findAll.mockResolvedValue([row()]); + models.TvShow.findAll.mockResolvedValue([titleShow]); + const result = await titleTargets.resolveTitleTargets([subject(1, 'aaaaaaaaaaa')], { type: 'channel' }, channels); + expect(result.targets.get(1)).toMatchObject({ showKey: 'title:3', after: { season: 1, episode: 20, source: 'title' } }); + expect(result.shows.get('title:3')).toMatchObject({ action: 'keep', showId: 3, libraryFolder: 'TV', folderName: 'Beyblade' }); + }); + + it('asks only for active title shows', async () => { + models.VideoClassification.findAll.mockResolvedValue([row()]); + models.TvShow.findAll.mockResolvedValue([]); + await titleTargets.resolveTitleTargets([subject(1, 'aaaaaaaaaaa')], { type: 'channel' }, channels); + expect(models.TvShow.findAll.mock.calls[0][0].where).toEqual({ id: [3], kind: 'title', retired_at: null }); + }); + + it('lets an episode of an unsubscribed channel\'s show follow the channel layout', async () => { + models.VideoClassification.findAll.mockResolvedValue([row()]); + models.TvShow.findAll.mockResolvedValue([titleShow]); + const disabled = new Map([[CHANNEL_ID, { channel_id: CHANNEL_ID, enabled: false }]]); + const result = await titleTargets.resolveTitleTargets([subject(1, 'aaaaaaaaaaa')], { type: 'channel' }, disabled); + expect(result.targets.size).toBe(0); + }); + + it('reads nothing without subjects', async () => { + await titleTargets.resolveTitleTargets([], { type: 'channel' }, channels); + expect(models.VideoClassification.findAll).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/modules/reorganize/changeContext.js b/server/modules/reorganize/changeContext.js index b19463594..4fbed1130 100644 --- a/server/modules/reorganize/changeContext.js +++ b/server/modules/reorganize/changeContext.js @@ -14,11 +14,13 @@ const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey, isMp3Format } = require('../tvShows const { getLayoutResolver } = require('../tvShows/libraryLayouts'); const layoutGuards = require('../tvShows/layoutGuards'); const channelLayout = require('../tvShows/channelLayout'); +const titleShowSaver = require('../tvShows/titleShowSaver'); const { CHANGE_CHANNEL, CHANGE_CHANNEL_LAYOUT, CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER, + CHANGE_TITLE_SHOWS, } = require('./constants'); const LAYOUTS = new Set([LAYOUT_VIDEOS, LAYOUT_TV]); @@ -99,6 +101,8 @@ async function resolveFolderLayoutChange(raw, base) { } if (layout === LAYOUT_TV) { layoutGuards.assertNoMp3Users(await layoutGuards.usersOfFolder(folder), 'this folder'); + } else { + await layoutGuards.assertNoTitleShows(folder); } const key = folderKey(folder); return { @@ -142,6 +146,31 @@ async function resolveDefaultSubfolderChange(raw, base) { }; } +// A channel's title shows (and episode assignments) after the change: the +// channel stays in its folder, and the title plan says where its videos go. +async function resolveTitleShowsChange(raw, base) { + const channel = await findChannel(raw.channelId); + const overrides = raw.overrides === undefined || raw.overrides === null ? [] : raw.overrides; + if (!Array.isArray(raw.shows) || !Array.isArray(overrides)) throw badRequest('shows and overrides must be lists'); + const { drafts, plan } = await titleShowSaver.prepare({ channel, rawShows: raw.shows, rawOverrides: overrides }); + const folder = libraryFolderOf(channel.sub_folder, base.defaultBefore); + return { + ...base, + type: CHANGE_TITLE_SHOWS, + stored: { type: CHANGE_TITLE_SHOWS, channelId: channel.channel_id, shows: raw.shows, overrides }, + scope: channel.channel_id, + label: `${channel.title || channel.uploader || channel.channel_id}: shows`, + channel, + fromFolder: folder, + toFolder: folder, + layoutAfter: base.layoutBefore, + defaultAfter: base.defaultBefore, + folderAfter: base.folderBefore, + drafts, + titlePlan: plan, + }; +} + /** * Resolve a change requested by the API. * @@ -150,6 +179,7 @@ async function resolveDefaultSubfolderChange(raw, base) { * | { type: 'channel', channelId, subFolder } * | { type: 'folderLayout', folder, layout } * | { type: 'defaultSubfolder', value } + * | { type: 'titleShows', channelId, shows, overrides? } * @returns {Promise<Object>} The resolved change. Errors carry .status (400/404/409). */ async function resolveChange(raw) { @@ -176,6 +206,8 @@ async function resolveChange(raw) { return resolveFolderLayoutChange(raw, base); case CHANGE_DEFAULT_SUBFOLDER: return resolveDefaultSubfolderChange(raw, base); + case CHANGE_TITLE_SHOWS: + return resolveTitleShowsChange(raw, base); default: throw badRequest('Unknown change type'); } diff --git a/server/modules/reorganize/changeScope.js b/server/modules/reorganize/changeScope.js index 17331207a..afa02d019 100644 --- a/server/modules/reorganize/changeScope.js +++ b/server/modules/reorganize/changeScope.js @@ -7,6 +7,7 @@ * - A library folder's videos: every row whose file is in that folder, * tracked or not. * - The default subfolder's videos: those of every channel that follows it. + * - A title show change: the downloaded videos its title plan moves. * * Only rows with a file inside the downloads folder are moved; rows marked * missing are left alone. @@ -24,7 +25,7 @@ const { resolveChannelFolderName, buildSubfolderSegment } = require('../filesyst const { GLOBAL_DEFAULT_SENTINEL } = require('../filesystem/constants'); const { LAYOUT_TV, folderKey } = require('../tvShows/constants'); const { resolveChannelDirectory } = require('../tvShows/channelFolders'); -const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER } = require('./constants'); +const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER, CHANGE_TITLE_SHOWS } = require('./constants'); const VIDEO_ATTRIBUTES = [ 'id', 'youtubeId', 'channel_id', 'youTubeVideoName', 'youTubeChannelName', 'originalDate', 'removed', @@ -149,6 +150,17 @@ async function videosOfFolder(libraryFolder, context, channels) { .filter(Boolean); } +// A title show change moves exactly the downloaded videos its title plan +// says must move. +async function videosOfTitleChange(context, channels) { + const ids = context.titlePlan.entries.filter((entry) => entry.moves).map((entry) => entry.youtubeId); + if (ids.length === 0) return []; + const videos = await Video.findAll({ where: { youtubeId: ids, removed: false }, attributes: VIDEO_ATTRIBUTES, raw: true }); + return videos + .map((video) => toSubject(video, context.channel.channel_id, channels, context)) + .filter(Boolean); +} + /** * @param {Object} context - changeContext.resolveChange's result * @returns {Promise<{subjects: Array<Object>, channels: Map<string, Object>}>} @@ -161,6 +173,8 @@ async function selectSubjects(context) { subjects = await videosOfChannel(channels.get(context.channel.channel_id) || context.channel, context, channels); } else if (context.type === CHANGE_FOLDER_LAYOUT) { subjects = await videosOfFolder(context.folder, context, channels); + } else if (context.type === CHANGE_TITLE_SHOWS) { + subjects = await videosOfTitleChange(context, channels); } else if (context.type === CHANGE_DEFAULT_SUBFOLDER) { for (const channel of channels.values()) { if (channel.sub_folder !== GLOBAL_DEFAULT_SENTINEL) continue; diff --git a/server/modules/reorganize/constants.js b/server/modules/reorganize/constants.js index 7c1f54e6c..b75a35805 100644 --- a/server/modules/reorganize/constants.js +++ b/server/modules/reorganize/constants.js @@ -9,6 +9,12 @@ const CHANGE_DEFAULT_SUBFOLDER = 'defaultSubfolder'; // Accepted on the API and normalized to CHANGE_CHANNEL with the folder the // Channel Settings layout toggle picks. const CHANGE_CHANNEL_LAYOUT = 'channelLayout'; +// A channel's title shows (or episode assignments) changed in a way that +// moves downloaded videos. +const CHANGE_TITLE_SHOWS = 'titleShows'; + +// What a change does to a show it plans. +const SHOW_ACTION = Object.freeze({ KEEP: 'keep', CREATE: 'create', MOVE: 'move' }); const OPERATION_STATUS = Object.freeze({ RUNNING: 'running', @@ -30,6 +36,8 @@ const PROBLEM = Object.freeze({ NO_NAME: 'no-name', NO_DATE: 'no-date', UNSAFE_NAME: 'unsafe-name', + // A title episode waiting for its upload year whose number another video holds. + EPISODE_TAKEN: 'episode-taken', }); // Notes about a planned move, reported by the preview. @@ -52,6 +60,8 @@ module.exports = { CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER, CHANGE_CHANNEL_LAYOUT, + CHANGE_TITLE_SHOWS, + SHOW_ACTION, OPERATION_STATUS, ITEM_STATUS, PROBLEM, diff --git a/server/modules/reorganize/destinationPlanner.js b/server/modules/reorganize/destinationPlanner.js index ce9e1793d..943bde232 100644 --- a/server/modules/reorganize/destinationPlanner.js +++ b/server/modules/reorganize/destinationPlanner.js @@ -23,8 +23,11 @@ const { LAYOUT_TV, folderKey } = require('../tvShows/constants'); const { STATUS_ASSIGNED } = require('../tvShows/episodeAllocator'); const { releaseTime, parseDateEpisodeCode, assignDateEpisodes, SOURCE_UPLOAD_DATE } = require('../tvShows/dateNumbering'); const { buildEpisodeStem, episodeFileName, seasonFolderName } = require('../tvShows/episodeNaming'); +const titleShowStore = require('../tvShows/titleShowStore'); +const { KIND_TITLE_SHOW } = require('../tvShows/constants'); const { renderMovieNames } = require('./movieNameRenderer'); const { plannedShowDirectory } = require('./showPlanner'); +const { assignTitleEpisodes } = require('./titleAssignments'); const { PROBLEM, FLAG, CHANGE_FOLDER_LAYOUT } = require('./constants'); // Partial downloads and copies left by an interrupted move are not the video's files. @@ -62,14 +65,22 @@ async function statFile(filePath) { const stat = await fs.promises.stat(filePath); return { size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) }; } catch (err) { - if (err.code === 'ENOENT') return null; + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null; throw err; } } async function existsOtherThan(target, source) { if (target === source) return false; - return (await statFile(target)) !== null; + try { + await fs.promises.stat(target); + return true; + } catch (err) { + if (err.code === 'ENOENT') return false; + // A file where a folder of the path belongs blocks the move as surely as a file at the path. + if (err.code === 'ENOTDIR') return true; + throw err; + } } /** @@ -222,6 +233,45 @@ async function numberShow(show, entries, stored) { return { assignments, flags, noDate }; } +// Numbers the title plan gives videos of a show, by season: a video waiting +// for an upload-time number must keep clear of them. +function takenByTitlePlan(context, showKey) { + const taken = new Map(); + for (const entry of (context.titlePlan && context.titlePlan.entries) || []) { + const { after } = entry; + if (!after || after.showKey !== showKey || after.status !== STATUS_ASSIGNED || after.season === null || after.episode === null) continue; + if (!taken.has(after.season)) taken.set(after.season, new Set()); + taken.get(after.season).add(after.episode); + } + return taken; +} + +/** + * Episode assignments for the videos going to one title show. + * @returns {Promise<{assignments: Map<string, Object>, noDate: Set<string>, taken: Set<string>}>} + */ +async function numberTitleShow(show, entries, titleTargets, context) { + const marks = show.showId ? await titleShowStore.highWaterMarks([show.showId]) : new Map(); + const highWater = new Map([...marks].map(([key, value]) => [Number(key.slice(key.lastIndexOf('|') + 1)), value])); + return assignTitleEpisodes({ + show, + entries: entries.map(({ subject, info }) => { + const target = titleTargets.get(subject.video.id); + return { + youtubeId: subject.video.youtubeId, + title: subject.video.youTubeVideoName, + info, + downloadedAt: subject.video.last_downloaded_at, + after: target.after, + pattern: target.pattern || {}, + stored: target.stored, + }; + }), + taken: takenByTitlePlan(context, show.key), + highWater, + }); +} + function problemOf(subject, kind, detail = null) { const { video } = subject; return { videoId: video.id, youtubeId: video.youtubeId, title: video.youTubeVideoName, problem: kind, detail }; @@ -241,10 +291,11 @@ function isInside(baseDir, dir) { * @param {Array<Object>} params.subjects - changeScope subjects * @param {Object} params.context - resolved change * @param {Map<number, {libraryFolder: string, layout: string}>} params.targets - showPlanner targets - * @param {Map<string, Object>} params.shows - showPlanner shows by owner channel id + * @param {Map<string, Object>} params.shows - showPlanner shows by owner channel id or title show key + * @param {Map<number, Object>} [params.titleTargets] - showPlanner title targets by Videos.id * @returns {Promise<{items: Array<Object>, problems: Array<Object>, unchanged: number}>} */ -async function planDestinations({ subjects, context, targets, shows }) { +async function planDestinations({ subjects, context, targets, shows, titleTargets = new Map() }) { const config = configModule.getConfig() || {}; const baseDir = configModule.directoryPath; const infos = new Map(); @@ -255,9 +306,9 @@ async function planDestinations({ subjects, context, targets, shows }) { for (const subject of subjects) { const target = targets.get(subject.video.id); if (target.layout === LAYOUT_TV) { - const owner = subject.ownerChannelId; - if (!tvEntries.has(owner)) tvEntries.set(owner, []); - tvEntries.get(owner).push({ subject, info: infos.get(subject.video.youtubeId) }); + const key = target.showKey || subject.ownerChannelId; + if (!tvEntries.has(key)) tvEntries.set(key, []); + tvEntries.get(key).push({ subject, info: infos.get(subject.video.youtubeId) }); } else { movieSubjects.push(subject); } @@ -267,11 +318,16 @@ async function planDestinations({ subjects, context, targets, shows }) { const assignments = new Map(); const flags = new Map(); const noDate = new Set(); - for (const [owner, entries] of tvEntries) { - const numbered = await numberShow(shows.get(owner), entries, stored); + const episodeTaken = new Set(); + for (const [key, entries] of tvEntries) { + const show = shows.get(key); + const numbered = show.kind === KIND_TITLE_SHOW + ? await numberTitleShow(show, entries, titleTargets, context) + : await numberShow(show, entries, stored); for (const [id, value] of numbered.assignments) assignments.set(id, value); - for (const [id, value] of numbered.flags) flags.set(id, value); + for (const [id, value] of numbered.flags || []) flags.set(id, value); for (const id of numbered.noDate) noDate.add(id); + for (const id of numbered.taken || []) episodeTaken.add(id); } const names = await renderMovieNames(movieSubjects.map((subject) => ({ youtubeId: subject.video.youtubeId, info: infos.get(subject.video.youtubeId), @@ -292,9 +348,14 @@ async function planDestinations({ subjects, context, targets, shows }) { problems.push(problemOf(subject, PROBLEM.NO_DATE)); continue; } + if (episodeTaken.has(video.youtubeId)) { + problems.push(problemOf(subject, PROBLEM.EPISODE_TAKEN)); + continue; + } classification = assignments.get(video.youtubeId); stem = classification.fileStem; - destDir = path.join(plannedShowDirectory(shows.get(subject.ownerChannelId)), seasonFolderName(classification.season)); + const show = shows.get(target.showKey || subject.ownerChannelId); + destDir = path.join(plannedShowDirectory(show), seasonFolderName(classification.season)); } else { const rendered = names.get(video.youtubeId); if (!rendered) { @@ -332,7 +393,10 @@ async function planDestinations({ subjects, context, targets, shows }) { continue; } const itemFlags = [...(flags.get(video.youtubeId) || [])]; - if (context.type !== CHANGE_FOLDER_LAYOUT && subject.ownerChannel + // A title show episode sits in its show's TV folder by design, not by a download override. + const storedRow = context.titlePlan && context.titlePlan.stored ? context.titlePlan.stored.get(video.youtubeId) : null; + const inTitleShow = Boolean(storedRow && storedRow.showKind === KIND_TITLE_SHOW); + if (context.type !== CHANGE_FOLDER_LAYOUT && subject.ownerChannel && !inTitleShow && folderKey(subject.libraryFolder) !== folderKey(context.folderBefore(subject.ownerChannel))) { itemFlags.push(FLAG.OVERRIDE_PLACED); } diff --git a/server/modules/reorganize/followUp.js b/server/modules/reorganize/followUp.js index 820d104e4..c01083796 100644 --- a/server/modules/reorganize/followUp.js +++ b/server/modules/reorganize/followUp.js @@ -32,7 +32,7 @@ const serverRegistry = require('../mediaServers/serverRegistry'); const watchStatusPushBack = require('../mediaServers/watchStatusPushBack'); const { cleanupEmptyChannelDirectory } = require('../filesystem/directoryManager'); const { cleanupOrphanShowFolder, resolveLibraryFolder } = require('../filesystem/showFolderCleanup'); -const { LAYOUT_TV } = require('../tvShows/constants'); +const { LAYOUT_TV, KIND_TITLE_SHOW } = require('../tvShows/constants'); const { showDirectory } = require('../tvShows/channelFolders'); // Playlists are synced again once the servers have had time to index the @@ -81,16 +81,30 @@ async function safely(label, context, action) { * @param {Object} params * @param {Array<Object>} params.items - Done items, each { channelId, plan, classification } * @param {Array<Object>} params.shows - Pinned shows (with showId) + * @param {string|null} [params.titleShowChannelId] - The channel whose title shows the change edited: every + * active one is rewritten, since a new name or season name changes no file */ -async function finishFiles({ items, shows }) { - const showIds = new Set(items.filter((item) => item.classification).map((item) => item.classification.ownerChannelId)); +async function finishFiles({ items, shows, titleShowChannelId = null }) { + // A channel show by its owner channel, a title show by its key. + const receiving = new Set(items.filter((item) => item.classification) + .map((item) => item.classification.showKey || item.classification.ownerChannelId)); + const written = new Set(); for (const planned of shows) { - if (!planned.showId || !showIds.has(planned.ownerChannelId)) continue; + if (!planned.showId || !receiving.has(planned.key || planned.ownerChannelId)) continue; + written.add(planned.showId); await safely('Could not write the show metadata after a reorganize', { showId: planned.showId }, async () => { const show = await TvShow.findByPk(planned.showId); if (show) await sidecarWriter.writeShowMetadata({ show, showDir: showDirectory(show), plot: planned.plot || null }); }); } + if (titleShowChannelId) { + await safely('Could not write the title show metadata after a reorganize', { channelId: titleShowChannelId }, async () => { + const titleShows = await TvShow.findAll({ where: { channel_id: titleShowChannelId, kind: KIND_TITLE_SHOW, retired_at: null } }); + for (const show of titleShows) { + if (!written.has(show.id)) await sidecarWriter.writeShowMetadata({ show, showDir: showDirectory(show) }); + } + }); + } const channelFolders = new Map(); const leftFolders = new Map(); diff --git a/server/modules/reorganize/index.js b/server/modules/reorganize/index.js index 710a022b8..7559af9c8 100644 --- a/server/modules/reorganize/index.js +++ b/server/modules/reorganize/index.js @@ -1,12 +1,16 @@ /** * Reorganize: move already-downloaded files to where changed settings say * they belong, when a channel, a library folder or the default subfolder - * switches between the Videos and TV layouts. + * switches between the Videos and TV layouts, or a channel's title shows + * change. * * - reorganizeLock: the running reorganize; holds downloads, refuses tasks and changes * - changeContext: a settings change resolved into the world before and after it * - changeScope: which videos a change moves, and their owner channels * - showPlanner: destinations, and the shows a change creates or moves + * - titleTargets: the videos that belong to a title show, and its planned show + * - titleAssignments: numbers and stems of videos moving into a title show + * - titleSnapshot: a title show change's previous state, to undo it * - destinationPlanner: every video's files, names and episode numbers * - movieNameRenderer: movie-style names rendered by yt-dlp from stored info * - revision: the plan's revision token diff --git a/server/modules/reorganize/itemExecutor.js b/server/modules/reorganize/itemExecutor.js index 0a45c6e84..6e7708860 100644 --- a/server/modules/reorganize/itemExecutor.js +++ b/server/modules/reorganize/itemExecutor.js @@ -21,8 +21,10 @@ const { unchangedSinceRead, GUARDED_COLUMNS } = require('../videoRowGuard'); const { moveFileNoClobber } = require('../filesystem/fileOperations'); const { isDirectoryEffectivelyEmpty, removeDirectoryResilient, isVideoDirectoryFor } = require('../filesystem/directoryManager'); const { resolveLibraryFolder, locateEpisodeFolders, cleanupEmptyShowFolders } = require('../filesystem/showFolderCleanup'); -const { LAYOUT_TV } = require('../tvShows/constants'); +const titleShowStore = require('../tvShows/titleShowStore'); +const { LAYOUT_TV, KIND_TITLE_SHOW } = require('../tvShows/constants'); const { STATUS_ASSIGNED } = require('../tvShows/episodeAllocator'); +const { SOURCE } = require('../tvShows/titleNumbering'); function itemError(message) { return new Error(message); @@ -63,6 +65,7 @@ async function loadVideo(videoId) { } async function storeClassification(youtubeId, classification, showId) { + const titleShow = classification.kind === KIND_TITLE_SHOW; const values = { channel_id: classification.ownerChannelId, show_id: showId, @@ -71,16 +74,21 @@ async function storeClassification(youtubeId, classification, showId) { episode: classification.episode, source: classification.source, timestamp_source: classification.timestampSource || null, - pattern_id: null, episode_title: classification.episodeTitle, file_stem: classification.fileStem, }; + // A title show's row was written with its pattern when the change was + // applied; a channel show has none. + if (!titleShow) values.pattern_id = null; const row = await VideoClassification.findByPk(youtubeId); if (row) { await row.update(values); } else { await VideoClassification.create({ youtube_id: youtubeId, ...values }); } + if (titleShow && classification.source === SOURCE.ORDER) { + await titleShowStore.raiseHighWater(showId, classification.season, classification.episode); + } } async function statOrNull(filePath) { @@ -176,7 +184,8 @@ async function cleanupSources(plan, youtubeId) { * * @param {Object} record - tv_reorganize_items row * @param {Object} params - * @param {(ownerChannelId: string) => number|null} params.showIdFor - The pinned show of an owner channel + * @param {(showKey: string) => number|null} params.showIdFor - The pinned show of an owner channel (channel + * shows) or of a title show key * @returns {Promise<void>} Throws with a user-facing message when the video can't move */ async function executeItem(record, { showIdFor }) { @@ -192,7 +201,7 @@ async function executeItem(record, { showIdFor }) { assertInsideDownloads(plan.files); if (classification) { - const showId = showIdFor(classification.ownerChannelId); + const showId = showIdFor(classification.showKey || classification.ownerChannelId); if (!showId) throw itemError('The video\'s show could not be found.'); await storeClassification(record.youtube_id, classification, showId); } diff --git a/server/modules/reorganize/operationRunner.js b/server/modules/reorganize/operationRunner.js index 5c8a803b4..90d79e3f3 100644 --- a/server/modules/reorganize/operationRunner.js +++ b/server/modules/reorganize/operationRunner.js @@ -23,7 +23,10 @@ const { buildPlan, summarizePlan, applyRefusal } = require('./planner'); const { applySettings, rollbackSettings } = require('./settingsApplier'); const { executeItem } = require('./itemExecutor'); const followUp = require('./followUp'); -const { OPERATION_STATUS, ITEM_STATUS, PROGRESS_MESSAGE_TYPE, CHANGE_CHANNEL } = require('./constants'); +const { OPERATION_STATUS, ITEM_STATUS, PROGRESS_MESSAGE_TYPE, CHANGE_CHANNEL, CHANGE_TITLE_SHOWS } = require('./constants'); + +// Changes made to one channel, which the lock covers from the start. +const CHANNEL_CHANGES = new Set([CHANGE_CHANNEL, CHANGE_TITLE_SHOWS]); // Scheduled tasks that read or write downloaded files or their rows, refused // while a reorganize runs (and which refuse a reorganize while they run). @@ -164,7 +167,7 @@ class OperationRunner { label: plan.context.label, channelIds: [ ...plan.items.map((item) => item.channelId), - ...(plan.context.type === CHANGE_CHANNEL ? [plan.context.channel.channel_id] : []), + ...(CHANNEL_CHANGES.has(plan.context.type) ? [plan.context.channel.channel_id] : []), ], videoIds: plan.items.map((item) => item.videoId), youtubeIds: plan.items.map((item) => item.youtubeId), @@ -328,7 +331,8 @@ class OperationRunner { shows = await applySettings({ change: settings.change, shows, layoutBefore }); await operationStore.markSettingsApplied(operation, shows); } - const showIds = new Map(shows.map((show) => [show.ownerChannelId, show.showId])); + // A channel show by its owner channel, a title show by its key. + const showIds = new Map(shows.map((show) => [show.key || show.ownerChannelId, show.showId])); for (const item of items) { try { @@ -350,7 +354,7 @@ class OperationRunner { const counts = await operationStore.refreshCounts(operation); if (counts.moved === 0 && counts.failed > 0) { - await rollbackSettings({ change: settings.change, shows }); + await rollbackSettings({ change: settings.change, shows, snapshot: settings.snapshot || null }); await operation.update({ settings_applied: false }); status = OPERATION_STATUS.FAILED; error = NOTHING_MOVED_MESSAGE; @@ -359,7 +363,11 @@ class OperationRunner { } finished = (await operationStore.itemsWithStatus(operation.id, [ITEM_STATUS.DONE])).map(describeDone); try { - await followUp.finishFiles({ items: finished, shows }); + await followUp.finishFiles({ + items: finished, + shows, + titleShowChannelId: settings.change.type === CHANGE_TITLE_SHOWS ? settings.change.channelId : null, + }); } catch (err) { logger.warn({ err, operationId: operation.id }, 'Could not finish the metadata and folders after a reorganize'); } @@ -369,7 +377,7 @@ class OperationRunner { error = err.message; if (operation.settings_applied && moved.size === 0) { try { - await rollbackSettings({ change: settings.change, shows }); + await rollbackSettings({ change: settings.change, shows, snapshot: settings.snapshot || null }); await operation.update({ settings_applied: false }); } catch (rollbackErr) { logger.error({ err: rollbackErr, operationId: operation.id }, 'Could not undo the settings change of a failed reorganize'); diff --git a/server/modules/reorganize/operationStore.js b/server/modules/reorganize/operationStore.js index bd8fdd359..bee313f97 100644 --- a/server/modules/reorganize/operationStore.js +++ b/server/modules/reorganize/operationStore.js @@ -46,7 +46,12 @@ async function createOperation(plan) { const operation = await TvReorganizeOperation.create({ change_type: context.type, scope: String(context.scope || ''), - settings_change: JSON.stringify({ change: context.stored, label: context.label, shows: plan.shows }), + settings_change: JSON.stringify({ + change: context.stored, + label: context.label, + shows: plan.shows, + ...(plan.snapshot ? { snapshot: plan.snapshot } : {}), + }), settings_applied: false, revision: plan.revision, status: OPERATION_STATUS.RUNNING, @@ -69,7 +74,7 @@ async function createOperation(plan) { }); } -/** @returns {{change: Object, label: string, shows: Array<Object>}} */ +/** @returns {{change: Object, label: string, shows: Array<Object>, snapshot?: Object}} */ function settingsOf(operation) { return JSON.parse(operation.settings_change); } @@ -156,6 +161,7 @@ function describeOperation(operation, failedItems = []) { finishedAt: operation.finished_at, failedItems: failedItems.map((item) => ({ id: item.id, youtubeId: item.youtube_id, title: item.title, channelId: item.channel_id, error: item.error, + filesMoved: Boolean(item.files_moved), })), }; } diff --git a/server/modules/reorganize/planner.js b/server/modules/reorganize/planner.js index b4dff713f..f16d09c45 100644 --- a/server/modules/reorganize/planner.js +++ b/server/modules/reorganize/planner.js @@ -17,19 +17,34 @@ const { selectSubjects } = require('./changeScope'); const { planShows } = require('./showPlanner'); const { planDestinations } = require('./destinationPlanner'); const { planRevision } = require('./revision'); -const { PREVIEW_ITEM_LIMIT, PROBLEM, FLAG } = require('./constants'); +const { takeTitleSnapshot } = require('./titleSnapshot'); +const { PREVIEW_ITEM_LIMIT, PROBLEM, FLAG, CHANGE_TITLE_SHOWS } = require('./constants'); + +// Date-numbered codes (channel shows, adopted preset names, year/date title +// patterns) are 8 digits; title, order and manual numbers 2. +const DATE_SOURCES = new Set(['date', 'adopted']); + +// A planned show's key: its owner channel for a channel show, its show key for a title show. +function showKeyOf(show) { + return show.key || show.ownerChannelId; +} + +function itemShowKey(item) { + return (item.classification && item.classification.showKey) || item.channelId; +} /** * @param {Object} rawChange - The requested change (changeContext.resolveChange) - * @returns {Promise<Object>} { context, items, problems, unchanged, shows, revision } + * @returns {Promise<Object>} { context, items, problems, unchanged, shows, revision, snapshot } + * snapshot: a title show change's shows and episode rows before it (to undo it), else null */ async function buildPlan(rawChange) { const context = await resolveChange(rawChange); - const { subjects } = await selectSubjects(context); - const { targets, shows } = await planShows(subjects, context); - const { items, problems, unchanged } = await planDestinations({ subjects, context, targets, shows }); - const tvOwners = new Set(items.filter((item) => item.layout === LAYOUT_TV).map((item) => item.channelId)); - const plannedShows = [...shows.values()].filter((show) => tvOwners.has(show.ownerChannelId)); + const { subjects, channels } = await selectSubjects(context); + const { targets, shows, titleTargets } = await planShows(subjects, context, channels); + const { items, problems, unchanged } = await planDestinations({ subjects, context, targets, shows, titleTargets }); + const receiving = new Set(items.filter((item) => item.layout === LAYOUT_TV).map(itemShowKey)); + const plannedShows = [...shows.values()].filter((show) => receiving.has(showKeyOf(show))); return { context, items, @@ -37,6 +52,7 @@ async function buildPlan(rawChange) { unchanged, shows: plannedShows, revision: planRevision({ change: context.stored, shows: plannedShows, items }), + snapshot: context.type === CHANGE_TITLE_SHOWS ? await takeTitleSnapshot(context.channel.channel_id) : null, }; } @@ -102,7 +118,11 @@ function describeItem(item) { from: relative(from), to: relative(to), episode: item.classification - ? episodeCode({ season: item.classification.season, episode: item.classification.episode, dateNumbered: true }) + ? episodeCode({ + season: item.classification.season, + episode: item.classification.episode, + dateNumbered: !item.classification.source || DATE_SOURCES.has(item.classification.source), + }) : null, flags: item.flags, }; @@ -135,6 +155,7 @@ async function summarizePlan(plan, { blocked = null } = {}) { noName: problemCount(PROBLEM.NO_NAME), noDate: problemCount(PROBLEM.NO_DATE), unsafeName: problemCount(PROBLEM.UNSAFE_NAME), + episodeTaken: problemCount(PROBLEM.EPISODE_TAKEN), overridePlaced: flagged(FLAG.OVERRIDE_PLACED), adopted: flagged(FLAG.ADOPTED), uploadDateOnly: flagged(FLAG.UPLOAD_DATE_ONLY), @@ -143,6 +164,7 @@ async function summarizePlan(plan, { blocked = null } = {}) { }, shows: plan.shows.map((show) => ({ name: show.name, libraryFolder: show.libraryFolder, folderName: show.folderName, action: show.action, + kind: show.kind || 'channel', })), // The TV folders videos move into: the preview shows their media server libraries. tvFolders: [...new Set(items.filter((item) => item.layout === LAYOUT_TV).map((item) => item.libraryFolder || ''))], diff --git a/server/modules/reorganize/revision.js b/server/modules/reorganize/revision.js index d352ef9cb..fc6df667d 100644 --- a/server/modules/reorganize/revision.js +++ b/server/modules/reorganize/revision.js @@ -18,7 +18,7 @@ function planRevision({ change, shows, items }) { const payload = { change, shows: shows - .map((show) => [show.ownerChannelId, show.action, show.libraryFolder, show.folderName]) + .map((show) => [show.key || show.ownerChannelId, show.action, show.libraryFolder, show.folderName]) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)), items: items.map((item) => [ item.youtubeId, diff --git a/server/modules/reorganize/settingsApplier.js b/server/modules/reorganize/settingsApplier.js index 8e2762578..e48b05976 100644 --- a/server/modules/reorganize/settingsApplier.js +++ b/server/modules/reorganize/settingsApplier.js @@ -18,10 +18,12 @@ const libraryLayouts = require('../tvShows/libraryLayouts'); const layoutGuards = require('../tvShows/layoutGuards'); const channelLayout = require('../tvShows/channelLayout'); const { syncPlexIgnore } = require('../tvShows/libraryFolders'); -const { LAYOUT_TV, folderKey } = require('../tvShows/constants'); +const titleShowSaver = require('../tvShows/titleShowSaver'); +const { LAYOUT_TV, folderKey, KIND_TITLE_SHOW } = require('../tvShows/constants'); const { SHOW_ACTION } = require('./showPlanner'); const { libraryFolderOf } = require('./changeContext'); -const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER } = require('./constants'); +const { restoreTitleSnapshot } = require('./titleSnapshot'); +const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER, CHANGE_TITLE_SHOWS } = require('./constants'); function sameLocation(show, planned) { return folderKey(show.library_folder) === folderKey(planned.libraryFolder) @@ -29,7 +31,8 @@ function sameLocation(show, planned) { } async function pinShow(planned) { - if (planned.action === SHOW_ACTION.KEEP) return { ...planned }; + // Title shows are saved with the rest of a title show change. + if (planned.action === SHOW_ACTION.KEEP || planned.kind === KIND_TITLE_SHOW) return { ...planned }; // Also reached again after a restart: the show may already be in place. const existing = planned.showId ? await TvShow.findByPk(planned.showId) @@ -78,6 +81,21 @@ async function setFolderLayout(folder, layout) { } } +async function findChannelOrFail(channelId) { + const channel = await Channel.findOne({ where: { channel_id: channelId } }); + if (!channel) throw new Error('The channel no longer exists.'); + return channel; +} + +// A title show change: the channel's shows and every episode row as planned +// (the plan is recomputed: the lock is held, so it is the one previewed). +async function applyTitleShows(change, pinned) { + const channel = await findChannelOrFail(change.channelId); + const { drafts, plan } = await titleShowSaver.prepare({ channel, rawShows: change.shows, rawOverrides: change.overrides || [] }); + const { showIds } = await titleShowSaver.applyPrepared({ channel, drafts, plan }); + return pinned.map((show) => (show.kind === KIND_TITLE_SHOW ? { ...show, showId: showIds.get(show.key) || show.showId } : show)); +} + /** * Apply the change and pin its shows. * @@ -120,6 +138,8 @@ async function applySettings({ change, shows, layoutBefore }) { } await subfolderModule.register(change.value); setDefaultSubfolder(change.value); + } else if (change.type === CHANGE_TITLE_SHOWS) { + return applyTitleShows(change, pinned); } return pinned; } @@ -131,8 +151,9 @@ async function applySettings({ change, shows, layoutBefore }) { * @param {Object} params * @param {Object} params.change * @param {Array<Object>} params.shows - Pinned shows + * @param {Object} [params.snapshot] - A title show change's shows and episodes before it */ -async function rollbackSettings({ change, shows }) { +async function rollbackSettings({ change, shows, snapshot = null }) { for (const show of shows) { if (show.action !== SHOW_ACTION.MOVE || !show.previousLocation || !show.showId) continue; const row = await TvShow.findByPk(show.showId); @@ -144,6 +165,8 @@ async function rollbackSettings({ change, shows }) { await setFolderLayout(change.folder, change.previousLayout); } else if (change.type === CHANGE_DEFAULT_SUBFOLDER) { setDefaultSubfolder(change.previousValue); + } else if (change.type === CHANGE_TITLE_SHOWS && snapshot) { + await restoreTitleSnapshot(await findChannelOrFail(change.channelId), snapshot); } } diff --git a/server/modules/reorganize/showPlanner.js b/server/modules/reorganize/showPlanner.js index dd716599d..016dba43c 100644 --- a/server/modules/reorganize/showPlanner.js +++ b/server/modules/reorganize/showPlanner.js @@ -15,10 +15,9 @@ const { resolveChannelFolderName } = require('../filesystem/pathBuilder'); const { ROOT_SENTINEL } = require('../filesystem/constants'); const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey } = require('../tvShows/constants'); const { showDirectory } = require('../tvShows/channelFolders'); -const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT } = require('./constants'); +const { CHANGE_CHANNEL, CHANGE_FOLDER_LAYOUT, SHOW_ACTION } = require('./constants'); const { libraryRootOf } = require('./changeScope'); - -const SHOW_ACTION = Object.freeze({ KEEP: 'keep', CREATE: 'create', MOVE: 'move' }); +const { resolveTitleTargets } = require('./titleTargets'); /** * @param {Object} subject - changeScope subject @@ -86,18 +85,30 @@ async function planShow({ ownerChannelId, subject, libraryFolder, context, reser } /** - * Plan the show of every owner channel with videos going to a TV folder. + * Plan the show of every video going to a TV folder: its title show (an + * episode of an active title show stays in, or goes to, that show), else its + * owner channel's show. * * @param {Array<Object>} subjects - changeScope subjects * @param {Object} context - resolved change - * @returns {Promise<{targets: Map<number, {libraryFolder: string, layout: string}>, shows: Map<string, Object>}>} - * targets by Videos.id; shows by owner channel id + * @param {Map<string, Object>} [channels] - channels rows by id + * @returns {Promise<{targets: Map<number, Object>, shows: Map<string, Object>, titleTargets: Map<number, Object>}>} + * targets by Videos.id ({ libraryFolder, layout }, plus showKey for a title show); shows by + * owner channel id (channel shows) or show key (title shows); titleTargets by Videos.id */ -async function planShows(subjects, context) { +async function planShows(subjects, context, channels = new Map()) { const targets = new Map(); const shows = new Map(); const reserved = new Set(); + const title = await resolveTitleTargets(subjects, context, channels); for (const subject of subjects) { + const titleTarget = title.targets.get(subject.video.id); + if (titleTarget) { + const show = title.shows.get(titleTarget.showKey); + targets.set(subject.video.id, { libraryFolder: show.libraryFolder, layout: LAYOUT_TV, showKey: show.key }); + shows.set(show.key, show); + continue; + } const target = targetOf(subject, context); targets.set(subject.video.id, target); if (target.layout !== LAYOUT_TV || shows.has(subject.ownerChannelId)) continue; @@ -105,7 +116,7 @@ async function planShows(subjects, context) { ownerChannelId: subject.ownerChannelId, subject, libraryFolder: target.libraryFolder, context, reserved, })); } - return { targets, shows }; + return { targets, shows, titleTargets: title.targets }; } /** diff --git a/server/modules/reorganize/titleAssignments.js b/server/modules/reorganize/titleAssignments.js new file mode 100644 index 000000000..9b207ae8f --- /dev/null +++ b/server/modules/reorganize/titleAssignments.js @@ -0,0 +1,111 @@ +/** + * Episode numbers and file stems of downloaded videos a reorganize moves into + * a title show. Numbers the title plan already decided are used as they are; + * a video waiting for an upload-time number (year seasons, date episodes) is + * numbered from its stored info.json the way the post-processor would. + */ + +const { releaseTime, dateEpisodeFor, allocateEpisode } = require('../tvShows/dateNumbering'); +const { buildEpisodeStem } = require('../tvShows/episodeNaming'); +const { SEASON_SOURCE, EPISODE_SOURCE } = require('../tvShows/patternCompiler'); +const { ROW_STATUS, SOURCE } = require('../tvShows/titleNumbering'); +const { KIND_TITLE_SHOW } = require('../tvShows/constants'); + +function setOf(map, key) { + if (!map.has(key)) map.set(key, new Set()); + return map.get(key); +} + +// The upload time, else the download time (as the post-processor does). +function timeOf(entry) { + const release = releaseTime(entry.info || {}); + if (release) return release; + const downloaded = entry.downloadedAt ? Math.floor(new Date(entry.downloadedAt).getTime() / 1000) : null; + return Number.isFinite(downloaded) ? { epochSeconds: downloaded, source: null } : null; +} + +/** + * @param {Object} params + * @param {Object} params.show - Planned title show { key, name, ownerChannelId } + * @param {Array<Object>} params.entries - { youtubeId, title, info, downloadedAt, after, pattern, stored } + * after: the title plan's row; pattern: its { seasonSource, episodeSource }; stored: the video's stored row + * @param {Map<number, Set<number>>} params.taken - Numbers other videos hold in the show after the change, by season + * @param {Map<number, number>} params.highWater - Order high-water marks by season + * @returns {{assignments: Map<string, Object>, noDate: Set<string>, taken: Set<string>}} + * taken: videos whose title number another video holds + */ +function assignTitleEpisodes({ show, entries, taken, highWater }) { + const assignments = new Map(); + const noDate = new Set(); + const numberTaken = new Set(); + const held = new Map([...taken].map(([season, numbers]) => [season, new Set(numbers)])); + const marks = new Map(highWater); + + const classification = (entry, { season, episode, source, timestampSource = null }) => { + const { stored } = entry; + const keepsStem = stored && stored.showKey === show.key && stored.season === season && stored.episode === episode && stored.fileStem; + return { + showKey: show.key, + kind: KIND_TITLE_SHOW, + ownerChannelId: show.ownerChannelId, + showTitle: show.name, + season, + episode, + source, + timestampSource, + episodeTitle: entry.after.episodeTitle || entry.title || null, + fileStem: keepsStem ? stored.fileStem : buildEpisodeStem({ + season, episode, dateNumbered: source === SOURCE.DATE, episodeTitle: entry.after.episodeTitle, videoTitle: entry.title, youtubeId: entry.youtubeId, + }), + }; + }; + + const pending = []; + for (const entry of entries) { + if (entry.after.status === ROW_STATUS.ASSIGNED) { + assignments.set(entry.youtubeId, classification(entry, entry.after)); + continue; + } + const time = timeOf(entry); + if (!time) { + noDate.add(entry.youtubeId); + continue; + } + pending.push({ entry, time }); + } + + pending.sort((a, b) => (a.time.epochSeconds - b.time.epochSeconds) || (a.entry.youtubeId < b.entry.youtubeId ? -1 : 1)); + for (const { entry, time } of pending) { + const dated = dateEpisodeFor(time.epochSeconds); + const season = entry.pattern.seasonSource === SEASON_SOURCE.YEAR ? dated.season : entry.after.season; + const numbers = setOf(held, season); + let episode; + let source; + if (entry.pattern.episodeSource === EPISODE_SOURCE.DATE) { + episode = allocateEpisode(dated.episode, numbers); + source = SOURCE.DATE; + } else if (entry.pattern.episodeSource === EPISODE_SOURCE.TITLE) { + const titleEpisode = entry.after.titleEpisode ?? entry.after.episode; + if (numbers.has(titleEpisode)) { + numberTaken.add(entry.youtubeId); + continue; + } + episode = titleEpisode; + source = SOURCE.TITLE; + } else { + episode = (marks.get(season) || 0) + 1; + while (numbers.has(episode)) episode += 1; + marks.set(season, episode); + source = SOURCE.ORDER; + } + numbers.add(episode); + assignments.set(entry.youtubeId, classification(entry, { + season, episode, source, timestampSource: source === SOURCE.DATE ? time.source : null, + })); + } + return { assignments, noDate, taken: numberTaken }; +} + +module.exports = { + assignTitleEpisodes +}; diff --git a/server/modules/reorganize/titleSnapshot.js b/server/modules/reorganize/titleSnapshot.js new file mode 100644 index 000000000..4f4fe984d --- /dev/null +++ b/server/modules/reorganize/titleSnapshot.js @@ -0,0 +1,148 @@ +/** + * A channel's title shows, episode rows and conflicts before a title show + * change, kept with the operation so the change can be undone when no video + * could be moved (the settings of a reorganize are applied when it starts). + */ + +const { sequelize } = require('../../db'); +const { VideoClassification, EpisodeConflict, Video } = require('../../models'); +const titleShowStore = require('../tvShows/titleShowStore'); +const episodeConflicts = require('../tvShows/episodeConflicts'); +const archiveSuppressor = require('../tvShows/archiveSuppressor'); + +const ROW_FIELDS = [ + 'youtube_id', 'channel_id', 'show_id', 'status', 'season', 'episode', 'source', 'timestamp_source', + 'pattern_id', 'episode_title', 'file_stem', 'title_opt_out', +]; +const CONFLICT_KINDS = ['duplicate', 'classification_error']; + +function pick(row, fields) { + const values = {}; + for (const field of fields) values[field] = row[field] === undefined ? null : row[field]; + return values; +} + +function parseDetails(text) { + try { + return JSON.parse(text || '{}') || {}; + } catch (err) { + return {}; + } +} + +/** + * @param {string} channelId + * @returns {Promise<{shows: Array<Object>, rows: Array<Object>, conflicts: Array<Object>}>} + */ +async function takeTitleSnapshot(channelId) { + const shows = await titleShowStore.listTitleShows(channelId, { includeRetired: true }); + const rows = await VideoClassification.findAll({ where: { channel_id: channelId }, raw: true }); + const conflicts = await EpisodeConflict.findAll({ where: { channel_id: channelId, kind: CONFLICT_KINDS }, raw: true }); + return { + shows, + rows: rows.map((row) => pick(row, ROW_FIELDS)), + conflicts: conflicts.map((conflict) => ({ + youtubeId: conflict.youtube_id, + kind: conflict.kind, + showId: conflict.show_id, + duplicateOf: conflict.duplicate_of, + details: conflict.details, + })), + }; +} + +async function restoreRows(channelId, snapshot, patternIds, transaction) { + const patternKeys = new Map(); + for (const show of snapshot.shows) for (const pattern of show.patterns) patternKeys.set(pattern.id, pattern.key); + const kept = new Set(snapshot.rows.map((row) => row.youtube_id)); + + await VideoClassification.update({ season: null, episode: null }, { where: { channel_id: channelId }, transaction }); + const current = await VideoClassification.findAll({ where: { channel_id: channelId }, attributes: ['youtube_id'], transaction }); + const added = current.map((row) => row.youtube_id).filter((id) => !kept.has(id)); + if (added.length) await VideoClassification.destroy({ where: { youtube_id: added }, transaction }); + + for (const values of snapshot.rows) { + const patternKey = patternKeys.get(values.pattern_id); + const restored = { ...values, pattern_id: patternKey ? patternIds.get(patternKey) || null : null }; + const row = await VideoClassification.findByPk(values.youtube_id, { transaction }); + if (row) await row.update(restored, { transaction }); + else await VideoClassification.create(restored, { transaction }); + } +} + +async function restoreConflicts(channelId, snapshot, transaction) { + const before = new Map(snapshot.conflicts.map((conflict) => [conflict.youtubeId, conflict])); + const now = await EpisodeConflict.findAll({ where: { channel_id: channelId, kind: CONFLICT_KINDS }, transaction }); + const current = new Map(now.map((conflict) => [conflict.youtube_id, conflict])); + // A conflict the change recorded, or turned into another kind (an error + // that became a duplicate), is released first: that takes back the ignore + // and archive line it came with, which the old kind never had. + for (const conflict of now) { + const was = before.get(conflict.youtube_id); + if (!was || was.kind !== conflict.kind) await episodeConflicts.release(conflict.youtube_id, { transaction }); + } + // Conflicts the change released, and ones it pointed at another episode, + // holder or show, are recorded again as they were. + const unchanged = (conflict) => { + const row = current.get(conflict.youtubeId); + return row && row.kind === conflict.kind && (row.show_id ?? null) === (conflict.showId ?? null) + && (row.duplicate_of ?? null) === (conflict.duplicateOf ?? null) && (row.details ?? null) === (conflict.details ?? null); + }; + const missing = snapshot.conflicts.filter((conflict) => !unchanged(conflict)); + if (missing.length === 0) return; + const downloads = await Video.findAll({ + where: { youtubeId: missing.map((conflict) => conflict.youtubeId), removed: false }, attributes: ['youtubeId'], raw: true, transaction, + }); + const downloaded = new Set(downloads.map((video) => video.youtubeId)); + for (const conflict of missing) { + const details = parseDetails(conflict.details); + if (conflict.kind === 'classification_error') { + await episodeConflicts.recordError({ youtubeId: conflict.youtubeId, channelId, message: details.message || null, transaction }); + continue; + } + await episodeConflicts.recordDuplicate({ + youtubeId: conflict.youtubeId, + channelId, + showId: conflict.showId, + season: details.season, + episode: details.episode, + duplicateOf: conflict.duplicateOf, + downloaded: downloaded.has(conflict.youtubeId), + transaction, + }); + } +} + +// Shows the change added are deleted, not retired: a retired show would hold +// its folder name, and a retry of the same change would be refused for it. +async function deleteAddedShows(channelId, snapshot, transaction) { + const before = new Set(snapshot.shows.map((show) => show.id)); + const added = (await titleShowStore.titleShowIds(channelId, { transaction })).filter((id) => !before.has(id)); + if (added.length === 0) return; + await EpisodeConflict.update({ show_id: null }, { where: { show_id: added }, transaction }); + await titleShowStore.deleteShows(added, { transaction }); +} + +/** + * Put a channel's title shows, episode rows and conflicts back as they were. + * Shows the change added are deleted; numbers allocated meanwhile stay used + * (high-water marks never go down). + * @param {Object} channel - channels row + * @param {Object} snapshot - takeTitleSnapshot's result + */ +async function restoreTitleSnapshot(channel, snapshot) { + const channelId = channel.channel_id; + await sequelize.transaction(async (transaction) => { + const drafts = snapshot.shows.filter((show) => !show.retired); + const { patternIds } = await titleShowStore.saveDefinitions({ channelId, drafts, transaction }); + await restoreRows(channelId, snapshot, patternIds, transaction); + await restoreConflicts(channelId, snapshot, transaction); + await deleteAddedShows(channelId, snapshot, transaction); + }); + await archiveSuppressor.flush(); +} + +module.exports = { + takeTitleSnapshot, + restoreTitleSnapshot +}; diff --git a/server/modules/reorganize/titleTargets.js b/server/modules/reorganize/titleTargets.js new file mode 100644 index 000000000..5e0d832c7 --- /dev/null +++ b/server/modules/reorganize/titleTargets.js @@ -0,0 +1,115 @@ +/** + * Which moving videos belong to a title show, and the title shows a change + * plans. Routing rule step 2: an episode of an active title show of a + * subscribed channel goes to that show, whatever folder the channel uses. + * + * - A title show change (CHANGE_TITLE_SHOWS) follows its title plan: a video + * the plan puts in a show (numbered or waiting for an upload-time number) + * goes there; one it takes out of every show follows the channel layout. + * - Any other change keeps the episodes of active title shows where they are. + */ + +const { VideoClassification, TvShow } = require('../../models'); +const { KIND_TITLE_SHOW } = require('../tvShows/constants'); +const { folderNameKey } = require('../tvShows/showFolderNames'); +const { ROW_STATUS } = require('../tvShows/titleNumbering'); +const { CHANGE_TITLE_SHOWS, SHOW_ACTION } = require('./constants'); + +const SHOW_STATUSES = [ROW_STATUS.ASSIGNED, ROW_STATUS.PENDING]; + +function plannedShow({ key, ownerChannelId, showId, action, name, libraryFolder, folderName, previousLocation = null }) { + const show = { key, kind: KIND_TITLE_SHOW, ownerChannelId, showId, action, name, libraryFolder, folderName }; + if (previousLocation) show.previousLocation = previousLocation; + return show; +} + +function fromTitlePlan(subjects, context) { + const { titlePlan, drafts } = context; + const channelId = context.channel.channel_id; + const draftsByKey = new Map(drafts.map((draft) => [draft.key, draft])); + const entries = new Map(titlePlan.entries.map((entry) => [entry.youtubeId, entry])); + const targets = new Map(); + const shows = new Map(); + + for (const subject of subjects) { + const entry = entries.get(subject.video.youtubeId); + const after = entry ? entry.after : null; + const draft = after ? draftsByKey.get(after.showKey) : null; + if (!draft || !SHOW_STATUSES.includes(after.status)) continue; + targets.set(subject.video.id, { + showKey: draft.key, + after, + pattern: draft.patterns.find((pattern) => pattern.key === after.patternKey) || null, + stored: titlePlan.stored.get(subject.video.youtubeId) || null, + }); + if (shows.has(draft.key)) continue; + const before = titlePlan.storedShows.get(draft.key); + const moved = before && folderNameKey(before.libraryFolder, before.folderName) !== folderNameKey(draft.libraryFolder, draft.folderName); + shows.set(draft.key, plannedShow({ + key: draft.key, + ownerChannelId: channelId, + showId: draft.id || null, + action: !draft.id ? SHOW_ACTION.CREATE : moved ? SHOW_ACTION.MOVE : SHOW_ACTION.KEEP, + name: draft.name, + libraryFolder: draft.libraryFolder, + folderName: draft.folderName, + previousLocation: moved ? { libraryFolder: before.libraryFolder, folderName: before.folderName } : null, + })); + } + return { targets, shows }; +} + +async function fromStoredRows(subjects, channels) { + const targets = new Map(); + const shows = new Map(); + if (subjects.length === 0) return { targets, shows }; + const rows = await VideoClassification.findAll({ + where: { youtube_id: subjects.map((subject) => subject.video.youtubeId), status: ROW_STATUS.ASSIGNED }, + }); + if (rows.length === 0) return { targets, shows }; + const titleShows = await TvShow.findAll({ + where: { id: [...new Set(rows.map((row) => row.show_id))], kind: KIND_TITLE_SHOW, retired_at: null }, + }); + const showsById = new Map(titleShows.map((show) => [show.id, show])); + const rowsById = new Map(rows.map((row) => [row.youtube_id, row])); + + for (const subject of subjects) { + const row = rowsById.get(subject.video.youtubeId); + const show = row ? showsById.get(row.show_id) : null; + const owner = show ? channels.get(show.channel_id) : null; + if (!show || !owner || !owner.enabled || row.season === null || row.episode === null || !row.file_stem) continue; + const key = `title:${show.id}`; + targets.set(subject.video.id, { + showKey: key, + after: { + showKey: key, status: ROW_STATUS.ASSIGNED, season: row.season, episode: row.episode, source: row.source, + episodeTitle: row.episode_title, + }, + pattern: null, + stored: { showKey: key, season: row.season, episode: row.episode, fileStem: row.file_stem }, + }); + if (!shows.has(key)) { + shows.set(key, plannedShow({ + key, ownerChannelId: show.channel_id, showId: show.id, action: SHOW_ACTION.KEEP, + name: show.name, libraryFolder: show.library_folder || '', folderName: show.folder_name, + })); + } + } + return { targets, shows }; +} + +/** + * @param {Array<Object>} subjects - changeScope subjects + * @param {Object} context - Resolved change + * @param {Map<string, Object>} [channels] - channels rows by id (other changes) + * @returns {Promise<{targets: Map<number, Object>, shows: Map<string, Object>}>} + * targets by Videos.id: { showKey, after, pattern, stored }; planned title shows by key + */ +async function resolveTitleTargets(subjects, context, channels = new Map()) { + if (context.type === CHANGE_TITLE_SHOWS) return fromTitlePlan(subjects, context); + return fromStoredRows(subjects, channels); +} + +module.exports = { + resolveTitleTargets +}; diff --git a/server/modules/sidecarWriter.js b/server/modules/sidecarWriter.js index aab5c0ef2..deb8d9de9 100644 --- a/server/modules/sidecarWriter.js +++ b/server/modules/sidecarWriter.js @@ -16,8 +16,10 @@ const configModule = require('./configModule'); const nfoGenerator = require('./nfoGenerator'); const logger = require('../logger'); const { copySyncWithFallback } = require('./filesystem/fileOperations'); -const { buildEpisodeNfo, writeTvShowNfoIfChanged } = require('./tvShows/tvNfo'); -const { earliestEpisodeDate } = require('./tvShows/episodePlacement'); +const { buildEpisodeNfo, writeTvShowNfoIfChanged, writeSeasonNfo, ID_TYPE_YOUTARR } = require('./tvShows/tvNfo'); +const { earliestEpisodeDate, seasonNamesOf } = require('./tvShows/episodePlacement'); +const { seasonFolderName } = require('./tvShows/episodeNaming'); +const { KIND_TITLE_SHOW } = require('./tvShows/constants'); const POSTER_FILE = 'poster.jpg'; const BACKDROP_FILE = 'backdrop.jpg'; @@ -115,13 +117,33 @@ function writeFolderArt({ channelId, folderPath }) { */ async function writeShowMetadata({ show, showDir, plot = null }) { if (!fs.existsSync(showDir)) return; + if (show.kind === KIND_TITLE_SHOW) { + await writeTitleShowMetadata(show, showDir); + } else { + await writeTvShowNfoIfChanged(showDir, { + title: show.name, + plot, + premiered: await earliestEpisodeDate(show.id), + externalKey: show.external_key, + }); + } + writeFolderArt({ channelId: show.channel_id, folderPath: showDir }); +} + +// A title show is identified by its own key and has the season names the +// user gave it, in tvshow.nfo and each season folder's season.nfo. +async function writeTitleShowMetadata(show, showDir) { + const namedSeasons = await seasonNamesOf(show.id); await writeTvShowNfoIfChanged(showDir, { - title: show.name, - plot, - premiered: await earliestEpisodeDate(show.id), - externalKey: show.external_key, + title: show.name, externalKey: show.external_key, idType: ID_TYPE_YOUTARR, namedSeasons, }); - writeFolderArt({ channelId: show.channel_id, folderPath: showDir }); + for (const entry of await fs.promises.readdir(showDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const match = /^Season (\d+)$/.exec(entry.name); + if (!match || seasonFolderName(Number(match[1])) !== entry.name) continue; + const season = Number(match[1]); + await writeSeasonNfo(path.join(showDir, entry.name), { season, name: namedSeasons[season] || null }); + } } module.exports = { diff --git a/server/modules/titleFilterRegex.js b/server/modules/titleFilterRegex.js index 7317882c4..64675b291 100644 --- a/server/modules/titleFilterRegex.js +++ b/server/modules/titleFilterRegex.js @@ -1,15 +1,78 @@ const { execFileSync, spawn } = require('child_process'); const path = require('path'); -// Channel title filters are Python regexes because yt-dlp applies them with -// Python's re.search. The channel filter preview and validation go through -// this module so a pattern behaves the same there as in real downloads. +// Channel title filters and title-show patterns are Python regexes because +// yt-dlp applies them with Python's re.search. The channel filter preview and +// validation, and title-show classification, go through this module so a +// pattern behaves the same there as in real downloads. // (Playlist title filters are evaluated separately; see // playlistModule.buildTitleFilterRegExp.) const SCRIPT_PATH = path.join(__dirname, '../utils/title-filter-regex.py'); const VALIDATE_TIMEOUT_MS = 2000; const MATCH_TIMEOUT_MS = 15000; +/** + * Run one request through the Python script in its own process. + * @param {Object} request + * @param {(parsed: Object) => *} pick - Returns the result, or throws when the response is malformed + * @returns {Promise<*>} + */ +function runScript(request, pick) { + return new Promise((resolve, reject) => { + const child = spawn('python3', [SCRIPT_PATH]); + let stdout = ''; + let stderr = ''; + let settled = false; + + const finish = (err, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) reject(err); + else resolve(value); + }; + + // A catastrophic-backtracking pattern must not hang the caller. + const timer = setTimeout(() => { + child.kill('SIGKILL'); + const err = new Error(`A title pattern took too long to check (over ${MATCH_TIMEOUT_MS / 1000} seconds). Simplify the pattern.`); + // The pattern is the cause, so callers answer it as a bad request. + err.status = 400; + finish(err); + }, MATCH_TIMEOUT_MS); + + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (err) => finish(err)); + child.on('close', (code) => { + if (code !== 0) { + finish(new Error(`Title filter regex check exited with code ${code}: ${stderr.trim()}`)); + return; + } + try { + const parsed = JSON.parse(stdout); + if (parsed.error) { + finish(new Error(parsed.error)); + return; + } + finish(null, pick(parsed)); + } catch (err) { + finish(err); + } + }); + + child.stdin.on('error', (err) => finish(err)); + child.stdin.end(JSON.stringify(request)); + }); +} + +function expectList(list, length) { + if (!Array.isArray(list) || list.length !== length) { + throw new Error('Title filter regex check returned an unexpected result'); + } + return list; +} + class TitleFilterRegex { /** * Check that a pattern compiles as a Python regex. @@ -39,54 +102,35 @@ class TitleFilterRegex { */ matchTitles(pattern, titles) { if (titles.length === 0) return Promise.resolve([]); + return runScript({ pattern, titles }, (parsed) => expectList(parsed.matches, titles.length)); + } - return new Promise((resolve, reject) => { - const child = spawn('python3', [SCRIPT_PATH]); - let stdout = ''; - let stderr = ''; - let settled = false; - - const finish = (err, value) => { - if (settled) return; - settled = true; - clearTimeout(timer); - if (err) reject(err); - else resolve(value); - }; - - // A catastrophic-backtracking pattern must not hang the caller. - const timer = setTimeout(() => { - child.kill('SIGKILL'); - finish(new Error(`Title filter regex timed out after ${MATCH_TIMEOUT_MS} ms`)); - }, MATCH_TIMEOUT_MS); - - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.on('error', (err) => finish(err)); - child.on('close', (code) => { - if (code !== 0) { - finish(new Error(`Title filter regex check exited with code ${code}: ${stderr.trim()}`)); - return; - } - try { - const parsed = JSON.parse(stdout); - if (parsed.error) { - finish(new Error(parsed.error)); - return; - } - if (!Array.isArray(parsed.matches) || parsed.matches.length !== titles.length) { - finish(new Error('Title filter regex check returned an unexpected result')); - return; - } - finish(null, parsed.matches); - } catch (err) { - finish(err); - } - }); + /** + * Compile-check several regexes in one Python process. + * @param {string[]} patterns + * @returns {Promise<Array<string|null>>} the compile error of each, or null + */ + checkPatterns(patterns) { + if (patterns.length === 0) return Promise.resolve([]); + return runScript({ mode: 'check', patterns }, (parsed) => expectList(parsed.errors, patterns.length)); + } - child.stdin.on('error', (err) => finish(err)); - child.stdin.end(JSON.stringify({ pattern, titles })); - }); + /** + * Find each title's first matching pattern, skipping a pattern when one of + * its excludes also matches the title. + * @param {Array<{regex: string, excludes?: string[]}>} patterns + * @param {string[]} titles + * @returns {Promise<Array<null|{index: number, groups: Object<string, string|null>}>>} + */ + classifyTitles(patterns, titles) { + if (titles.length === 0) return Promise.resolve([]); + if (patterns.length === 0) return Promise.resolve(titles.map(() => null)); + const request = { + mode: 'classify', + patterns: patterns.map(({ regex, excludes }) => ({ regex, excludes: excludes || [] })), + titles, + }; + return runScript(request, (parsed) => expectList(parsed.results, titles.length)); } } diff --git a/server/modules/tvShows/__tests__/archiveSuppressor.test.js b/server/modules/tvShows/__tests__/archiveSuppressor.test.js new file mode 100644 index 000000000..dd3c40140 --- /dev/null +++ b/server/modules/tvShows/__tests__/archiveSuppressor.test.js @@ -0,0 +1,128 @@ +jest.mock('../../../models', () => ({ + EpisodeConflict: { findAll: jest.fn() }, +})); +jest.mock('../../archiveModule', () => ({ + addVideoToArchive: jest.fn().mockResolvedValue(true), + removeVideoFromArchive: jest.fn().mockResolvedValue(true), + isVideoInArchive: jest.fn(), +})); +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +function conflict(youtubeId, values) { + const row = { youtube_id: youtubeId, kind: 'duplicate', archive_suppressed: false, archive_pending: null, ...values }; + row.update = jest.fn(async (patch) => Object.assign(row, patch)); + row.destroy = jest.fn(); + return row; +} + +describe('archiveSuppressor', () => { + let suppressor; + let models; + let archiveModule; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + archiveModule = require('../../archiveModule'); + suppressor = require('../archiveSuppressor'); + }); + + it('writes nothing until it knows whether a download is running', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([conflict('a', { archive_pending: 'add' })]); + await suppressor.flush(); + expect(archiveModule.addVideoToArchive).not.toHaveBeenCalled(); + }); + + it('writes nothing while a download job runs', async () => { + suppressor.initialize({ isDownloadRunning: () => true }); + models.EpisodeConflict.findAll.mockResolvedValue([conflict('a', { archive_pending: 'add' })]); + await suppressor.flush(); + expect(archiveModule.addVideoToArchive).not.toHaveBeenCalled(); + }); + + describe('when no download runs', () => { + beforeEach(() => { + suppressor.initialize({ isDownloadRunning: () => false }); + archiveModule.isVideoInArchive.mockImplementation(async (id) => archiveModule.addVideoToArchive.mock.calls.some(([added]) => added === id)); + }); + + it('adds a pending line and records that Youtarr wrote it', async () => { + const row = conflict('a', { archive_pending: 'add' }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + expect(archiveModule.addVideoToArchive).toHaveBeenCalledWith('a'); + expect(row).toMatchObject({ archive_pending: null, archive_suppressed: true }); + }); + + it('records that the line is Youtarr\'s before appending it', async () => { + const row = conflict('a', { archive_pending: 'add' }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + const owned = row.update.mock.calls.findIndex(([patch]) => patch.archive_suppressed === true); + expect(owned).toBeGreaterThanOrEqual(0); + expect(row.update.mock.invocationCallOrder[owned]).toBeLessThan(archiveModule.addVideoToArchive.mock.invocationCallOrder[0]); + }); + + it('keeps ownership of a line it appended when the retry finds it there', async () => { + // The append went through but the row was not updated (a restart in between). + archiveModule.isVideoInArchive.mockResolvedValue(true); + const row = conflict('a', { archive_pending: 'add', archive_suppressed: true }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + expect(archiveModule.addVideoToArchive).not.toHaveBeenCalled(); + expect(row).toMatchObject({ archive_pending: null, archive_suppressed: true }); + }); + + it('leaves a line that was already in the archive as not Youtarr\'s', async () => { + archiveModule.isVideoInArchive.mockResolvedValue(true); + const row = conflict('a', { archive_pending: 'add' }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + expect(archiveModule.addVideoToArchive).not.toHaveBeenCalled(); + expect(row.update).toHaveBeenCalledWith({ archive_pending: null, archive_suppressed: false }); + }); + + it('removes a pending line and forgets that Youtarr wrote it', async () => { + const row = conflict('b', { archive_pending: 'remove', archive_suppressed: true }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + expect(archiveModule.removeVideoFromArchive).toHaveBeenCalledWith('b'); + expect(row.update).toHaveBeenCalledWith({ archive_pending: null, archive_suppressed: false }); + }); + + it('keeps the request when the line did not reach the archive', async () => { + archiveModule.addVideoToArchive.mockResolvedValueOnce(false); + archiveModule.isVideoInArchive.mockResolvedValue(false); + const row = conflict('a', { archive_pending: 'add' }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + expect(row.archive_pending).toBe('add'); + }); + + it('deletes a released conflict once its line is removed', async () => { + const row = conflict('c', { kind: 'released', archive_pending: 'remove', archive_suppressed: true }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + expect(row.destroy).toHaveBeenCalled(); + }); + + it('keeps the request when the archive write fails', async () => { + archiveModule.addVideoToArchive.mockRejectedValueOnce(new Error('EACCES')); + const row = conflict('a', { archive_pending: 'add' }); + models.EpisodeConflict.findAll.mockResolvedValue([row]); + await suppressor.flush(); + expect(row.archive_pending).toBe('add'); + }); + + it('runs one flush at a time', async () => { + let release; + models.EpisodeConflict.findAll.mockReturnValue(new Promise((resolve) => { release = resolve; })); + const first = suppressor.flush(); + const second = suppressor.flush(); + release([]); + await Promise.all([first, second]); + expect(models.EpisodeConflict.findAll).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/episodeConflicts.test.js b/server/modules/tvShows/__tests__/episodeConflicts.test.js new file mode 100644 index 000000000..6d2d64795 --- /dev/null +++ b/server/modules/tvShows/__tests__/episodeConflicts.test.js @@ -0,0 +1,220 @@ +jest.mock('../../../models', () => ({ + EpisodeConflict: { findByPk: jest.fn(), findAll: jest.fn(), create: jest.fn() }, +})); +jest.mock('../../../models/channelvideo', () => ({ findAll: jest.fn(), update: jest.fn() })); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; + +function row(values) { + const conflict = { youtube_id: 'dup', channel_id: CHANNEL_ID, kind: 'duplicate', archive_suppressed: false, archive_pending: null, ...values }; + conflict.update = jest.fn(async (patch) => Object.assign(conflict, patch)); + conflict.destroy = jest.fn(); + return conflict; +} + +const duplicate = { youtubeId: 'dup', channelId: CHANNEL_ID, showId: 3, season: 1, episode: 20, duplicateOf: 'win' }; + +describe('episodeConflicts', () => { + let conflicts; + let models; + let ChannelVideo; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + ChannelVideo = require('../../../models/channelvideo'); + conflicts = require('../episodeConflicts'); + models.EpisodeConflict.findByPk.mockResolvedValue(null); + ChannelVideo.findAll.mockResolvedValue([]); + }); + + describe('recordDuplicate', () => { + it('ignores a not-downloaded duplicate and asks for its archive line', async () => { + await conflicts.recordDuplicate({ ...duplicate, downloaded: false }); + expect(ChannelVideo.update).toHaveBeenCalledWith( + { ignored: true, ignored_at: expect.any(Date) }, + expect.objectContaining({ where: { channel_id: CHANNEL_ID, youtube_id: 'dup' } }) + ); + expect(models.EpisodeConflict.create).toHaveBeenCalledWith(expect.objectContaining({ + youtube_id: 'dup', kind: 'duplicate', show_id: 3, duplicate_of: 'win', archive_pending: 'add', youtarr_ignored: true, + details: JSON.stringify({ season: 1, episode: 20 }), + }), expect.anything()); + }); + + it('leaves a video the user already ignored as the user\'s', async () => { + ChannelVideo.findAll.mockResolvedValue([{ ignored: true }]); + await conflicts.recordDuplicate({ ...duplicate, downloaded: false }); + expect(ChannelVideo.update).not.toHaveBeenCalled(); + expect(models.EpisodeConflict.create.mock.calls[0][0]).toMatchObject({ archive_pending: null, youtarr_ignored: false }); + }); + + it('never ignores a downloaded duplicate', async () => { + await conflicts.recordDuplicate({ ...duplicate, downloaded: true }); + expect(ChannelVideo.update).not.toHaveBeenCalled(); + expect(models.EpisodeConflict.create.mock.calls[0][0].archive_pending).toBeNull(); + }); + + it('points an existing duplicate at its new holder', async () => { + const existing = row({ archive_suppressed: true, duplicate_of: 'old' }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.recordDuplicate({ ...duplicate, downloaded: false }); + expect(existing.update).toHaveBeenCalledWith(expect.objectContaining({ kind: 'duplicate', duplicate_of: 'win' }), expect.anything()); + }); + + it('suppresses a released duplicate again without removing its line first', async () => { + const existing = row({ kind: 'released', archive_suppressed: true, archive_pending: 'remove' }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.recordDuplicate({ ...duplicate, downloaded: false }); + expect(existing.update).toHaveBeenCalledWith(expect.objectContaining({ kind: 'duplicate', archive_pending: null, youtarr_ignored: true }), expect.anything()); + expect(ChannelVideo.update).toHaveBeenCalled(); + }); + }); + + describe('release', () => { + it('takes back the ignore and asks to remove the line Youtarr wrote', async () => { + const existing = row({ youtarr_ignored: true, archive_suppressed: true }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.release('dup'); + expect(ChannelVideo.update).toHaveBeenCalledWith( + { ignored: false, ignored_at: null }, + expect.objectContaining({ where: { channel_id: CHANNEL_ID, youtube_id: 'dup', ignored: true } }) + ); + expect(existing.update).toHaveBeenCalledWith({ kind: 'released', archive_pending: 'remove', youtarr_ignored: false }, expect.anything()); + }); + + it('takes back Youtarr\'s ignore but keeps an archive line Youtarr did not write', async () => { + const existing = row({ youtarr_ignored: true, archive_suppressed: false }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.release('dup'); + expect(ChannelVideo.update).toHaveBeenCalledWith({ ignored: false, ignored_at: null }, expect.anything()); + expect(existing.destroy).toHaveBeenCalled(); + }); + + it('never asks to remove an archive line Youtarr did not write', async () => { + const existing = row({ youtarr_ignored: true, archive_suppressed: false }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.release('dup'); + expect(existing.update).not.toHaveBeenCalled(); + }); + + it('drops a conflict whose line was never written', async () => { + const existing = row({ youtarr_ignored: true, archive_pending: 'add' }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.release('dup'); + expect(ChannelVideo.update).toHaveBeenCalled(); + expect(existing.destroy).toHaveBeenCalled(); + }); + + it('never touches the user\'s own ignore', async () => { + const existing = row({}); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.release('dup'); + expect(ChannelVideo.update).not.toHaveBeenCalled(); + expect(existing.destroy).toHaveBeenCalled(); + }); + + it('does nothing without a conflict', async () => { + await expect(conflicts.release('none')).resolves.toBeUndefined(); + }); + }); + + describe('noteArchiveLinesRemoved', () => { + it('asks to write Youtarr\'s line again after an explicit download removed it', async () => { + const existing = row({ archive_suppressed: true }); + models.EpisodeConflict.findAll.mockResolvedValue([existing]); + await conflicts.noteArchiveLinesRemoved(['dup']); + expect(existing.update).toHaveBeenCalledWith({ archive_suppressed: false, archive_pending: 'add' }); + }); + }); + + describe('noteDownloaded', () => { + it('hands the archive line to the download and takes back Youtarr\'s ignore', async () => { + const existing = row({ youtarr_ignored: true, archive_pending: 'add' }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.noteDownloaded('dup'); + expect(ChannelVideo.update).toHaveBeenCalledWith({ ignored: false, ignored_at: null }, expect.anything()); + expect(existing.update).toHaveBeenCalledWith({ youtarr_ignored: false, archive_suppressed: false, archive_pending: null }); + }); + + it('leaves a conflict without Youtarr\'s suppression alone', async () => { + const existing = row({}); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.noteDownloaded('dup'); + expect(existing.update).not.toHaveBeenCalled(); + }); + }); + + describe('recordError', () => { + it('records a classification error for a video without a conflict', async () => { + await conflicts.recordError({ youtubeId: 'e', channelId: CHANNEL_ID, message: 'timed out' }); + expect(models.EpisodeConflict.create).toHaveBeenCalledWith(expect.objectContaining({ + youtube_id: 'e', kind: 'classification_error', show_id: null, details: JSON.stringify({ message: 'timed out' }), + }), expect.anything()); + }); + + it('leaves a duplicate conflict as it is', async () => { + const existing = row({}); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.recordError({ youtubeId: 'dup', channelId: CHANNEL_ID, message: 'x' }); + expect(existing.update).not.toHaveBeenCalled(); + }); + + it('takes over a released row, keeping its pending archive removal', async () => { + const existing = row({ kind: 'released', archive_suppressed: true, archive_pending: 'remove', show_id: 3, duplicate_of: 'win' }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.recordError({ youtubeId: 'dup', channelId: CHANNEL_ID, message: 'x' }); + expect(existing.update).toHaveBeenCalledWith( + { channel_id: CHANNEL_ID, show_id: null, kind: 'classification_error', duplicate_of: null, details: JSON.stringify({ message: 'x' }) }, + expect.anything() + ); + expect(existing).toMatchObject({ archive_suppressed: true, archive_pending: 'remove' }); + }); + }); + + describe('clearError', () => { + it('removes a classification error', async () => { + const existing = row({ kind: 'classification_error' }); + models.EpisodeConflict.findByPk.mockResolvedValue(existing); + await conflicts.clearError('dup'); + expect(existing.destroy).toHaveBeenCalled(); + }); + }); + + describe('clearErrorsForChannel', () => { + it('removes the channel\'s classification errors', async () => { + models.EpisodeConflict.destroy = jest.fn(); + await conflicts.clearErrorsForChannel(CHANNEL_ID, { transaction: 't' }); + expect(models.EpisodeConflict.destroy).toHaveBeenCalledWith({ where: { channel_id: CHANNEL_ID, kind: 'classification_error' }, transaction: 't' }); + }); + }); + + describe('duplicateIdsForChannel', () => { + it('returns the ids of the channel\'s duplicates', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([row({ youtube_id: 'dup1' }), row({ youtube_id: 'dup2' })]); + expect(await conflicts.duplicateIdsForChannel(CHANNEL_ID)).toEqual(new Set(['dup1', 'dup2'])); + }); + + it('asks only for duplicates of that channel', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([]); + await conflicts.duplicateIdsForChannel(CHANNEL_ID, { transaction: 't' }); + expect(models.EpisodeConflict.findAll).toHaveBeenCalledWith(expect.objectContaining({ + where: { channel_id: CHANNEL_ID, kind: 'duplicate' }, transaction: 't', + })); + }); + }); + + describe('listForChannel', () => { + it('lists the channel\'s duplicates and errors with their details', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([row({ details: JSON.stringify({ season: 1, episode: 20 }), duplicate_of: 'win', show_id: 3 })]); + expect(await conflicts.listForChannel(CHANNEL_ID)).toEqual([{ + youtubeId: 'dup', kind: 'duplicate', showId: 3, duplicateOf: 'win', season: 1, episode: 20, message: null, suppressed: false, + }]); + }); + + it('reports a duplicate Youtarr ignored as suppressed, whoever wrote its archive line', async () => { + models.EpisodeConflict.findAll.mockResolvedValue([row({ youtarr_ignored: true, archive_suppressed: false })]); + expect((await conflicts.listForChannel(CHANNEL_ID))[0].suppressed).toBe(true); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/episodePlacement.test.js b/server/modules/tvShows/__tests__/episodePlacement.test.js index 8bc6e9c86..515a3b6bf 100644 --- a/server/modules/tvShows/__tests__/episodePlacement.test.js +++ b/server/modules/tvShows/__tests__/episodePlacement.test.js @@ -7,6 +7,9 @@ jest.mock('../showStore', () => ({ })); jest.mock('../episodeAllocator', () => ({ assignDateEpisode: jest.fn() })); jest.mock('../../../models/videoclassification', () => ({ findOne: jest.fn() })); +jest.mock('../../../models/tvshowseason', () => ({ findAll: jest.fn().mockResolvedValue([]) })); +jest.mock('../titleEpisodeAssigner', () => ({ resolveTitlePlacement: jest.fn() })); +jest.mock('../episodeConflicts', () => ({ noteDownloaded: jest.fn() })); jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); // Retries of a failing move are instant. jest.mock('../../filesystem/sleep', () => ({ sleep: () => Promise.resolve() })); @@ -55,6 +58,35 @@ describe('episodePlacement', () => { ...overrides, }); + it('places a title-show episode at the show\'s location, even from a videos folder', async () => { + const titleAssigner = require('../titleEpisodeAssigner'); + const show = { id: 3, kind: 'title', name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV', external_key: 'u' }; + const assignment = { season: 1, episode: 20, dateNumbered: false, episodeTitle: 'Relative', fileStem: `S01E20 - Relative [${ID}]` }; + titleAssigner.resolveTitlePlacement.mockResolvedValue({ show, assignment }); + await expect(plan({ resolvedSubfolder: 'Kids' })).resolves.toEqual({ + show, assignment, showDir: path.join('/data', '__TV', 'Beyblade'), seasonDir: path.join('/data', '__TV', 'Beyblade', 'Season 01'), stem: assignment.fileStem, + }); + }); + + it('asks the title shows of the owner channel first', async () => { + const titleAssigner = require('../titleEpisodeAssigner'); + await plan({ channelRecord: { title: 'Mark Rober' }, channelEnabled: true }); + expect(titleAssigner.resolveTitlePlacement).toHaveBeenCalledWith(expect.objectContaining({ youtubeId: ID, ownerChannelId: 'UC1', channelEnabled: true })); + }); + + it('saves a title-show episode by the channel layout when its show\'s folder is no longer TV', async () => { + const titleAssigner = require('../titleEpisodeAssigner'); + titleAssigner.resolveTitlePlacement.mockResolvedValue({ + show: { id: 3, kind: 'title', library_folder: 'Kids', folder_name: 'Beyblade' }, assignment: ASSIGNMENT, + }); + await expect(plan({ resolvedSubfolder: 'Kids' })).resolves.toBeNull(); + }); + + it('hands a downloaded duplicate\'s archive line to the download', async () => { + await plan({ resolvedSubfolder: 'Kids' }); + expect(require('../episodeConflicts').noteDownloaded).toHaveBeenCalledWith(ID); + }); + it('returns null for a videos folder without touching shows', async () => { await expect(plan({ resolvedSubfolder: 'Kids' })).resolves.toBeNull(); expect(showStore.findChannelShow).not.toHaveBeenCalled(); @@ -154,6 +186,25 @@ describe('episodePlacement', () => { expect(xml).toContain('<episode>3151200</episode>'); }); + it('writes a title show\'s tvshow.nfo with its Youtarr id and season names', async () => { + require('../../../models/tvshowseason').findAll.mockResolvedValue([{ season: 1, name: 'Beyblade' }]); + const seasonDir = path.join(showDir, 'Season 01'); + fs.mkdirSync(seasonDir); + await episodePlacement.writeEpisodeMetadata({ + placement: { + show: { id: 3, kind: 'title', name: 'Beyblade', external_key: 'uuid-3' }, + assignment: { season: 1, episode: 20, dateNumbered: false, episodeTitle: 'Relative' }, showDir, seasonDir, stem: `S01E20 - Relative [${ID}]`, + }, + info: { id: ID, title: 'x' }, + showPlot: 'Channel description', + }); + const xml = fs.readFileSync(path.join(showDir, 'tvshow.nfo'), 'utf8'); + expect(xml).toContain('<uniqueid type="youtarr" default="true">uuid-3</uniqueid>'); + expect(xml).toContain('<namedseason number="1">Beyblade</namedseason>'); + expect(xml).not.toContain('Channel description'); + expect(fs.readFileSync(path.join(seasonDir, 'season.nfo'), 'utf8')).toContain('<title>Beyblade'); + }); + it('writes tvshow.nfo with the earliest episode as premiered', async () => { VideoClassification.findOne.mockResolvedValue({ season: 2019, episode: 4050000 }); await write(); diff --git a/server/modules/tvShows/__tests__/layoutGuards.test.js b/server/modules/tvShows/__tests__/layoutGuards.test.js index 7b03843c4..5c8c60427 100644 --- a/server/modules/tvShows/__tests__/layoutGuards.test.js +++ b/server/modules/tvShows/__tests__/layoutGuards.test.js @@ -2,6 +2,7 @@ jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); jest.mock('../../../models/video', () => ({ count: jest.fn() })); jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/tvshow', () => ({ findAll: jest.fn() })); jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: '/data' })); jest.mock('../showStore', () => ({ findChannelShow: jest.fn() })); jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); @@ -190,4 +191,21 @@ describe('layoutGuards', () => { }); }); }); + + describe('assertNoTitleShows', () => { + it('refuses a folder that holds active title shows, naming them', async () => { + require('../../../models/tvshow').findAll.mockResolvedValue([{ name: 'Beyblade' }, { name: 'Hermitcraft' }]); + await expect(layoutGuards.assertNoTitleShows('TV Shows')).rejects.toMatchObject({ + status: 400, message: expect.stringContaining('Beyblade, Hermitcraft'), + }); + expect(require('../../../models/tvshow').findAll.mock.calls[0][0].where).toEqual({ + library_folder: 'TV Shows', kind: 'title', retired_at: null, + }); + }); + + it('lets a folder without title shows through', async () => { + require('../../../models/tvshow').findAll.mockResolvedValue([]); + await expect(layoutGuards.assertNoTitleShows('')).resolves.toBeUndefined(); + }); + }); }); diff --git a/server/modules/tvShows/__tests__/libraryFolders.test.js b/server/modules/tvShows/__tests__/libraryFolders.test.js index 399ca42bc..56bdf8478 100644 --- a/server/modules/tvShows/__tests__/libraryFolders.test.js +++ b/server/modules/tvShows/__tests__/libraryFolders.test.js @@ -8,6 +8,7 @@ jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); jest.mock('../../../models/video', () => ({ count: jest.fn() })); jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn().mockResolvedValue([]) })); +jest.mock('../../../models/tvshow', () => ({ findAll: jest.fn().mockResolvedValue([]) })); jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: mockRoot })); jest.mock('../../subfolderModule', () => ({ getUsage: jest.fn(), getAll: jest.fn(), register: jest.fn() })); @@ -92,6 +93,12 @@ describe('libraryFolders', () => { expect(subfolderModule.register).not.toHaveBeenCalled(); }); + it('refuses to switch a folder holding title shows to Videos', async () => { + require('../../../models/tvshow').findAll.mockResolvedValueOnce([{ name: 'Beyblade' }]); + await expect(libraryFolders.setFolderLayout('TV', 'videos')).rejects.toMatchObject({ status: 400 }); + expect(libraryLayouts.setLayout).not.toHaveBeenCalled(); + }); + it('leaves a folder that already has the layout alone', async () => { await expect(libraryFolders.setFolderLayout('TV', 'tv')).resolves.toEqual({ changed: false }); expect(libraryLayouts.setLayout).not.toHaveBeenCalled(); diff --git a/server/modules/tvShows/__tests__/patternCompiler.test.js b/server/modules/tvShows/__tests__/patternCompiler.test.js new file mode 100644 index 000000000..e2a1ccb2a --- /dev/null +++ b/server/modules/tvShows/__tests__/patternCompiler.test.js @@ -0,0 +1,199 @@ +const { + compilePattern, + buildShowFilter, + excludeTermRegex, + validateSources, + isAssignableSeason, + PatternError, +} = require('../patternCompiler'); + +const simple = (text) => compilePattern({ text, kind: 'simple' }); +const regex = (text) => compilePattern({ text, kind: 'regex' }); + +describe('patternCompiler', () => { + describe('simple syntax', () => { + it('matches literal text case-insensitively anywhere in the title', () => { + expect(simple('BEYBLADE EN').compiledRegex).toBe('(?i)BEYBLADE\\s+EN'); + }); + + it('turns a run of spaces into any run of whitespace', () => { + expect(simple('Secret Life').compiledRegex).toBe('(?i)Secret\\s+Life'); + }); + + it('compiles number placeholders to ASCII digits only', () => { + expect(simple('Episode {episode}').compiledRegex).toBe('(?i)Episode\\s+(?P[0-9]+)'); + }); + + it('compiles season, episode_end and part placeholders', () => { + expect(simple('{season}x{episode_end}p{part}').compiledRegex) + .toBe('(?i)(?P[0-9]+)x(?P[0-9]+)p(?P[0-9]+)'); + }); + + it('makes {title} lazy when more pattern text follows', () => { + expect(simple('{title} | Official Clip').compiledRegex) + .toBe('(?i)(?P.+?)\\s+\\|\\s+Official\\s+Clip'); + }); + + it('makes a trailing {title} greedy and anchored to the end', () => { + expect(simple('Episode {episode}: {title}').compiledRegex) + .toBe('(?i)Episode\\s+(?P<episode>[0-9]+):\\s+(?P<title>.+)$'); + }); + + it('treats {title} followed only by spaces as trailing', () => { + expect(simple('Ep {episode} {title} ').compiledRegex).toBe('(?i)Ep\\s+(?P<episode>[0-9]+)\\s+(?P<title>.+)$'); + }); + + it('compiles * to any text', () => { + expect(simple('Ep.{episode} * | Ep.{episode_end} *').compiledRegex) + .toBe('(?i)Ep\\.(?P<episode>[0-9]+)\\s+.*?\\s+\\|\\s+Ep\\.(?P<episode_end>[0-9]+)\\s+.*?'); + }); + + it('anchors to the start of the title when the pattern starts with ^', () => { + expect(simple('^Hermitcraft {season}').compiledRegex).toBe('(?i)^Hermitcraft\\s+(?P<season>[0-9]+)'); + }); + + it('escapes regex metacharacters in literal text', () => { + expect(simple('a.b(c)[d]+e?f$g|h\\i').compiledRegex).toBe('(?i)a\\.b\\(c\\)\\[d\\]\\+e\\?f\\$g\\|h\\\\i'); + }); + + it('keeps non-ASCII text and quotes as they are', () => { + expect(simple('EPISÓDIO \'x\' & y').compiledRegex).toBe('(?i)EPISÓDIO\\s+\'x\'\\s+&\\s+y'); + }); + + it('keeps a brace that is not a placeholder as literal text', () => { + expect(simple('Part { one').compiledRegex).toBe('(?i)Part\\s+\\{\\s+one'); + }); + + it('refuses an unknown placeholder', () => { + expect(() => simple('Episode {ep}')).toThrow(PatternError); + }); + + it('refuses a placeholder used twice', () => { + expect(() => simple('{episode} and {episode}')).toThrow('{episode} appears more than once'); + }); + + it('refuses a regex that is only flags', () => { + expect(() => compilePattern({ text: '(?i)', kind: 'regex' })).toThrow('The pattern is empty.'); + }); + + it('refuses an empty pattern', () => { + expect(() => simple(' ')).toThrow(PatternError); + }); + + it('reports the placeholders it uses', () => { + expect(simple('Hermitcraft {season}: Episode {episode} - {title}').groups) + .toEqual(['season', 'episode', 'title']); + }); + + it('builds the filter form with unnamed groups and scoped flags', () => { + expect(simple('Episode {episode}: {title}').filterRegex) + .toBe('(?i:Episode\\s+(?:[0-9]+):\\s+(?:.+)$)'); + }); + }); + + describe('regex mode', () => { + it('keeps the regex as written', () => { + expect(regex('^S(?P<season>\\d+)E(?P<episode>\\d+)').compiledRegex).toBe('^S(?P<season>\\d+)E(?P<episode>\\d+)'); + }); + + it('reports the named groups it uses', () => { + expect(regex('(?P<title>.+?) - (?P<episode>[0-9]+)').groups).toEqual(['title', 'episode']); + }); + + it('unnames groups in the filter form', () => { + expect(regex('Ep(?P<episode>[0-9]+)').filterRegex).toBe('(?:Ep(?:[0-9]+))'); + }); + + it('turns leading global flags into a scoped group in the filter form', () => { + expect(regex('(?i)ep(?P<episode>[0-9]+)').filterRegex).toBe('(?i:ep(?:[0-9]+))'); + }); + + it('merges several leading flag groups', () => { + expect(regex('(?i)(?s)a.b').filterRegex).toBe('(?is:a.b)'); + }); + + it('leaves escaped parentheses and character classes alone', () => { + expect(regex('\\(?P<x>[(?P<y>]').filterRegex).toBe('(?:\\(?P<x>[(?P<y>])'); + }); + + it('refuses named backreferences', () => { + expect(() => regex('(?P<episode>[0-9]+) (?P=episode)')).toThrow('backreference'); + }); + + it('refuses group names other than the placeholders', () => { + expect(() => regex('(?P<number>[0-9]+)')).toThrow('number'); + }); + + it('refuses global flags after the start', () => { + expect(() => regex('abc(?i)')).toThrow('start'); + }); + }); + + describe('buildShowFilter', () => { + it('returns the one filter of a single pattern', () => { + expect(buildShowFilter(['(?i:a)'])).toBe('(?i:a)'); + }); + + it('joins several patterns into one alternation', () => { + expect(buildShowFilter(['(?i:a)', '(?:b)'])).toBe('(?i:a)|(?:b)'); + }); + }); + + describe('excludeTermRegex', () => { + it('matches the term as literal text, ignoring case and spacing', () => { + expect(excludeTermRegex(' Official Clip. ')).toBe('(?i:Official\\s+Clip\\.)'); + }); + + it('refuses an empty term', () => { + expect(() => excludeTermRegex(' ')).toThrow(PatternError); + }); + }); + + describe('validateSources', () => { + it('accepts title season and episode with both captured', () => { + expect(validateSources({ groups: ['season', 'episode'], seasonSource: 'title', episodeSource: 'title' })).toBeNull(); + }); + + it('needs {season} for a title season', () => { + expect(validateSources({ groups: ['episode'], seasonSource: 'title', episodeSource: 'title' })).toMatch('{season}'); + }); + + it('needs {episode} for a title episode', () => { + expect(validateSources({ groups: [], seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' })).toMatch('{episode}'); + }); + + it('needs a fixed season between 0 and 199', () => { + expect(validateSources({ groups: [], seasonSource: 'fixed', seasonFixed: 200, episodeSource: 'order' })).toMatch('0 and 199'); + }); + + it('accepts season 0', () => { + expect(validateSources({ groups: [], seasonSource: 'fixed', seasonFixed: 0, episodeSource: 'order' })).toBeNull(); + }); + + it('allows date episodes only in year seasons', () => { + expect(validateSources({ groups: [], seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'date' })).toMatch('year'); + }); + + it('accepts date episodes in year seasons', () => { + expect(validateSources({ groups: [], seasonSource: 'year', episodeSource: 'date' })).toBeNull(); + }); + + it('refuses an unknown source', () => { + expect(validateSources({ groups: [], seasonSource: 'month', episodeSource: 'order' })).toMatch('season source'); + }); + }); + + describe('isAssignableSeason', () => { + it('accepts title seasons 0 to 199', () => { + expect([isAssignableSeason(0), isAssignableSeason(199)]).toEqual([true, true]); + }); + + it('accepts upload years 1928 to 2500', () => { + expect([isAssignableSeason(1928), isAssignableSeason(2024), isAssignableSeason(2500)]).toEqual([true, true, true]); + }); + + it('refuses numbers between the ranges, outside them, and non-integers', () => { + expect([200, 1927, 2501, -1, 1.5, '1', null].map(isAssignableSeason)).toEqual([false, false, false, false, false, false, false]); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/showFolderNames.test.js b/server/modules/tvShows/__tests__/showFolderNames.test.js new file mode 100644 index 000000000..85990f1bb --- /dev/null +++ b/server/modules/tvShows/__tests__/showFolderNames.test.js @@ -0,0 +1,33 @@ +const { showFolderNameProblem, sanitizeShowFolderName, folderNameKey } = require('../showFolderNames'); + +describe('showFolderNames', () => { + describe('showFolderNameProblem', () => { + it.each(['__kids', '.hidden', 'Season 01', 'Extras', 'a/b', ''])('flags %p', (name) => { + expect(showFolderNameProblem(name)).not.toBeNull(); + }); + + it('accepts an ordinary show name', () => { + expect(showFolderNameProblem('Hermitcraft')).toBeNull(); + }); + }); + + describe('sanitizeShowFolderName', () => { + it('replaces characters folders can\'t hold like yt-dlp does', () => { + expect(sanitizeShowFolderName('Life Series: Season 1?')).toBe('Life Series: Season 1?'); + }); + + it('returns an empty name for nothing', () => { + expect(sanitizeShowFolderName(null)).toBe(''); + }); + }); + + describe('folderNameKey', () => { + it('compares names ignoring case and accents', () => { + expect(folderNameKey('TV', 'Pokémon')).toBe(folderNameKey('tv', 'POKEMON')); + }); + + it('tells library folders apart', () => { + expect(folderNameKey('TV', 'Show')).not.toBe(folderNameKey('Anime', 'Show')); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleCorpusClassification.test.js b/server/modules/tvShows/__tests__/titleCorpusClassification.test.js new file mode 100644 index 000000000..3c43346e1 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleCorpusClassification.test.js @@ -0,0 +1,83 @@ +// Classifies the real-title corpus with its draft shows through the real +// pattern compiler, Python matcher and numbering, and compares every video +// with the classification the corpus expects. +const corpus = require('./fixtures/channelTitleCorpus.json'); +const { compilePattern } = require('../patternCompiler'); +const { matchVideos } = require('../titleMatcher'); +const { planNumbers } = require('../titleNumbering'); + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.UTC(2026, 9, 2); + +function toShows(channel) { + return channel.shows.map((show) => ({ + key: show.key, + excludeTerms: show.excludeTerms, + patterns: show.patterns.map((entry, index) => ({ + key: `${show.key}#${index}`, + compiledRegex: compilePattern({ text: entry.pattern, kind: 'simple' }).compiledRegex, + seasonSource: entry.season, + seasonFixed: entry.seasonFixed === undefined ? null : entry.seasonFixed, + episodeSource: entry.episode, + })), + })); +} + +// listingIndex 0 is the newest upload: the only order a flat listing gives. +function toVideos(channel) { + return channel.videos.map((video) => ({ + youtubeId: video.id, + title: video.title, + publishedAtMs: NOW - video.listingIndex * DAY, + available: true, + downloaded: false, + })); +} + +async function classify(channel) { + const matches = await matchVideos(toShows(channel), toVideos(channel)); + return planNumbers({ videos: toVideos(channel), matches, stored: new Map(), highWater: new Map() }); +} + +function outcome(video, result) { + const row = result.rows.get(video.id); + if (!row) return { status: 'unmatched' }; + const base = { status: row.status, show: row.showKey }; + if (row.status === 'duplicate') { + const duplicate = result.duplicates.find((entry) => entry.youtubeId === video.id); + return { ...base, season: duplicate.season, episode: duplicate.episode, duplicateOf: duplicate.duplicateOf }; + } + if (row.status === 'unsupported') { + const entry = result.unsupported.find((item) => item.youtubeId === video.id); + return { ...base, reason: entry.reason }; + } + return { ...base, season: row.season, episode: row.episode, episodeTitle: row.episodeTitle, source: row.source }; +} + +function expectedOutcome(expected) { + if (expected.status === 'unmatched') return { status: 'unmatched' }; + const base = { status: expected.status, show: expected.show }; + if (expected.status === 'duplicate') { + return { ...base, season: expected.season, episode: expected.episode, duplicateOf: expected.duplicateOf }; + } + if (expected.status === 'unsupported') return { ...base, reason: expected.reason }; + return { + ...base, + season: expected.season, + episode: expected.episodeSource ? expect.any(Number) : expected.episode, + episodeTitle: expected.episodeTitle, + source: expected.episodeSource || 'title', + }; +} + +describe.each(corpus.channels.map((channel) => [channel.name, channel]))('title corpus: %s', (name, channel) => { + let result; + + beforeAll(async () => { + result = await classify(channel); + }); + + it.each(channel.videos.map((video) => [video.id, video.title, video]))('%s %s', (id, title, video) => { + expect(outcome(video, result)).toEqual(expectedOutcome(video.expected)); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleEpisodeAssigner.test.js b/server/modules/tvShows/__tests__/titleEpisodeAssigner.test.js new file mode 100644 index 000000000..8659e4836 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleEpisodeAssigner.test.js @@ -0,0 +1,181 @@ +jest.mock('../../../models', () => ({ + VideoClassification: { findByPk: jest.fn(), findAll: jest.fn(), create: jest.fn() }, + TvShow: { findByPk: jest.fn() }, +})); +jest.mock('../titleShowStore', () => ({ listTitleShows: jest.fn(), highWaterMarks: jest.fn(), raiseHighWater: jest.fn() })); +jest.mock('../titleMatcher', () => ({ ...jest.requireActual('../titleMatcher'), matchVideos: jest.fn() })); +jest.mock('../episodeConflicts', () => ({ recordDuplicate: jest.fn(), recordError: jest.fn() })); +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +const { MATCH_KIND } = jest.requireActual('../titleMatcher'); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; +const VIDEO_ID = 'y7xVT7DTt2k'; +// 2020-08-09 12:30 UTC +const INFO = { id: VIDEO_ID, title: 'BEYBLADE EN Episode 20: It\'s All Relative', timestamp: 1596976200 }; + +function show(id, patterns) { + return { id, key: `title:${id}`, name: 'Beyblade', patterns, excludeTerms: [], seasonNames: {} }; +} + +function pattern(id, sources) { + return { id, key: `p${id}`, compiledRegex: 'x', ...sources }; +} + +const TITLE_SHOW = show(3, [pattern(30, { seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' })]); +const SHOW_ROW = { id: 3, kind: 'title', name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', channel_id: CHANNEL_ID, external_key: 'u' }; + +function match(values) { + return new Map([[VIDEO_ID, { showKey: 'title:3', patternKey: 'p30', reason: null, episodeTitle: 'It\'s All Relative', ...values }]]); +} + +describe('titleEpisodeAssigner.resolveTitlePlacement', () => { + let assigner; + let models; + let store; + let matcher; + let conflicts; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + store = require('../titleShowStore'); + matcher = require('../titleMatcher'); + conflicts = require('../episodeConflicts'); + assigner = require('../titleEpisodeAssigner'); + store.listTitleShows.mockResolvedValue([TITLE_SHOW]); + store.highWaterMarks.mockResolvedValue(new Map()); + models.VideoClassification.findByPk.mockResolvedValue(null); + models.VideoClassification.findAll.mockResolvedValue([]); + models.VideoClassification.create.mockImplementation(async (values) => values); + models.TvShow.findByPk.mockResolvedValue(SHOW_ROW); + }); + + const resolve = (overrides = {}) => assigner.resolveTitlePlacement({ + youtubeId: VIDEO_ID, info: INFO, ownerChannelId: CHANNEL_ID, channelEnabled: true, ...overrides, + }); + + it('leaves a video of an untracked or disabled channel to the channel layout', async () => { + expect(await resolve({ channelEnabled: false })).toBeNull(); + expect(store.listTitleShows).not.toHaveBeenCalled(); + }); + + it('leaves a channel without title shows to the channel layout', async () => { + store.listTitleShows.mockResolvedValue([]); + expect(await resolve()).toBeNull(); + }); + + it('reuses a stored episode without classifying again', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ + youtube_id: VIDEO_ID, channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 20, source: 'title', + episode_title: 'It\'s All Relative', file_stem: 'S01E20 - It\'s All Relative [y7xVT7DTt2k]', title_opt_out: false, + }); + const placement = await resolve(); + expect(placement).toEqual({ + show: SHOW_ROW, + assignment: { season: 1, episode: 20, dateNumbered: false, episodeTitle: 'It\'s All Relative', fileStem: 'S01E20 - It\'s All Relative [y7xVT7DTt2k]' }, + }); + expect(matcher.matchVideos).not.toHaveBeenCalled(); + }); + + it('leaves a video marked "Not an episode" to the channel layout', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ channel_id: CHANNEL_ID, show_id: 3, status: 'opted_out', title_opt_out: true }); + expect(await resolve()).toBeNull(); + }); + + it('leaves a stored duplicate to the channel layout', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ channel_id: CHANNEL_ID, show_id: 3, status: 'duplicate', title_opt_out: false }); + expect(await resolve()).toBeNull(); + }); + + it('leaves a channel-show episode to the channel layout', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ channel_id: CHANNEL_ID, show_id: 9, status: 'assigned', season: 2020, episode: 8091230, file_stem: 's', title_opt_out: false }); + models.TvShow.findByPk.mockResolvedValue({ id: 9, kind: 'channel' }); + expect(await resolve()).toBeNull(); + }); + + it('classifies a video it hasn\'t seen by its full title', async () => { + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.NUMBERED, season: 1, episode: 20 })); + await resolve(); + expect(matcher.matchVideos).toHaveBeenCalledWith([TITLE_SHOW], [{ youtubeId: VIDEO_ID, title: INFO.title }]); + }); + + it('numbers a title-numbered match and stores it', async () => { + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.NUMBERED, season: 1, episode: 20 })); + const placement = await resolve(); + expect(placement.assignment).toEqual({ + season: 1, episode: 20, dateNumbered: false, episodeTitle: 'It\'s All Relative', fileStem: 'S01E20 - It\'s All Relative [y7xVT7DTt2k]', + }); + expect(models.VideoClassification.create).toHaveBeenCalledWith(expect.objectContaining({ + youtube_id: VIDEO_ID, channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', source: 'title', pattern_id: 30, + })); + }); + + it('records a duplicate when another video holds the number', async () => { + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.NUMBERED, season: 1, episode: 20 })); + models.VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'heldBy00001', season: 1, episode: 20 }]); + expect(await resolve()).toBeNull(); + expect(conflicts.recordDuplicate).toHaveBeenCalledWith(expect.objectContaining({ + youtubeId: VIDEO_ID, showId: 3, season: 1, episode: 20, duplicateOf: 'heldBy00001', downloaded: true, + })); + expect(models.VideoClassification.create.mock.calls[0][0]).toMatchObject({ status: 'duplicate', season: null, episode: null }); + }); + + it('allocates the next order number past the high-water mark', async () => { + const orderShow = show(3, [pattern(31, { seasonSource: 'fixed', seasonFixed: 0, episodeSource: 'order' })]); + store.listTitleShows.mockResolvedValue([orderShow]); + store.highWaterMarks.mockResolvedValue(new Map([['title:3|0', 6]])); + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.ORDER, patternKey: 'p31', season: 0, episode: null })); + const placement = await resolve(); + expect(placement.assignment).toMatchObject({ season: 0, episode: 7 }); + expect(store.raiseHighWater).toHaveBeenCalledWith(3, 0, 7); + }); + + it('numbers an upload-time episode from the release time', async () => { + const dateShow = show(3, [pattern(32, { seasonSource: 'year', episodeSource: 'date' })]); + store.listTitleShows.mockResolvedValue([dateShow]); + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.PENDING, patternKey: 'p32', season: null, episode: null })); + const placement = await resolve(); + expect(placement.assignment).toMatchObject({ season: 2020, episode: 8091230, dateNumbered: true }); + }); + + it('numbers a title episode of a year season by the upload year', async () => { + const yearShow = show(3, [pattern(33, { seasonSource: 'year', episodeSource: 'title' })]); + store.listTitleShows.mockResolvedValue([yearShow]); + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.PENDING, patternKey: 'p33', season: null, episode: 20 })); + expect((await resolve()).assignment).toMatchObject({ season: 2020, episode: 20 }); + }); + + it('records an unsupported match and leaves it to the channel layout', async () => { + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.UNSUPPORTED, reason: 'part', season: 1, episode: 1 })); + expect(await resolve()).toBeNull(); + expect(models.VideoClassification.create.mock.calls[0][0]).toMatchObject({ status: 'unsupported', show_id: 3 }); + }); + + it('releases a pending row whose full title no longer matches', async () => { + const pending = { channel_id: CHANNEL_ID, show_id: 3, status: 'pending_number', title_opt_out: false, destroy: jest.fn() }; + models.VideoClassification.findByPk.mockResolvedValue(pending); + matcher.matchVideos.mockResolvedValue(new Map()); + expect(await resolve()).toBeNull(); + expect(pending.destroy).toHaveBeenCalled(); + }); + + it('records a classification error and leaves the video to the channel layout when Python fails', async () => { + matcher.matchVideos.mockRejectedValue(new Error('Title filter regex timed out after 15000 ms')); + expect(await resolve()).toBeNull(); + expect(conflicts.recordError).toHaveBeenCalledWith({ youtubeId: VIDEO_ID, channelId: CHANNEL_ID, message: 'Title filter regex timed out after 15000 ms' }); + }); + + it('numbers again once when the number was taken meanwhile', async () => { + matcher.matchVideos.mockResolvedValue(match({ kind: MATCH_KIND.NUMBERED, season: 1, episode: 20 })); + models.VideoClassification.create + .mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'SequelizeUniqueConstraintError' })) + .mockImplementationOnce(async (values) => values); + models.VideoClassification.findAll + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ youtube_id: 'heldBy00001', season: 1, episode: 20 }]); + expect(await resolve()).toBeNull(); + expect(conflicts.recordDuplicate).toHaveBeenCalled(); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleMatcher.test.js b/server/modules/tvShows/__tests__/titleMatcher.test.js new file mode 100644 index 000000000..b4a766653 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleMatcher.test.js @@ -0,0 +1,150 @@ +// matchVideos runs the real Python script: classification must follow +// Python's re exactly, like yt-dlp's match filter. +const { interpretMatch, matchVideos, MATCH_KIND } = require('../titleMatcher'); +const { compilePattern } = require('../patternCompiler'); + +function pattern(text, sources) { + return { key: text, compiledRegex: compilePattern({ text, kind: 'simple' }).compiledRegex, ...sources }; +} + +const fixedTitle = { seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }; + +describe('titleMatcher', () => { + describe('interpretMatch', () => { + it('numbers an episode from the title in a fixed season', () => { + expect(interpretMatch(fixedTitle, { episode: '20', title: 'It\'s All Relative' })).toEqual({ + kind: MATCH_KIND.NUMBERED, season: 1, episode: 20, episodeTitle: 'It\'s All Relative', reason: null, + }); + }); + + it('takes the season from the title', () => { + const result = interpretMatch({ seasonSource: 'title', episodeSource: 'title' }, { season: '10', episode: '43' }); + expect([result.season, result.episode]).toEqual([10, 43]); + }); + + it('leaves an empty title capture to the video title', () => { + expect(interpretMatch(fixedTitle, { episode: '3', title: ' ' }).episodeTitle).toBeNull(); + }); + + it('marks an order-numbered match for allocation', () => { + expect(interpretMatch({ seasonSource: 'fixed', seasonFixed: 0, episodeSource: 'order' }, {})) + .toMatchObject({ kind: MATCH_KIND.ORDER, season: 0, episode: null }); + }); + + it('waits for the upload time in a year season', () => { + expect(interpretMatch({ seasonSource: 'year', episodeSource: 'title' }, { episode: '4' })) + .toMatchObject({ kind: MATCH_KIND.PENDING, season: null }); + }); + + it('keeps a title episode it can\'t place before the upload year is known', () => { + expect(interpretMatch({ seasonSource: 'year', episodeSource: 'title' }, { episode: '4' }).episode).toBe(4); + }); + + it('waits for the upload time for a date episode', () => { + expect(interpretMatch({ seasonSource: 'year', episodeSource: 'date' }, {}).kind).toBe(MATCH_KIND.PENDING); + }); + + it('marks a compilation unsupported', () => { + expect(interpretMatch(fixedTitle, { episode: '19', episode_end: '20' })) + .toMatchObject({ kind: MATCH_KIND.UNSUPPORTED, reason: 'compilation', season: 1, episode: 19, episodeEnd: 20 }); + }); + + it('marks a part unsupported', () => { + expect(interpretMatch(fixedTitle, { episode: '1', part: '2' })) + .toMatchObject({ kind: MATCH_KIND.UNSUPPORTED, reason: 'part', part: 2 }); + }); + + it('marks a season above 199 unsupported', () => { + expect(interpretMatch({ seasonSource: 'title', episodeSource: 'title' }, { season: '2024', episode: '1' })) + .toMatchObject({ kind: MATCH_KIND.UNSUPPORTED, reason: 'number-out-of-range' }); + }); + + it('marks a title-sourced season that captured nothing unsupported', () => { + expect(interpretMatch({ seasonSource: 'title', episodeSource: 'title' }, { season: null, episode: '3' })) + .toMatchObject({ kind: MATCH_KIND.UNSUPPORTED, reason: 'missing-number' }); + }); + + it('marks a title-sourced episode that captured nothing unsupported', () => { + expect(interpretMatch(fixedTitle, { episode: null }).reason).toBe('missing-number'); + }); + + it('lets an upload-year season come without a capture', () => { + expect(interpretMatch({ seasonSource: 'year', episodeSource: 'title' }, { episode: '4' }).kind).toBe(MATCH_KIND.PENDING); + }); + + it('numbers a year-season title episode when the upload year is known', () => { + expect(interpretMatch({ seasonSource: 'year', episodeSource: 'title' }, { episode: '4' }, { uploadYear: 2024 })) + .toMatchObject({ kind: MATCH_KIND.NUMBERED, season: 2024, episode: 4 }); + }); + + it('still waits for the upload time for a date episode when the year is known', () => { + expect(interpretMatch({ seasonSource: 'year', episodeSource: 'date' }, {}, { uploadYear: 2024 })) + .toMatchObject({ kind: MATCH_KIND.PENDING, season: null }); + }); + + it('still allocates an order episode in a year season when the year is known', () => { + expect(interpretMatch({ seasonSource: 'year', episodeSource: 'order' }, {}, { uploadYear: 2024 })) + .toMatchObject({ kind: MATCH_KIND.PENDING, season: null }); + }); + + it('ignores the upload year for a title or fixed season', () => { + expect(interpretMatch(fixedTitle, { episode: '4' }, { uploadYear: 2024 }).season).toBe(1); + }); + + it('marks episode 0 unsupported', () => { + expect(interpretMatch(fixedTitle, { episode: '0' }).reason).toBe('number-out-of-range'); + }); + + it('marks an episode beyond a 32-bit integer unsupported', () => { + expect(interpretMatch(fixedTitle, { episode: '2147483648' }).reason).toBe('number-out-of-range'); + }); + }); + + describe('matchVideos', () => { + const shows = [ + { + key: 'title:1', + excludeTerms: ['Official Clip'], + patterns: [pattern('BEYBLADE BURST QUADSTRIKE EP{episode}', fixedTitle)], + }, + { + key: 'title:2', + excludeTerms: [], + patterns: [pattern('{title} | BEYBLADE BURST QUADSTRIKE EP* | Official Clip', { seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'order' })], + }, + ]; + + it('gives each video the first show that matches it', async () => { + const result = await matchVideos(shows, [{ youtubeId: 'a', title: 'BEYBLADE BURST QUADSTRIKE EP3 recap' }]); + expect(result.get('a')).toMatchObject({ showKey: 'title:1', patternKey: shows[0].patterns[0].key, episode: 3 }); + }); + + it('passes a title one show excludes on to the next show', async () => { + const result = await matchVideos(shows, [{ youtubeId: 'b', title: 'Bel battles Quadra | BEYBLADE BURST QUADSTRIKE EP1 | Official Clip' }]); + expect(result.get('b')).toMatchObject({ showKey: 'title:2', kind: MATCH_KIND.ORDER, episodeTitle: 'Bel battles Quadra' }); + }); + + it('leaves a video no show matches out of the result', async () => { + const result = await matchVideos(shows, [{ youtubeId: 'c', title: 'Find the ODD One Out' }]); + expect(result.has('c')).toBe(false); + }); + + it('matches nothing without shows', async () => { + expect((await matchVideos([], [{ youtubeId: 'd', title: 'x' }])).size).toBe(0); + }); + + it('numbers a year-season title episode from the video\'s upload year', async () => { + const yearShows = [{ + key: 'title:3', + excludeTerms: [], + patterns: [pattern('Hermitcraft {episode}', { seasonSource: 'year', episodeSource: 'title' })], + }]; + const result = await matchVideos(yearShows, [ + { youtubeId: 'e', title: 'Hermitcraft 12', uploadYear: 2024 }, + { youtubeId: 'f', title: 'Hermitcraft 13' }, + ]); + expect(result.get('e')).toMatchObject({ kind: MATCH_KIND.NUMBERED, season: 2024, episode: 12 }); + expect(result.get('f')).toMatchObject({ kind: MATCH_KIND.PENDING, season: null, episode: 13 }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleNumbering.test.js b/server/modules/tvShows/__tests__/titleNumbering.test.js new file mode 100644 index 000000000..1b1db92ff --- /dev/null +++ b/server/modules/tvShows/__tests__/titleNumbering.test.js @@ -0,0 +1,384 @@ +const { planNumbers, computeGaps, ROW_STATUS } = require('../titleNumbering'); +const { MATCH_KIND } = require('../titleMatcher'); + +const DAY = 24 * 60 * 60 * 1000; + +function video(youtubeId, { daysAgo = 0, available = true, downloaded = false } = {}) { + return { youtubeId, title: youtubeId, publishedAtMs: 1e12 - daysAgo * DAY, available, downloaded }; +} + +function numbered(episode, extra = {}) { + return { showKey: 'title:1', patternKey: 'p1', kind: MATCH_KIND.NUMBERED, season: 1, episode, episodeTitle: `Ep ${episode}`, reason: null, ...extra }; +} + +function stored(values) { + return { showKind: 'title', showActive: true, titleOptOut: false, patternKey: 'p1', fileStem: null, episodeTitle: null, ...values }; +} + +function plan({ videos, matches = new Map(), storedRows = new Map(), highWater = new Map(), overrides = new Map() }) { + return planNumbers({ videos, matches, stored: storedRows, highWater, overrides }); +} + +describe('titleNumbering.planNumbers', () => { + it('assigns a title-numbered match its captured season and episode', () => { + const result = plan({ videos: [video('a')], matches: new Map([['a', numbered(20)]]) }); + expect(result.rows.get('a')).toMatchObject({ + showKey: 'title:1', status: ROW_STATUS.ASSIGNED, season: 1, episode: 20, source: 'title', episodeTitle: 'Ep 20', + }); + }); + + it('gives a shared number to the earliest upload and marks the later one a duplicate', () => { + const result = plan({ + videos: [video('new', { daysAgo: 10 }), video('old', { daysAgo: 700 })], + matches: new Map([['new', numbered(20)], ['old', numbered(20)]]), + }); + expect(result.duplicates).toEqual([{ youtubeId: 'new', showKey: 'title:1', season: 1, episode: 20, duplicateOf: 'old' }]); + }); + + it('stores a duplicate without a number', () => { + const result = plan({ + videos: [video('new', { daysAgo: 10 }), video('old', { daysAgo: 700 })], + matches: new Map([['new', numbered(20)], ['old', numbered(20)]]), + }); + expect(result.rows.get('new')).toMatchObject({ status: ROW_STATUS.DUPLICATE, season: null, episode: null }); + }); + + it('prefers an available upload over an earlier unavailable one', () => { + const result = plan({ + videos: [video('gone', { daysAgo: 700, available: false }), video('here', { daysAgo: 10 })], + matches: new Map([['gone', numbered(5)], ['here', numbered(5)]]), + }); + expect(result.rows.get('here').status).toBe(ROW_STATUS.ASSIGNED); + }); + + it('keeps the number with its stored holder even when an earlier upload appears', () => { + const result = plan({ + videos: [video('holder', { daysAgo: 10 }), video('earlier', { daysAgo: 700 })], + matches: new Map([['holder', numbered(20)], ['earlier', numbered(20)]]), + storedRows: new Map([['holder', stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 20, source: 'title' })]]), + }); + expect(result.rows.get('earlier').status).toBe(ROW_STATUS.DUPLICATE); + }); + + it('allocates order numbers after the season high-water mark, oldest upload first', () => { + const order = { kind: MATCH_KIND.ORDER, showKey: 'title:2', patternKey: 'p2', season: 0, episode: null, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('b', { daysAgo: 1 }), video('a', { daysAgo: 5 })], + matches: new Map([['a', order], ['b', order]]), + highWater: new Map([['title:2|0', 7]]), + }); + expect([result.rows.get('a').episode, result.rows.get('b').episode]).toEqual([8, 9]); + }); + + it('raises the high-water mark past the numbers it allocates', () => { + const order = { kind: MATCH_KIND.ORDER, showKey: 'title:2', patternKey: 'p2', season: 0, episode: null, episodeTitle: null, reason: null }; + const result = plan({ videos: [video('a')], matches: new Map([['a', order]]), highWater: new Map([['title:2|0', 7]]) }); + expect(result.highWater.get('title:2|0')).toBe(8); + }); + + it('keeps an order number the video already holds in the season', () => { + const order = { kind: MATCH_KIND.ORDER, showKey: 'title:2', patternKey: 'p2', season: 0, episode: null, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('a')], + matches: new Map([['a', order]]), + storedRows: new Map([['a', stored({ showKey: 'title:2', status: 'assigned', season: 0, episode: 3, source: 'order' })]]), + highWater: new Map([['title:2|0', 9]]), + }); + expect(result.rows.get('a').episode).toBe(3); + }); + + it('skips order numbers other rows of the season hold', () => { + const order = { kind: MATCH_KIND.ORDER, showKey: 'title:1', patternKey: 'p2', season: 1, episode: null, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('t'), video('o')], + matches: new Map([['t', numbered(1)], ['o', order]]), + }); + expect(result.rows.get('o').episode).toBe(2); + }); + + it('marks a pending match pending_number without a number', () => { + const pending = { kind: MATCH_KIND.PENDING, showKey: 'title:1', patternKey: 'p3', season: null, episode: null, episodeTitle: null, reason: null }; + const result = plan({ videos: [video('a')], matches: new Map([['a', pending]]) }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.PENDING, season: null, episode: null }); + }); + + it('keeps the number of a downloaded pending match already numbered in the show', () => { + const pending = { kind: MATCH_KIND.PENDING, showKey: 'title:1', patternKey: 'p3', season: null, episode: null, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('a', { downloaded: true })], + matches: new Map([['a', pending]]), + storedRows: new Map([['a', stored({ showKey: 'title:1', status: 'assigned', season: 2024, episode: 3151200, source: 'date' })]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.ASSIGNED, season: 2024, episode: 3151200, source: 'date' }); + }); + + it('waits for the upload year when the stored number comes from a fixed season', () => { + const pending = { kind: MATCH_KIND.PENDING, showKey: 'title:1', patternKey: 'p3', season: null, episode: 30, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('a')], + matches: new Map([['a', pending]]), + storedRows: new Map([['a', stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 30, source: 'title' })]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.PENDING, season: null, episode: null }); + }); + + it('waits for the upload year when the title now gives another episode', () => { + const pending = { kind: MATCH_KIND.PENDING, showKey: 'title:1', patternKey: 'p3', season: null, episode: 31, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('a')], + matches: new Map([['a', pending]]), + storedRows: new Map([['a', stored({ showKey: 'title:1', status: 'assigned', season: 2021, episode: 30, source: 'title' })]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.PENDING }); + }); + + it('keeps the upload-year number of a deleted download', () => { + const pending = { kind: MATCH_KIND.PENDING, showKey: 'title:1', patternKey: 'p3', season: null, episode: 30, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('a')], + matches: new Map([['a', pending]]), + storedRows: new Map([['a', stored({ showKey: 'title:1', status: 'assigned', season: 2021, episode: 30, source: 'title' })]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.ASSIGNED, season: 2021, episode: 30 }); + }); + + it('keeps the duplicate verdict of a downloaded pending match decided at its download', () => { + const pending = { kind: MATCH_KIND.PENDING, showKey: 'title:1', patternKey: 'p3', season: null, episode: 5, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('a', { downloaded: true })], + matches: new Map([['a', pending]]), + storedRows: new Map([['a', stored({ showKey: 'title:1', status: 'duplicate', season: null, episode: null, source: 'title' })]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.DUPLICATE, keep: true }); + }); + + it('classifies a pending match again for a duplicate that is not downloaded', () => { + const pending = { kind: MATCH_KIND.PENDING, showKey: 'title:1', patternKey: 'p3', season: null, episode: 5, episodeTitle: null, reason: null }; + const result = plan({ + videos: [video('a')], + matches: new Map([['a', pending]]), + storedRows: new Map([['a', stored({ showKey: 'title:1', status: 'duplicate', season: null, episode: null, source: 'title' })]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.PENDING }); + }); + + it('records an unsupported match with its reason', () => { + const compilation = numbered(19, { kind: MATCH_KIND.UNSUPPORTED, reason: 'compilation', episodeEnd: 20 }); + const result = plan({ videos: [video('c')], matches: new Map([['c', compilation]]) }); + expect(result.unsupported).toEqual([expect.objectContaining({ youtubeId: 'c', reason: 'compilation', episode: 19, episodeEnd: 20 })]); + }); + + it('never puts an opted-out video back into a title show', () => { + const optedOut = stored({ showKey: 'title:1', status: 'opted_out', season: null, episode: null, source: null, titleOptOut: true }); + const result = plan({ videos: [video('a')], matches: new Map([['a', numbered(4)]]), storedRows: new Map([['a', optedOut]]) }); + expect(result.rows.get('a')).toMatchObject({ status: 'opted_out', keep: true }); + }); + + it('leaves a manual assignment untouched', () => { + const manual = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 49, source: 'manual' }); + const result = plan({ videos: [video('a')], matches: new Map([['a', numbered(4)]]), storedRows: new Map([['a', manual]]) }); + expect(result.rows.get('a')).toMatchObject({ episode: 49, source: 'manual', keep: true }); + }); + + it('makes title claimants of a manually assigned number duplicates of it', () => { + const manual = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 4, source: 'manual' }); + const result = plan({ + videos: [video('m'), video('t', { daysAgo: 900 })], + matches: new Map([['t', numbered(4)]]), + storedRows: new Map([['m', manual]]), + }); + expect(result.duplicates).toEqual([expect.objectContaining({ youtubeId: 't', duplicateOf: 'm' })]); + }); + + it('releases the row of a video that no longer matches its title show', () => { + const row = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 4, source: 'title' }); + const result = plan({ videos: [video('a')], storedRows: new Map([['a', row]]) }); + expect(result.rows.get('a')).toBeNull(); + }); + + it('keeps the order number of a retired show\'s video that matches nothing', () => { + const row = stored({ showKey: 'title:9', showActive: false, status: 'assigned', season: 0, episode: 2, source: 'order' }); + const result = plan({ videos: [video('a')], storedRows: new Map([['a', row]]) }); + expect(result.rows.get('a')).toMatchObject({ episode: 2, keep: true }); + }); + + it('keeps a channel-show episode that matches no title show', () => { + const row = stored({ showKey: 'channel:UC1', showKind: 'channel', status: 'assigned', season: 2024, episode: 1010101, source: 'date' }); + const result = plan({ videos: [video('a', { downloaded: true })], storedRows: new Map([['a', row]]) }); + expect(result.rows.get('a')).toMatchObject({ showKey: 'channel:UC1', keep: true }); + }); + + it('moves a channel-show episode into a title show it now matches', () => { + const row = stored({ showKey: 'channel:UC1', showKind: 'channel', status: 'assigned', season: 2024, episode: 1010101, source: 'date' }); + const result = plan({ videos: [video('a', { downloaded: true })], matches: new Map([['a', numbered(7)]]), storedRows: new Map([['a', row]]) }); + expect(result.rows.get('a')).toMatchObject({ showKey: 'title:1', season: 1, episode: 7, keep: false }); + }); + + it('applies a manual assignment override', () => { + const result = plan({ + videos: [video('a')], + overrides: new Map([['a', { showKey: 'title:1', season: 1, episode: 51 }]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.ASSIGNED, season: 1, episode: 51, source: 'manual' }); + }); + + it('turns the holder of an overridden number into a duplicate of the override', () => { + const row = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 51, source: 'title' }); + const result = plan({ + videos: [video('holder'), video('fix')], + matches: new Map([['holder', numbered(51)]]), + storedRows: new Map([['holder', row]]), + overrides: new Map([['fix', { showKey: 'title:1', season: 1, episode: 51 }]]), + }); + expect(result.duplicates).toEqual([expect.objectContaining({ youtubeId: 'holder', duplicateOf: 'fix' })]); + }); + + it('applies a "Not an episode" override', () => { + const row = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 4, source: 'title' }); + const result = plan({ + videos: [video('a')], + matches: new Map([['a', numbered(4)]]), + storedRows: new Map([['a', row]]), + overrides: new Map([['a', { optOut: true }]]), + }); + expect(result.rows.get('a')).toMatchObject({ showKey: 'title:1', status: ROW_STATUS.OPTED_OUT, titleOptOut: true, season: null }); + }); +}); + +describe('titleNumbering.planNumbers review fixes', () => { + const order = { kind: MATCH_KIND.ORDER, showKey: 'title:1', patternKey: 'p2', season: 1, episode: null, episodeTitle: null, reason: null }; + const manualTo5 = new Map([['copy', { showKey: 'title:1', season: 1, episode: 5 }]]); + + it('takes a manually held number for an override and leaves the holder without it', () => { + const result = plan({ + videos: [video('holder', { daysAgo: 700 }), video('copy', { daysAgo: 10 })], + matches: new Map([['holder', numbered(5)], ['copy', numbered(5)]]), + storedRows: new Map([['holder', stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 5, source: 'manual' })]]), + overrides: manualTo5, + }); + expect(result.rows.get('holder')).toMatchObject({ status: ROW_STATUS.DUPLICATE, season: null, episode: null }); + }); + + it('records the displaced manual holder as a duplicate of the override', () => { + const result = plan({ + videos: [video('holder', { daysAgo: 700 }), video('copy', { daysAgo: 10 })], + matches: new Map([['holder', numbered(5)], ['copy', numbered(5)]]), + storedRows: new Map([['holder', stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 5, source: 'manual' })]]), + overrides: manualTo5, + }); + expect(result.duplicates).toEqual([{ youtubeId: 'holder', showKey: 'title:1', season: 1, episode: 5, duplicateOf: 'copy' }]); + }); + + it('gives an order-numbered holder of an overridden number the next order number', () => { + const result = plan({ + videos: [video('holder', { daysAgo: 700 }), video('copy', { daysAgo: 10 })], + matches: new Map([['holder', order]]), + storedRows: new Map([['holder', stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 5, source: 'order', patternKey: 'p2' })]]), + highWater: new Map([['title:1|1', 5]]), + overrides: manualTo5, + }); + expect(result.rows.get('holder')).toMatchObject({ status: ROW_STATUS.ASSIGNED, season: 1, episode: 6, source: 'order' }); + }); + + it('hands a number from a removed, never-downloaded holder to the earliest available upload', () => { + const result = plan({ + videos: [video('gone', { daysAgo: 700, available: false }), video('reupload', { daysAgo: 10 })], + matches: new Map([['gone', numbered(20)], ['reupload', numbered(20)]]), + storedRows: new Map([['gone', stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 20, source: 'title' })]]), + }); + expect(result.rows.get('reupload')).toMatchObject({ status: ROW_STATUS.ASSIGNED, season: 1, episode: 20 }); + }); + + it('keeps the number with a removed holder that was downloaded', () => { + const result = plan({ + videos: [video('kept', { daysAgo: 700, available: false, downloaded: true }), video('reupload', { daysAgo: 10 })], + matches: new Map([['kept', numbered(20)], ['reupload', numbered(20)]]), + storedRows: new Map([['kept', stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 20, source: 'title' })]]), + }); + expect(result.rows.get('reupload').status).toBe(ROW_STATUS.DUPLICATE); + }); + + it('keeps the channel-show row of an episode that loses a title claim', () => { + const channelRow = stored({ showKey: 'channel:UC1', showKind: 'channel', status: 'assigned', season: 2024, episode: 3151200, source: 'date', patternKey: null }); + const result = plan({ + videos: [video('holder', { daysAgo: 700 }), video('episode', { daysAgo: 10, downloaded: true })], + matches: new Map([['holder', numbered(20)], ['episode', numbered(20)]]), + storedRows: new Map([['episode', channelRow]]), + }); + expect(result.rows.get('episode')).toMatchObject({ showKey: 'channel:UC1', status: ROW_STATUS.ASSIGNED, season: 2024, episode: 3151200 }); + }); + + it('still records a channel-show episode that loses a title claim as a duplicate', () => { + const channelRow = stored({ showKey: 'channel:UC1', showKind: 'channel', status: 'assigned', season: 2024, episode: 3151200, source: 'date', patternKey: null }); + const result = plan({ + videos: [video('holder', { daysAgo: 700 }), video('episode', { daysAgo: 10, downloaded: true })], + matches: new Map([['holder', numbered(20)], ['episode', numbered(20)]]), + storedRows: new Map([['episode', channelRow]]), + }); + expect(result.duplicates).toEqual([{ youtubeId: 'episode', showKey: 'title:1', season: 1, episode: 20, duplicateOf: 'holder' }]); + }); +}); + +describe('titleNumbering.planNumbers reset override', () => { + it('classifies an opted-out video again', () => { + const optedOut = stored({ showKey: 'title:1', status: 'opted_out', season: null, episode: null, source: null, titleOptOut: true }); + const result = plan({ + videos: [video('a')], matches: new Map([['a', numbered(4)]]), storedRows: new Map([['a', optedOut]]), + overrides: new Map([['a', { reset: true }]]), + }); + expect(result.rows.get('a')).toMatchObject({ status: ROW_STATUS.ASSIGNED, episode: 4, titleOptOut: false }); + }); + + it('releases a manual row that no pattern matches', () => { + const manual = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 49, source: 'manual' }); + const result = plan({ videos: [video('a')], storedRows: new Map([['a', manual]]), overrides: new Map([['a', { reset: true }]]) }); + expect(result.rows.get('a')).toBeNull(); + }); +}); + +describe('titleNumbering.planNumbers frozen videos', () => { + it('keeps a frozen video\'s row as stored even when its title now claims another number', () => { + const row = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 4, source: 'title' }); + const result = planNumbers({ + videos: [video('a')], matches: new Map([['a', numbered(9)]]), stored: new Map([['a', row]]), + highWater: new Map(), frozen: new Set(['a']), + }); + expect(result.rows.get('a')).toMatchObject({ episode: 4, keep: true }); + }); + + it('makes a new video that claims a frozen video\'s number its duplicate', () => { + const row = stored({ showKey: 'title:1', status: 'assigned', season: 1, episode: 4, source: 'title' }); + const result = planNumbers({ + videos: [video('a', { daysAgo: 1 }), video('new', { daysAgo: 900 })], + matches: new Map([['new', numbered(4)]]), stored: new Map([['a', row]]), + highWater: new Map(), frozen: new Set(['a']), + }); + expect(result.duplicates).toEqual([expect.objectContaining({ youtubeId: 'new', duplicateOf: 'a' })]); + }); + + it('leaves out a frozen video without a row', () => { + const result = planNumbers({ + videos: [video('a')], matches: new Map(), stored: new Map(), highWater: new Map(), frozen: new Set(['a']), + }); + expect(result.rows.has('a')).toBe(false); + }); +}); + +describe('titleNumbering.computeGaps', () => { + const row = (episode, source = 'title', season = 1) => ({ showKey: 'title:1', status: 'assigned', season, episode, source }); + + it('lists the numbers missing below the highest episode of a season', () => { + expect(computeGaps([row(1), row(2), row(5)])).toEqual([ + { showKey: 'title:1', season: 1, have: 3, highest: 5, missing: [3, 4], truncated: false }, + ]); + }); + + it('skips date-numbered seasons', () => { + expect(computeGaps([row(1010101, 'date', 2024), row(5050505, 'date', 2024)])).toEqual([]); + }); + + it('caps the missing list', () => { + const [gap] = computeGaps([row(1), row(400)], { limit: 3 }); + expect([gap.missing, gap.truncated]).toEqual([[2, 3, 4], true]); + }); +}); diff --git a/server/modules/tvShows/__tests__/titlePlanner.test.js b/server/modules/tvShows/__tests__/titlePlanner.test.js new file mode 100644 index 000000000..08b5ce01d --- /dev/null +++ b/server/modules/tvShows/__tests__/titlePlanner.test.js @@ -0,0 +1,304 @@ +jest.mock('../../../models', () => ({ + Video: { findAll: jest.fn() }, + VideoClassification: { findAll: jest.fn() }, + TvShow: { findAll: jest.fn() }, +})); +jest.mock('../../../models/channelvideo', () => ({ findAll: jest.fn() })); +jest.mock('../titleShowStore', () => ({ listTitleShows: jest.fn(), highWaterMarks: jest.fn() })); +jest.mock('../titleMatcher', () => ({ + ...jest.requireActual('../titleMatcher'), + matchVideos: jest.fn(), +})); + +const { MATCH_KIND } = jest.requireActual('../titleMatcher'); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; + +function draftShow(key, extra = {}) { + return { + id: key.startsWith('title:') ? Number(key.slice(6)) : null, + key, position: 0, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV Shows', + excludeTerms: [], seasonNames: {}, + patterns: [{ key: `${key}#0`, compiledRegex: 'x', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }], + ...extra, + }; +} + +function video(youtubeId, extra = {}) { + return { youtubeId, title: youtubeId, publishedAtMs: 0, available: true, downloaded: false, filePath: null, videoId: null, ...extra }; +} + +function numbered(showKey, episode) { + return { showKey, patternKey: `${showKey}#0`, kind: MATCH_KIND.NUMBERED, season: 1, episode, episodeTitle: `E${episode}`, reason: null }; +} + +const storedTitleShow = { key: 'title:3', kind: 'title', active: true, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV Shows' }; + +function storedRow(extra) { + return { + showKey: 'title:3', showKind: 'title', showActive: true, status: 'assigned', season: 1, episode: 20, source: 'title', + titleOptOut: false, patternKey: 'title:3#0', episodeTitle: 'E20', fileStem: 'S01E20 - E20 [a]', ...extra, + }; +} + +describe('titlePlanner', () => { + let planner; + let models; + let ChannelVideo; + let titleShowStore; + let titleMatcher; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + ChannelVideo = require('../../../models/channelvideo'); + titleShowStore = require('../titleShowStore'); + titleMatcher = require('../titleMatcher'); + planner = require('../titlePlanner'); + }); + + describe('buildChannelPlan', () => { + const build = (params) => planner.buildChannelPlan({ + drafts: [draftShow('title:3')], + storedShows: new Map([['title:3', storedTitleShow]]), + stored: new Map(), + matches: new Map(), + highWater: new Map(), + overrides: new Map(), + ...params, + }); + + it('needs no file move for a video that is not downloaded', () => { + const plan = build({ videos: [video('a')], matches: new Map([['a', numbered('title:3', 20)]]) }); + expect(plan.entries[0]).toMatchObject({ youtubeId: 'a', moves: false, after: expect.objectContaining({ episode: 20 }) }); + expect(plan.requiresReorganize).toBe(false); + }); + + it('moves a downloaded video that joins a title show', () => { + const plan = build({ videos: [video('a', { downloaded: true, filePath: '/d/__Kids/Ch/a.mp4' })], matches: new Map([['a', numbered('title:3', 20)]]) }); + expect(plan.entries[0].moves).toBe(true); + expect(plan.requiresReorganize).toBe(true); + }); + + it('keeps a downloaded episode in place when nothing about it changes', () => { + const plan = build({ + videos: [video('a', { downloaded: true, filePath: '/d/__TV Shows/Beyblade/Season 01/S01E20 - E20 [a].mp4' })], + stored: new Map([['a', storedRow()]]), + matches: new Map([['a', numbered('title:3', 20)]]), + }); + expect(plan.entries[0].moves).toBe(false); + }); + + it('leaves a downloaded file outside the downloads folder where it is', () => { + const plan = build({ + videos: [video('a', { downloaded: true, filePath: '/elsewhere/a.mp4', outsideDownloads: true })], + matches: new Map([['a', numbered('title:3', 20)]]), + }); + expect([plan.entries[0].moves, plan.entries[0].staysOutside, plan.requiresReorganize]).toEqual([false, true, false]); + }); + + it('moves a downloaded episode whose number changes', () => { + const plan = build({ + videos: [video('a', { downloaded: true, filePath: '/d/__TV Shows/Beyblade/Season 01/S01E20 - E20 [a].mp4' })], + stored: new Map([['a', storedRow()]]), + matches: new Map([['a', numbered('title:3', 21)]]), + }); + expect(plan.entries[0].moves).toBe(true); + }); + + it('moves the downloaded episodes of a show whose folder changes', () => { + const plan = build({ + drafts: [draftShow('title:3', { folderName: 'Beyblade (2001)' })], + videos: [video('a', { downloaded: true, filePath: '/d/__TV Shows/Beyblade/Season 01/S01E20 - E20 [a].mp4' })], + stored: new Map([['a', storedRow()]]), + matches: new Map([['a', numbered('title:3', 20)]]), + }); + expect([plan.entries[0].moves, plan.relocated]).toEqual([true, ['title:3']]); + }); + + it('moves a downloaded episode out of a show it leaves', () => { + const plan = build({ + drafts: [], + videos: [video('a', { downloaded: true, filePath: '/d/__TV Shows/Beyblade/Season 01/S01E20 - E20 [a].mp4' })], + stored: new Map([['a', storedRow()]]), + }); + expect([plan.entries[0].moves, plan.entries[0].after, plan.retired]).toEqual([true, null, ['title:3']]); + }); + + it('does not move a downloaded video whose stored row never placed its file', () => { + const plan = build({ + drafts: [], + videos: [video('a', { downloaded: true, filePath: '/d/__Kids/Ch/Ch - a [a].mp4' })], + stored: new Map([['a', storedRow()]]), + }); + expect(plan.entries[0].moves).toBe(false); + }); + + it('moves a downloaded duplicate nowhere', () => { + const plan = build({ + videos: [video('old'), video('new', { downloaded: true, publishedAtMs: 5, filePath: '/d/__Kids/Ch/new.mp4' })], + matches: new Map([['old', numbered('title:3', 20)], ['new', numbered('title:3', 20)]]), + }); + expect(plan.entries.find((entry) => entry.youtubeId === 'new')).toMatchObject({ moves: false, after: expect.objectContaining({ status: 'duplicate' }) }); + }); + + it('needs no move for a downloaded year-show duplicate decided at its download', () => { + const pending = { showKey: 'title:3', patternKey: 'title:3#0', kind: MATCH_KIND.PENDING, season: null, episode: 5, episodeTitle: null, reason: null }; + const plan = build({ + videos: [video('a', { downloaded: true, filePath: '/d/__Kids/Ch/a.mp4' })], + matches: new Map([['a', pending]]), + stored: new Map([['a', storedRow({ status: 'duplicate', season: null, episode: null, fileStem: null })]]), + }); + expect(plan.entries[0]).toMatchObject({ moves: false, after: expect.objectContaining({ status: 'duplicate' }) }); + expect(plan.requiresReorganize).toBe(false); + }); + + it('marks a downloaded video waiting for an upload-time number as moving', () => { + const pending = { showKey: 'title:3', patternKey: 'title:3#0', kind: MATCH_KIND.PENDING, season: null, episode: null, episodeTitle: null, reason: null }; + const plan = build({ videos: [video('a', { downloaded: true, filePath: '/d/__Kids/Ch/a.mp4' })], matches: new Map([['a', pending]]) }); + expect(plan.entries[0].moves).toBe(true); + }); + + it('leaves out videos with neither a match nor a row', () => { + expect(build({ videos: [video('loose')] }).entries).toEqual([]); + }); + + it('leaves every video but the ones asked for exactly as stored', () => { + const plan = build({ + videos: [video('old'), video('new')], + stored: new Map([['old', storedRow({ episode: 4 })]]), + matches: new Map([['old', numbered('title:3', 9)], ['new', numbered('title:3', 5)]]), + onlyIds: new Set(['new']), + }); + expect(plan.entries.map((entry) => [entry.youtubeId, entry.after.episode])).toEqual([['old', 4], ['new', 5]]); + }); + + it('counts the videos it looked at', () => { + expect(build({ videos: [video('loose'), video('x')] }).knownVideos).toBe(2); + }); + }); + + describe('planChannel', () => { + const channel = { channel_id: CHANNEL_ID, title: 'BEYBLADE Official' }; + + beforeEach(() => { + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'a', title: 'BEYBLADE EN Episode 20: x', publishedAt: '2020-08-09T00:00:00.000Z', youtube_removed: false, availability: null }, + ]); + models.Video.findAll.mockResolvedValue([ + { id: 11, youtubeId: 'b', youTubeVideoName: 'BEYBLADE EN Episode 2: y', originalDate: '20200628', channel_id: CHANNEL_ID, filePath: '/d/b.mp4', audioFilePath: null, removed: false }, + ]); + models.VideoClassification.findAll.mockResolvedValue([]); + models.TvShow.findAll.mockResolvedValue([]); + titleShowStore.listTitleShows.mockResolvedValue([]); + titleShowStore.highWaterMarks.mockResolvedValue(new Map()); + titleMatcher.matchVideos.mockResolvedValue(new Map()); + }); + + it('matches listed and downloaded videos of the channel', async () => { + await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + expect(titleMatcher.matchVideos.mock.calls[0][1]).toEqual([ + expect.objectContaining({ youtubeId: 'a', title: 'BEYBLADE EN Episode 20: x', downloaded: false }), + expect.objectContaining({ youtubeId: 'b', title: 'BEYBLADE EN Episode 2: y', downloaded: true, videoId: 11 }), + ]); + }); + + it('dates a listed video by its listing date and a downloaded one by its upload date', async () => { + await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + const [a, b] = titleMatcher.matchVideos.mock.calls[0][1]; + expect([a.publishedAtMs, b.publishedAtMs]).toEqual([Date.UTC(2020, 7, 9), Date.UTC(2020, 5, 28)]); + }); + + it('gives a downloaded video its upload year and a listed one none', async () => { + await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + const [a, b] = titleMatcher.matchVideos.mock.calls[0][1]; + expect([a.uploadYear, b.uploadYear]).toEqual([null, 2020]); + }); + + it('gives a downloaded video without an upload date no upload year', async () => { + models.Video.findAll.mockResolvedValue([ + { id: 11, youtubeId: 'b', youTubeVideoName: 'x', originalDate: null, channel_id: CHANNEL_ID, filePath: '/d/b.mp4', audioFilePath: null, removed: false }, + ]); + await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + expect(titleMatcher.matchVideos.mock.calls[0][1][1].uploadYear).toBeNull(); + }); + + it('matches only the videos asked for', async () => { + await planner.planChannel({ channel, drafts: [draftShow('new:0')], onlyIds: new Set(['a']) }); + expect(titleMatcher.matchVideos.mock.calls[0][1].map((entry) => entry.youtubeId)).toEqual(['a']); + }); + + it('returns every known video with the plan', async () => { + const plan = await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + expect(plan.videos.map((entry) => entry.youtubeId)).toEqual(['a', 'b']); + }); + + it('treats a members-only or removed listing as unavailable', async () => { + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'm', title: 'm', publishedAt: null, youtube_removed: false, availability: 'subscriber_only' }, + { youtube_id: 'r', title: 'r', publishedAt: null, youtube_removed: true, availability: null }, + ]); + models.Video.findAll.mockResolvedValue([]); + await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + expect(titleMatcher.matchVideos.mock.calls[0][1].map((entry) => entry.available)).toEqual([false, false]); + }); + + it('leaves out videos classified into another channel\'s show', async () => { + models.VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'a', channel_id: 'UCother', show_id: 5, status: 'assigned' }]); + await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + expect(titleMatcher.matchVideos.mock.calls[0][1].map((entry) => entry.youtubeId)).toEqual(['b']); + }); + + it('marks a download whose file is outside the downloads folder', async () => { + models.Video.findAll.mockResolvedValue([ + { id: 11, youtubeId: 'b', youTubeVideoName: 'x', originalDate: null, channel_id: CHANNEL_ID, filePath: '/elsewhere/b.mp4', audioFilePath: null, removed: false }, + { id: 12, youtubeId: 'c', youTubeVideoName: 'y', originalDate: null, channel_id: CHANNEL_ID, filePath: '/d/__Kids/c.mp4', audioFilePath: null, removed: false }, + ]); + const plan = await planner.planChannel({ channel, drafts: [draftShow('new:0')], downloadsDir: '/d' }); + expect(plan.videos.filter((entry) => entry.outsideDownloads).map((entry) => entry.youtubeId)).toEqual(['b']); + }); + + it('plans a video classified for the channel but downloaded under another channel id', async () => { + const vevo = { + youtube_id: 'v', channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 7, source: 'title', + pattern_id: 31, title_opt_out: false, episode_title: 'z', file_stem: 'S01E07 - z [v]', + }; + models.VideoClassification.findAll.mockImplementation(async ({ where }) => (where.channel_id ? [{ youtube_id: 'v' }] : [vevo])); + models.Video.findAll.mockImplementation(async ({ where }) => (where.youtubeId + ? [{ id: 12, youtubeId: 'v', youTubeVideoName: 'BEYBLADE EN Episode 7: z', originalDate: '20200701', channel_id: 'UCvevo', filePath: '/d/v.mp4', audioFilePath: null, removed: false }] + : [])); + await planner.planChannel({ channel, drafts: [draftShow('new:0')] }); + expect(titleMatcher.matchVideos.mock.calls[0][1]).toContainEqual( + expect.objectContaining({ youtubeId: 'v', title: 'BEYBLADE EN Episode 7: z', downloaded: true, videoId: 12 }) + ); + }); + + it('keeps the row of a classified video Youtarr has no other record of as stored', async () => { + const orphan = { + youtube_id: 'o', channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 9, source: 'title', + pattern_id: 31, title_opt_out: false, episode_title: 'gone', file_stem: 'S01E09 - gone [o]', + }; + models.VideoClassification.findAll.mockImplementation(async ({ where }) => (where.channel_id ? [{ youtube_id: 'o', episode_title: 'gone' }] : [orphan])); + models.Video.findAll.mockResolvedValue([]); + models.TvShow.findAll.mockResolvedValue([{ id: 3, kind: 'title', retired_at: null, name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', channel_id: CHANNEL_ID }]); + titleShowStore.listTitleShows.mockResolvedValue([{ id: 3, key: 'title:3', patterns: [{ id: 31, key: 'title:3#0' }] }]); + const plan = await planner.planChannel({ channel, drafts: [draftShow('title:3')] }); + expect(plan.entries.find((entry) => entry.youtubeId === 'o').after).toMatchObject({ status: 'assigned', season: 1, episode: 9, keep: true }); + }); + + it('reads a stored row with its show and pattern keys', async () => { + models.VideoClassification.findAll.mockResolvedValue([{ + youtube_id: 'a', channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 20, source: 'title', + pattern_id: 31, title_opt_out: false, episode_title: 'x', file_stem: 'S01E20 - x [a]', timestamp_source: 'timestamp', + }]); + models.TvShow.findAll.mockResolvedValue([{ id: 3, kind: 'title', retired_at: null, name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', channel_id: CHANNEL_ID }]); + titleShowStore.listTitleShows.mockResolvedValue([{ id: 3, key: 'title:3', patterns: [{ id: 31, key: 'title:3#0' }] }]); + const plan = await planner.planChannel({ channel, drafts: [draftShow('title:3')] }); + expect(plan.entries.find((entry) => entry.youtubeId === 'a').before).toMatchObject({ + showKey: 'title:3', showKind: 'title', showActive: true, patternKey: 'title:3#0', patternId: 31, fileStem: 'S01E20 - x [a]', + timestampSource: 'timestamp', + }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titlePreview.test.js b/server/modules/tvShows/__tests__/titlePreview.test.js new file mode 100644 index 000000000..e41f2b908 --- /dev/null +++ b/server/modules/tvShows/__tests__/titlePreview.test.js @@ -0,0 +1,125 @@ +const { summarizePlan } = require('../titlePreview'); + +const drafts = [ + { key: 'title:3', id: 3, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV Shows', patterns: [{ key: 'title:3#0' }, { key: 'title:3#1' }] }, +]; +const storedShows = new Map([['title:3', { key: 'title:3', kind: 'title', active: true, name: 'Beyblade (old)' }]]); + +function video(youtubeId, extra = {}) { + return { youtubeId, title: `Title ${youtubeId}`, publishedAtMs: 0, available: true, downloaded: false, filePath: null, ...extra }; +} + +function assigned(episode, extra = {}) { + return { showKey: 'title:3', status: 'assigned', season: 1, episode, source: 'title', patternKey: 'title:3#1', episodeTitle: `E${episode}`, keep: false, ...extra }; +} + +function plan(entries, extra = {}) { + return { + entries, duplicates: [], unsupported: [], retired: [], relocated: [], requiresReorganize: false, knownVideos: 4, + drafts, storedShows, ...extra, + }; +} + +describe('titlePreview.summarizePlan', () => { + const videos = [video('a'), video('b', { downloaded: true }), video('c'), video('loose')]; + + it('lists each show\'s episodes in order with their download state', () => { + const result = summarizePlan(plan([ + { ...videos[1], before: null, after: assigned(2), moves: true }, + { ...videos[0], before: null, after: assigned(1), moves: false }, + ]), { videos, isQueued: () => false }); + expect(result.shows[0].episodes).toEqual([ + expect.objectContaining({ youtubeId: 'a', code: 'S01E01', downloadState: 'not_downloaded', patternIndex: 1 }), + expect.objectContaining({ youtubeId: 'b', code: 'S01E02', downloadState: 'downloaded' }), + ]); + }); + + it('counts the downloads whose files stay outside the downloads folder', () => { + const result = summarizePlan(plan([ + { ...videos[1], before: null, after: assigned(2), moves: false, staysOutside: true }, + { ...videos[0], before: null, after: assigned(1), moves: false }, + ]), { videos, isQueued: () => false }); + expect(result.staysOutside).toBe(1); + }); + + it('marks a queued episode', () => { + const result = summarizePlan(plan([{ ...videos[0], before: null, after: assigned(1), moves: false }]), { videos, isQueued: (id) => id === 'a' }); + expect(result.shows[0].episodes[0].downloadState).toBe('queued'); + }); + + it('counts each show\'s episodes, downloads, pending and duplicates', () => { + const result = summarizePlan(plan([ + { ...videos[0], before: null, after: assigned(1), moves: false }, + { ...videos[1], before: null, after: assigned(2), moves: false }, + { ...videos[2], before: null, after: { ...assigned(null), status: 'duplicate', season: null }, moves: false }, + ], { duplicates: [{ youtubeId: 'c', showKey: 'title:3', season: 1, episode: 1, duplicateOf: 'a' }] }), { videos }); + expect(result.shows[0].counts).toEqual({ episodes: 2, downloaded: 1, pending: 0, duplicates: 1, unsupported: 0 }); + }); + + it('names the winner of each duplicate', () => { + const result = summarizePlan(plan([], { duplicates: [{ youtubeId: 'c', showKey: 'title:3', season: 1, episode: 1, duplicateOf: 'a' }] }), { videos }); + expect(result.duplicates[0]).toMatchObject({ code: 'S01E01', duplicateOfTitle: 'Title a', showName: 'Beyblade' }); + }); + + it('lists the videos no show takes', () => { + const result = summarizePlan(plan([{ ...videos[0], before: null, after: assigned(1), moves: false }]), { videos }); + expect(result.unmatched).toEqual({ count: 3, videos: [ + { youtubeId: 'b', title: 'Title b', downloaded: true }, + { youtubeId: 'c', title: 'Title c', downloaded: false }, + { youtubeId: 'loose', title: 'Title loose', downloaded: false }, + ] }); + }); + + it('leaves duplicates and unsupported matches out of the unmatched list', () => { + const result = summarizePlan(plan([ + { ...videos[0], before: null, after: assigned(1), moves: false }, + { ...videos[1], before: null, after: { ...assigned(null), status: 'duplicate', season: null }, moves: false }, + { ...videos[2], before: null, after: { ...assigned(null), status: 'unsupported', season: null }, moves: false }, + ]), { videos }); + expect(result.unmatched).toEqual({ count: 1, videos: [{ youtubeId: 'loose', title: 'Title loose', downloaded: false }] }); + }); + + it('counts a duplicate whose stored channel-show row is kept as matched, not unmatched', () => { + const channelRow = { showKey: 'channel:UC1', status: 'assigned', season: 2021, episode: 6161600, source: 'date' }; + const result = summarizePlan(plan([ + { ...videos[0], before: assigned(1), after: assigned(1), moves: false }, + { ...videos[1], before: channelRow, after: channelRow, moves: false }, + ], { duplicates: [{ youtubeId: 'b', showKey: 'title:3', season: 1, episode: 1, duplicateOf: 'a' }] }), { videos }); + expect([result.unmatched.videos.map((v) => v.youtubeId), result.shows[0].counts.duplicates]).toEqual([['c', 'loose'], 1]); + }); + + it('caps the unmatched list but not its count', () => { + const result = summarizePlan(plan([]), { videos, listLimit: 2 }); + expect([result.unmatched.count, result.unmatched.videos.length]).toEqual([4, 2]); + }); + + it('reports seasons with missing numbers', () => { + const result = summarizePlan(plan([ + { ...videos[0], before: null, after: assigned(1), moves: false }, + { ...videos[2], before: null, after: assigned(4), moves: false }, + ]), { videos }); + expect(result.gaps).toEqual([expect.objectContaining({ showKey: 'title:3', season: 1, missing: [2, 3] })]); + }); + + it('lists stored episodes whose number would change', () => { + const before = { showKey: 'title:3', status: 'assigned', season: 1, episode: 5, source: 'title' }; + const result = summarizePlan(plan([{ ...videos[0], before, after: assigned(6), moves: false }]), { videos }); + expect(result.changes).toEqual([{ + youtubeId: 'a', title: 'Title a', downloaded: false, + from: { showKey: 'title:3', showName: 'Beyblade (old)', code: 'S01E05', status: 'assigned' }, + to: { showKey: 'title:3', showName: 'Beyblade', code: 'S01E06', status: 'assigned' }, + }]); + }); + + it('counts the downloaded videos that move', () => { + const result = summarizePlan(plan([{ ...videos[1], before: null, after: assigned(2), moves: true }]), { videos }); + expect(result.filesToMove).toBe(1); + }); + + it('describes unsupported matches', () => { + const result = summarizePlan(plan([], { + unsupported: [{ youtubeId: 'c', showKey: 'title:3', reason: 'compilation', season: 1, episode: 19, episodeEnd: 20, part: null }], + }), { videos }); + expect(result.unsupported[0]).toMatchObject({ title: 'Title c', showName: 'Beyblade', reason: 'compilation', episodeEnd: 20 }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleRowWriter.test.js b/server/modules/tvShows/__tests__/titleRowWriter.test.js new file mode 100644 index 000000000..6d9f254ca --- /dev/null +++ b/server/modules/tvShows/__tests__/titleRowWriter.test.js @@ -0,0 +1,240 @@ +jest.mock('../../../models', () => ({ + VideoClassification: { update: jest.fn(), findByPk: jest.fn(), create: jest.fn(), destroy: jest.fn() }, +})); +jest.mock('../titleShowStore', () => ({ saveDefinitions: jest.fn(), raiseHighWater: jest.fn() })); +jest.mock('../episodeConflicts', () => ({ + recordDuplicate: jest.fn(), release: jest.fn(), clearErrorsForChannel: jest.fn(), duplicateIdsForChannel: jest.fn(), +})); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; +const channel = { channel_id: CHANNEL_ID }; + +function entry(youtubeId, before, after, extra = {}) { + return { youtubeId, title: `Video ${youtubeId}`, downloaded: false, before, after, moves: false, ...extra }; +} + +function titleRow(episode, extra = {}) { + return { + showKey: 'new:0', status: 'assigned', season: 1, episode, source: 'title', patternKey: 'new:0#0', + episodeTitle: `Ep ${episode}`, titleOptOut: false, keep: false, ...extra, + }; +} + +function plan(entries, extra = {}) { + return { entries, duplicates: [], highWater: new Map(), ...extra }; +} + +describe('titleRowWriter.applyPlan', () => { + let writer; + let models; + let store; + let conflicts; + let rows; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + store = require('../titleShowStore'); + conflicts = require('../episodeConflicts'); + writer = require('../titleRowWriter'); + rows = new Map(); + store.saveDefinitions.mockResolvedValue({ + showIds: new Map([['new:0', 9], ['title:3', 3]]), + patternIds: new Map([['new:0#0', 90], ['title:3#0', 30]]), + }); + models.VideoClassification.findByPk.mockImplementation(async (id) => rows.get(id) || null); + conflicts.duplicateIdsForChannel.mockResolvedValue(new Set()); + models.VideoClassification.update.mockResolvedValue([1]); + models.VideoClassification.destroy.mockResolvedValue(1); + }); + + // The guarded write of one row (not the pass that frees numbers). + const rowWrite = (youtubeId) => models.VideoClassification.update.mock.calls + .find(([, options]) => options.where.youtube_id === youtubeId && options.where.status !== undefined); + + const apply = (planned, highWaterBefore = new Map()) => writer.applyPlan({ channel, drafts: [], plan: planned, highWaterBefore, transaction: 't' }); + + it('saves the definitions in the transaction', async () => { + await apply(plan([])); + expect(store.saveDefinitions).toHaveBeenCalledWith({ channelId: CHANNEL_ID, drafts: [], transaction: 't' }); + }); + + it('creates a row for a newly classified video with its show, pattern and stem', async () => { + await apply(plan([entry('abcdefghijk', null, titleRow(20))])); + expect(models.VideoClassification.create).toHaveBeenCalledWith({ + youtube_id: 'abcdefghijk', channel_id: CHANNEL_ID, show_id: 9, status: 'assigned', season: 1, episode: 20, + source: 'title', timestamp_source: null, pattern_id: 90, episode_title: 'Ep 20', + file_stem: 'S01E20 - Ep 20 [abcdefghijk]', title_opt_out: false, + }, { transaction: 't' }); + }); + + it('falls back to the video title for the episode title', async () => { + await apply(plan([entry('abcdefghijk', null, titleRow(20, { episodeTitle: null }))])); + expect(models.VideoClassification.create.mock.calls[0][0]).toMatchObject({ episode_title: 'Video abcdefghijk', file_stem: 'S01E20 - Video abcdefghijk [abcdefghijk]' }); + }); + + it('frees the numbers of changing rows before writing any', async () => { + const before = { ...titleRow(2, { showKey: 'title:3' }), showId: 3, fileStem: 'S01E02 - x [aaaaaaaaaaa]' }; + await apply(plan([entry('aaaaaaaaaaa', before, titleRow(3, { showKey: 'title:3', patternKey: 'title:3#0' }))])); + expect(models.VideoClassification.update.mock.calls[0]).toEqual([ + { season: null, episode: null }, + { where: { youtube_id: ['aaaaaaaaaaa'] }, transaction: 't' }, + ]); + expect(rowWrite('aaaaaaaaaaa')[0]).toMatchObject({ season: 1, episode: 3 }); + }); + + it('writes a changed row only while it is still as planned', async () => { + const before = { ...titleRow(null, { status: 'pending_number', season: null, showKey: 'title:3' }), showId: 3 }; + await apply(plan([entry('aaaaaaaaaaa', before, titleRow(4, { showKey: 'title:3', patternKey: 'title:3#0' }))])); + expect(rowWrite('aaaaaaaaaaa')[1]).toEqual({ + where: { youtube_id: 'aaaaaaaaaaa', status: 'pending_number', show_id: 3, season: null, episode: null }, transaction: 't', + }); + }); + + it('refuses to overwrite a row another writer changed since the plan', async () => { + const before = { ...titleRow(null, { status: 'pending_number', season: null, showKey: 'title:3' }), showId: 3 }; + models.VideoClassification.update.mockResolvedValue([0]); + await expect(apply(plan([entry('aaaaaaaaaaa', before, titleRow(4, { showKey: 'title:3', patternKey: 'title:3#0' }))]))) + .rejects.toMatchObject({ code: 'ROW_CHANGED' }); + }); + + it('refuses to delete a row another writer changed since the plan', async () => { + const before = { ...titleRow(null, { status: 'pending_number', season: null, showKey: 'title:3' }), showId: 3 }; + models.VideoClassification.destroy.mockResolvedValue(0); + await expect(apply(plan([entry('aaaaaaaaaaa', before, null)]))).rejects.toMatchObject({ code: 'ROW_CHANGED' }); + }); + + it('keeps the stem of a row whose show and number stay', async () => { + const before = { ...titleRow(2, { showKey: 'title:3' }), showId: 3, fileStem: 'S01E02 - Old title [aaaaaaaaaaa]' }; + await apply(plan([entry('aaaaaaaaaaa', before, titleRow(2, { showKey: 'title:3', patternKey: 'title:3#0', episodeTitle: 'New title' }))])); + expect(rowWrite('aaaaaaaaaaa')[0]).toMatchObject({ file_stem: 'S01E02 - Old title [aaaaaaaaaaa]', pattern_id: 30 }); + }); + + it('points a kept row at its pattern\'s new id', async () => { + const before = { ...titleRow(2, { showKey: 'title:3', patternKey: 'title:3#0' }), showId: 3, fileStem: 's' }; + await apply(plan([entry('aaaaaaaaaaa', before, { ...titleRow(2, { showKey: 'title:3', patternKey: 'title:3#0' }), keep: true })])); + expect(models.VideoClassification.update).toHaveBeenCalledWith({ pattern_id: 30 }, { where: { youtube_id: ['aaaaaaaaaaa'] }, transaction: 't' }); + }); + + // A pattern without {title}: the stored episode title is the video title. + const untitled = (youtubeId, patternId) => ({ + ...titleRow(20, { showKey: 'title:3', patternKey: 'title:3#0', episodeTitle: 'Video ' + youtubeId }), + showId: 3, patternId, timestampSource: null, fileStem: `S01E20 - Video ${youtubeId} [${youtubeId}]`, + }); + const untitledAfter = () => titleRow(20, { showKey: 'title:3', patternKey: 'title:3#0', episodeTitle: null }); + + it('skips a row whose stored values already are the planned ones', async () => { + await apply(plan([entry('aaaaaaaaaaa', untitled('aaaaaaaaaaa', 30), untitledAfter())])); + expect(models.VideoClassification.update).not.toHaveBeenCalled(); + }); + + it('moves rows whose only change is their pattern in one statement per pattern', async () => { + await apply(plan([ + entry('aaaaaaaaaaa', untitled('aaaaaaaaaaa', 99), untitledAfter()), + entry('bbbbbbbbbbb', untitled('bbbbbbbbbbb', 99), untitledAfter()), + ])); + expect(models.VideoClassification.update.mock.calls).toEqual([ + [{ pattern_id: 30 }, { where: { youtube_id: ['aaaaaaaaaaa', 'bbbbbbbbbbb'] }, transaction: 't' }], + ]); + }); + + it('keeps the time source of a row whose number stays', async () => { + const channelRow = { + showKey: 'channel:UC1', showKind: 'channel', showId: 5, status: 'assigned', season: 2024, episode: 3151200, source: 'date', + titleOptOut: true, patternKey: null, patternId: null, episodeTitle: 'x', fileStem: 'S2024E03151200 - x [aaaaaaaaaaa]', timestampSource: 'timestamp', + }; + await apply(plan([entry('aaaaaaaaaaa', channelRow, { ...channelRow, titleOptOut: false, keep: true })])); + expect(rowWrite('aaaaaaaaaaa')[0]).toMatchObject({ timestamp_source: 'timestamp', title_opt_out: false }); + }); + + it('leaves a kept row whose pattern kept its id alone', async () => { + const before = { ...titleRow(2, { showKey: 'title:3', patternKey: 'title:3#0' }), showId: 3, patternId: 30, fileStem: 's' }; + await apply(plan([entry('aaaaaaaaaaa', before, { ...titleRow(2, { showKey: 'title:3', patternKey: 'title:3#0' }), keep: true })])); + expect(models.VideoClassification.findByPk).not.toHaveBeenCalled(); + }); + + it('uses the stored definitions without saving them again', async () => { + const definitions = { showIds: new Map([['title:3', 3]]), patternIds: new Map([['title:3#0', 30]]) }; + await writer.applyPlan({ channel, drafts: [], plan: plan([]), highWaterBefore: new Map(), transaction: 't', definitions }); + expect(store.saveDefinitions).not.toHaveBeenCalled(); + }); + + it('leaves a kept channel-show row alone', async () => { + const before = { showKey: 'channel:UC1', showKind: 'channel', showId: 5, status: 'assigned', season: 2024, episode: 1, source: 'date', fileStem: 's' }; + await apply(plan([entry('aaaaaaaaaaa', before, { ...before, keep: true })])); + expect(models.VideoClassification.findByPk).not.toHaveBeenCalled(); + }); + + it('deletes a released row', async () => { + const before = { ...titleRow(2, { showKey: 'title:3' }), showId: 3 }; + await apply(plan([entry('aaaaaaaaaaa', before, null)])); + expect(models.VideoClassification.destroy).toHaveBeenCalledWith({ + where: { youtube_id: 'aaaaaaaaaaa', status: 'assigned', show_id: 3 }, transaction: 't', + }); + }); + + it('writes an opted-out row without a number', async () => { + await apply(plan([entry('aaaaaaaaaaa', null, titleRow(null, { status: 'opted_out', season: null, source: null, titleOptOut: true }))])); + expect(models.VideoClassification.create.mock.calls[0][0]).toMatchObject({ status: 'opted_out', season: null, episode: null, file_stem: null, title_opt_out: true }); + }); + + it('records duplicates against the saved show', async () => { + await apply(plan( + [entry('dup', null, titleRow(null, { status: 'duplicate', season: null }), { downloaded: true })], + { duplicates: [{ youtubeId: 'dup', showKey: 'new:0', season: 1, episode: 20, duplicateOf: 'win' }] } + )); + expect(conflicts.recordDuplicate).toHaveBeenCalledWith({ + youtubeId: 'dup', channelId: CHANNEL_ID, showId: 9, season: 1, episode: 20, duplicateOf: 'win', downloaded: true, transaction: 't', + }); + }); + + it('releases the conflict of a video that is no longer a duplicate', async () => { + const before = { ...titleRow(null, { status: 'duplicate', season: null, showKey: 'title:3' }), showId: 3 }; + rows.set('dupdupdup01', { update: jest.fn() }); + conflicts.duplicateIdsForChannel.mockResolvedValue(new Set(['dupdupdup01'])); + await apply(plan([entry('dupdupdup01', before, titleRow(20, { showKey: 'title:3', patternKey: 'title:3#0' }))])); + expect(conflicts.release).toHaveBeenCalledWith('dupdupdup01', { transaction: 't' }); + }); + + it('releases the conflict of a channel-show episode that no longer loses a title claim', async () => { + const channelRow = { showKey: 'channel:UC1', showKind: 'channel', status: 'assigned', season: 2024, episode: 3151200, source: 'date', titleOptOut: false, keep: true }; + conflicts.duplicateIdsForChannel.mockResolvedValue(new Set(['episode0001'])); + await apply(plan([entry('episode0001', channelRow, channelRow)])); + expect(conflicts.release).toHaveBeenCalledWith('episode0001', { transaction: 't' }); + }); + + it('keeps the conflict of a channel-show episode that still loses a title claim', async () => { + const channelRow = { showKey: 'channel:UC1', showKind: 'channel', status: 'assigned', season: 2024, episode: 3151200, source: 'date', titleOptOut: false, keep: true }; + conflicts.duplicateIdsForChannel.mockResolvedValue(new Set(['episode0001'])); + await apply(plan( + [entry('episode0001', channelRow, channelRow)], + { duplicates: [{ youtubeId: 'episode0001', showKey: 'new:0', season: 1, episode: 20, duplicateOf: 'win' }] } + )); + expect(conflicts.release).not.toHaveBeenCalled(); + }); + + it('keeps the conflict of a downloaded duplicate whose verdict the plan kept', async () => { + const before = { ...titleRow(null, { status: 'duplicate', season: null, showKey: 'title:3' }), showId: 3 }; + conflicts.duplicateIdsForChannel.mockResolvedValue(new Set(['keptdup0001'])); + await apply(plan([entry('keptdup0001', before, { ...before, keep: true }, { downloaded: true })])); + expect(conflicts.release).not.toHaveBeenCalled(); + }); + + it('leaves the conflicts of videos a listing refresh did not classify', async () => { + const before = { ...titleRow(null, { status: 'duplicate', season: null, showKey: 'title:3' }), showId: 3 }; + conflicts.duplicateIdsForChannel.mockResolvedValue(new Set(['frozen00001'])); + await apply(plan([entry('frozen00001', before, { ...before, keep: true }, { classified: false })])); + expect(conflicts.release).not.toHaveBeenCalled(); + }); + + it('clears the channel\'s classification errors once its titles classified', async () => { + await apply(plan([])); + expect(conflicts.clearErrorsForChannel).toHaveBeenCalledWith(CHANNEL_ID, { transaction: 't' }); + }); + + it('raises the high-water marks of seasons that allocated order numbers', async () => { + await apply(plan([], { highWater: new Map([['new:0|0', 4], ['title:3|1', 2]]) }), new Map([['title:3|1', 2]])); + expect(store.raiseHighWater.mock.calls).toEqual([[9, 0, 4, { transaction: 't' }]]); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleShowDrafts.test.js b/server/modules/tvShows/__tests__/titleShowDrafts.test.js new file mode 100644 index 000000000..e437b7e38 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleShowDrafts.test.js @@ -0,0 +1,153 @@ +jest.mock('../../titleFilterRegex', () => ({ checkPatterns: jest.fn() })); + +const titleFilterRegex = require('../../titleFilterRegex'); +const { normalizeDrafts, defaultLibraryFolder, assertDraftsCompile, MAX_PATTERNS_PER_SHOW } = require('../titleShowDrafts'); + +const layoutOf = (folder) => (['TV Shows', 'Anime'].includes(folder) ? 'tv' : 'videos'); +const context = { layoutOf, defaultLibraryFolder: 'TV Shows' }; + +function draft(overrides = {}) { + return { + name: 'Beyblade', + patterns: [{ text: 'BEYBLADE EN Episode {episode}: {title}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }], + ...overrides, + }; +} + +describe('titleShowDrafts', () => { + describe('normalizeDrafts', () => { + it('keys a new show by its position and an existing one by its id', () => { + const [created, existing] = normalizeDrafts([draft(), draft({ id: 4, name: 'V-Force' })], context); + expect([created.key, existing.key]).toEqual(['new:0', 'title:4']); + }); + + it('defaults the folder name to the sanitized show name', () => { + const [show] = normalizeDrafts([draft({ name: 'Beyblade: V-Force?' })], context); + expect(show.folderName).toBe('Beyblade: V-Force?'); + }); + + it('defaults the library folder', () => { + expect(normalizeDrafts([draft()], context)[0].libraryFolder).toBe('TV Shows'); + }); + + it('stores a TV folder under its registered name, whatever the request\'s case', () => { + const tvLayoutOf = (folder) => (folder.toLowerCase() === 'tv shows' ? 'tv' : 'videos'); + const drafts = normalizeDrafts([draft({ libraryFolder: 'tv shows' })], { layoutOf: tvLayoutOf, defaultLibraryFolder: 'TV Shows', tvFolders: ['TV Shows'] }); + expect(drafts[0].libraryFolder).toBe('TV Shows'); + }); + + it('refuses a library folder that is not a TV folder', () => { + expect(() => normalizeDrafts([draft({ libraryFolder: 'Kids' })], context)).toThrow('__Kids is not a TV folder'); + }); + + it('asks for a TV folder when there is no default one', () => { + expect(() => normalizeDrafts([draft()], { layoutOf, defaultLibraryFolder: null })).toThrow('Choose a TV folder'); + }); + + it('refuses a folder name media servers treat as extras', () => { + expect(() => normalizeDrafts([draft({ folderName: 'Extras' })], context)).toThrow('extras'); + }); + + it('refuses two shows with the same folder in one library folder', () => { + expect(() => normalizeDrafts([draft(), draft({ name: 'beyblade' })], context)).toThrow('same folder'); + }); + + it('compiles each pattern', () => { + const [show] = normalizeDrafts([draft()], context); + expect(show.patterns[0]).toMatchObject({ + key: 'new:0#0', + compiledRegex: '(?i)BEYBLADE\\s+EN\\s+Episode\\s+(?P<episode>[0-9]+):\\s+(?P<title>.+)$', + seasonFixed: 1, + }); + }); + + it('names the show and pattern a pattern error belongs to', () => { + const bad = draft({ patterns: [draft().patterns[0], { text: 'Ep {ep}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'order' }] }); + expect(() => normalizeDrafts([bad], context)).toThrow('Beyblade, pattern 2: Unknown placeholder {ep}'); + }); + + it('refuses sources that do not fit the pattern', () => { + const bad = draft({ patterns: [{ text: 'Episode {episode}', kind: 'simple', seasonSource: 'title', episodeSource: 'title' }] }); + expect(() => normalizeDrafts([bad], context)).toThrow('{season}'); + }); + + it('needs at least one pattern', () => { + expect(() => normalizeDrafts([draft({ patterns: [] })], context)).toThrow('at least one pattern'); + }); + + it('limits the number of patterns', () => { + const patterns = Array.from({ length: MAX_PATTERNS_PER_SHOW + 1 }, () => draft().patterns[0]); + expect(() => normalizeDrafts([draft({ patterns })], context)).toThrow(`${MAX_PATTERNS_PER_SHOW}`); + }); + + it('trims exclude terms and drops empty and repeated ones', () => { + const [show] = normalizeDrafts([draft({ excludeTerms: [' Official Clip ', '', 'official clip', 'Dub'] })], context); + expect(show.excludeTerms).toEqual(['Official Clip', 'Dub']); + }); + + it('keeps named seasons and drops empty names', () => { + const [show] = normalizeDrafts([draft({ seasonNames: { 1: ' Beyblade ', 2: '', 3: 'G-Revolution' } })], context); + expect(show.seasonNames).toEqual({ 1: 'Beyblade', 3: 'G-Revolution' }); + }); + + it('refuses a season name for a season out of range', () => { + expect(() => normalizeDrafts([draft({ seasonNames: { 200: 'Too far' } })], context)).toThrow('0 to 199'); + }); + + it('names an upload-year season', () => { + const [show] = normalizeDrafts([draft({ seasonNames: { 2024: 'The first year' } })], context); + expect(show.seasonNames).toEqual({ 2024: 'The first year' }); + }); + + it('refuses a season name beyond the years media servers read as seasons', () => { + expect(() => normalizeDrafts([draft({ seasonNames: { 2501: 'Far future' } })], context)).toThrow('1928 to 2500'); + }); + + it('refuses a show without a name', () => { + expect(() => normalizeDrafts([draft({ name: ' ' })], context)).toThrow('needs a name'); + }); + + it('refuses an input that is not a list', () => { + expect(() => normalizeDrafts({ name: 'x' }, context)).toThrow('list'); + }); + }); + + describe('defaultLibraryFolder', () => { + it('uses the channel\'s folder when it is a TV folder', () => { + expect(defaultLibraryFolder({ channelFolder: 'Anime', defaultFolder: 'TV Shows', tvFolders: ['Anime', 'TV Shows'], layoutOf })).toBe('Anime'); + }); + + it('uses the default subfolder when it is a TV folder', () => { + expect(defaultLibraryFolder({ channelFolder: 'Kids', defaultFolder: 'TV Shows', tvFolders: ['Anime', 'TV Shows'], layoutOf })).toBe('TV Shows'); + }); + + it('uses the only TV folder', () => { + expect(defaultLibraryFolder({ channelFolder: 'Kids', defaultFolder: '', tvFolders: ['Anime'], layoutOf })).toBe('Anime'); + }); + + it('has none when there are several TV folders to choose from', () => { + expect(defaultLibraryFolder({ channelFolder: 'Kids', defaultFolder: '', tvFolders: ['Anime', 'TV Shows'], layoutOf })).toBeNull(); + }); + }); + + describe('assertDraftsCompile', () => { + beforeEach(() => jest.clearAllMocks()); + + it('checks every pattern, filter and exclude term in Python', async () => { + titleFilterRegex.checkPatterns.mockImplementation(async (list) => list.map(() => null)); + const shows = normalizeDrafts([draft({ excludeTerms: ['Official Clip'] })], context); + await assertDraftsCompile(shows); + expect(titleFilterRegex.checkPatterns.mock.calls[0][0]).toEqual([ + shows[0].patterns[0].compiledRegex, + shows[0].patterns[0].filterRegex, + '(?i:Official\\s+Clip)', + ]); + }); + + it('refuses a pattern Python can\'t compile, naming it', async () => { + titleFilterRegex.checkPatterns.mockImplementation(async (list) => list.map((_, index) => (index === 0 ? 'bad escape' : null))); + const shows = normalizeDrafts([draft()], context); + await expect(assertDraftsCompile(shows)).rejects.toThrow('Beyblade, pattern 1: bad escape'); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleShowQueries.test.js b/server/modules/tvShows/__tests__/titleShowQueries.test.js new file mode 100644 index 000000000..7ec47e896 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleShowQueries.test.js @@ -0,0 +1,147 @@ +jest.mock('../../../models', () => ({ + TvShow: { findAll: jest.fn(), findOne: jest.fn() }, + VideoClassification: { findAll: jest.fn() }, + Video: { findAll: jest.fn() }, + TvShowSeason: { findAll: jest.fn() }, +})); +jest.mock('../../../models/channelvideo', () => ({ findAll: jest.fn() })); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; + +function classification(youtubeId, values) { + return { youtube_id: youtubeId, show_id: 3, status: 'assigned', season: 1, episode: 1, source: 'title', ...values }; +} + +describe('titleShowQueries', () => { + let queries; + let models; + let ChannelVideo; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + ChannelVideo = require('../../../models/channelvideo'); + queries = require('../titleShowQueries'); + models.Video.findAll.mockResolvedValue([]); + ChannelVideo.findAll.mockResolvedValue([]); + models.TvShowSeason.findAll.mockResolvedValue([]); + }); + + describe('countsByShow', () => { + it('counts episodes, downloads, duplicates and unsupported videos per show', async () => { + models.VideoClassification.findAll.mockResolvedValue([ + classification('a'), classification('b', { status: 'pending_number', season: null, episode: null }), + classification('c', { status: 'duplicate' }), classification('d', { status: 'unsupported', show_id: 4 }), + ]); + models.Video.findAll.mockResolvedValue([{ youtubeId: 'a' }]); + const counts = await queries.countsByShow([3, 4]); + expect(counts.get(3)).toEqual({ episodes: 2, downloaded: 1, duplicates: 1, unsupported: 0 }); + expect(counts.get(4)).toEqual({ episodes: 0, downloaded: 0, duplicates: 0, unsupported: 1 }); + }); + + it('asks only for downloaded files that are present', async () => { + models.VideoClassification.findAll.mockResolvedValue([classification('a')]); + await queries.countsByShow([3]); + expect(models.Video.findAll.mock.calls[0][0].where).toMatchObject({ youtubeId: ['a'], removed: false }); + }); + }); + + describe('countActiveByChannel', () => { + it('counts the active title shows of each channel', async () => { + models.TvShow.findAll.mockResolvedValue([{ channel_id: CHANNEL_ID }, { channel_id: CHANNEL_ID }, { channel_id: 'UCx' }]); + expect(await queries.countActiveByChannel([CHANNEL_ID, 'UCx'])).toEqual(new Map([[CHANNEL_ID, 2], ['UCx', 1]])); + }); + }); + + describe('missingEpisodes', () => { + beforeEach(() => { + models.TvShow.findOne.mockResolvedValue({ id: 3, name: 'Beyblade', channel_id: CHANNEL_ID }); + models.VideoClassification.findAll.mockResolvedValue([ + classification('a', { episode: 1 }), classification('b', { episode: 2 }), classification('c', { episode: 5 }), + ]); + models.Video.findAll.mockResolvedValue([{ youtubeId: 'a' }]); + ChannelVideo.findAll.mockResolvedValue([{ youtube_id: 'b', title: 'Day of the Dragoon' }]); + models.TvShowSeason.findAll.mockResolvedValue([{ season: 1, name: 'Beyblade' }]); + }); + + it('lists the not-downloaded episodes and the gaps of each season', async () => { + const result = await queries.missingEpisodes(CHANNEL_ID, 3); + expect(result).toEqual({ + showId: 3, + name: 'Beyblade', + seasons: [{ + season: 1, + name: 'Beyblade', + episodes: 3, + downloaded: 1, + notDownloaded: [ + { youtubeId: 'b', title: 'Day of the Dragoon', episode: 2, code: 'S01E02' }, + { youtubeId: 'c', title: null, episode: 5, code: 'S01E05' }, + ], + gaps: [3, 4], + gapsTruncated: false, + }], + }); + }); + + it('returns null for a show of another channel', async () => { + models.TvShow.findOne.mockResolvedValue(null); + expect(await queries.missingEpisodes(CHANNEL_ID, 99)).toBeNull(); + }); + }); + + describe('showEpisodeIds', () => { + it('returns the videos classified into one of the channel\'s active title shows', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 3 }, { id: 4 }]); + models.VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'a' }]); + expect(await queries.showEpisodeIds(CHANNEL_ID, ['a', 'b'])).toEqual(new Set(['a'])); + expect(models.VideoClassification.findAll.mock.calls[0][0].where).toEqual({ + youtube_id: ['a', 'b'], show_id: [3, 4], status: ['assigned', 'pending_number'], + }); + }); + + it('returns null for a channel without active title shows', async () => { + models.TvShow.findAll.mockResolvedValue([]); + expect(await queries.showEpisodeIds(CHANNEL_ID, ['a'])).toBeNull(); + }); + }); + + describe('youtubeIdsForShow', () => { + it('lists the videos classified into a show as episodes', async () => { + models.VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'a' }, { youtube_id: 'b' }]); + expect(await queries.youtubeIdsForShow(3)).toEqual(new Set(['a', 'b'])); + expect(models.VideoClassification.findAll.mock.calls[0][0].where).toEqual({ show_id: 3, status: ['assigned', 'pending_number'] }); + }); + }); + + describe('describeVideos', () => { + it('gives each video its title and whether its file is present', async () => { + ChannelVideo.findAll.mockResolvedValue([{ youtube_id: 'a', title: 'Listed A' }]); + models.Video.findAll.mockResolvedValue([ + { id: 5, youtubeId: 'a', youTubeVideoName: 'Downloaded A', removed: false }, + { id: 6, youtubeId: 'b', youTubeVideoName: 'Downloaded B', removed: true }, + ]); + expect(await queries.describeVideos(CHANNEL_ID, ['a', 'b', 'c'])).toEqual(new Map([ + ['a', { title: 'Listed A', downloaded: true, videoId: 5 }], + ['b', { title: 'Downloaded B', downloaded: false, videoId: 6 }], + ['c', { title: null, downloaded: false, videoId: null }], + ])); + }); + }); + + describe('plannedEpisodes', () => { + it('gives numbered title-show episodes their code and show name', async () => { + models.VideoClassification.findAll.mockResolvedValue([classification('a', { episode: 20 })]); + models.TvShow.findAll.mockResolvedValue([{ id: 3, name: 'Beyblade' }]); + expect(await queries.plannedEpisodes(['a'])).toEqual(new Map([['a', { showName: 'Beyblade', season: 1, episode: 20, code: 'S01E20' }]])); + }); + + it('asks only for active title shows', async () => { + models.VideoClassification.findAll.mockResolvedValue([classification('a')]); + models.TvShow.findAll.mockResolvedValue([]); + expect(await queries.plannedEpisodes(['a'])).toEqual(new Map()); + expect(models.TvShow.findAll.mock.calls[0][0].where).toEqual({ id: [3], kind: 'title', retired_at: null }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleShowSaver.test.js b/server/modules/tvShows/__tests__/titleShowSaver.test.js new file mode 100644 index 000000000..06b9bc014 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleShowSaver.test.js @@ -0,0 +1,212 @@ +jest.mock('../../../db', () => ({ sequelize: { transaction: jest.fn(async (fn) => fn('t')) } })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(() => 'TV Shows'), directoryPath: '/d' })); +jest.mock('../libraryLayouts', () => ({ + getLayoutResolver: jest.fn(async () => (folder) => (folder === 'TV Shows' ? 'tv' : 'videos')), + listTvFolders: jest.fn(async () => ['TV Shows']), +})); +jest.mock('../channelFolders', () => ({ effectiveLibraryFolder: jest.fn(() => 'Kids') })); +jest.mock('../titleShowDrafts', () => ({ + ...jest.requireActual('../titleShowDrafts'), + assertDraftsCompile: jest.fn(), +})); +jest.mock('../titleShowStore', () => ({ + assertFolderNamesFree: jest.fn(), highWaterMarks: jest.fn(async () => new Map()), listTitleShows: jest.fn(async () => []), +})); +jest.mock('../titlePlanner', () => ({ planChannel: jest.fn() })); +jest.mock('../titleRowWriter', () => ({ + applyPlan: jest.fn(), + isRowChangedError: jest.fn((err) => Boolean(err) && err.code === 'ROW_CHANGED'), +})); +jest.mock('../archiveSuppressor', () => ({ flush: jest.fn() })); +jest.mock('../layoutGuards', () => ({ + reorganizeRequiredError: jest.fn((message, change) => Object.assign(new Error(message), { status: 409, reorganizeRequired: true, change })), +})); +jest.mock('../../reorganize/reorganizeLock', () => ({ assertChannelFree: jest.fn(), coversChannel: jest.fn(() => false) })); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; +const channel = { channel_id: CHANNEL_ID, title: 'BEYBLADE Official', sub_folder: 'Kids' }; +const rawShows = [{ + name: 'Beyblade', + patterns: [{ text: 'BEYBLADE EN Episode {episode}: {title}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }], +}]; + +function plan(requiresReorganize) { + return { requiresReorganize, entries: [], duplicates: [], highWater: new Map(), storedShows: new Map() }; +} + +describe('titleShowSaver', () => { + let saver; + let planner; + let writer; + let suppressor; + let lock; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + planner = require('../titlePlanner'); + writer = require('../titleRowWriter'); + suppressor = require('../archiveSuppressor'); + lock = require('../../reorganize/reorganizeLock'); + saver = require('../titleShowSaver'); + }); + + describe('prepare', () => { + it('defaults new shows to the default TV folder', async () => { + planner.planChannel.mockResolvedValue(plan(false)); + const { drafts } = await saver.prepare({ channel, rawShows }); + expect(drafts[0].libraryFolder).toBe('TV Shows'); + }); + + it('plans the channel with the normalized drafts and overrides', async () => { + planner.planChannel.mockResolvedValue(plan(false)); + await saver.prepare({ channel, rawShows, rawOverrides: [{ youtubeId: 'abcdefghijk', notAnEpisode: true }] }); + expect(planner.planChannel).toHaveBeenCalledWith({ + channel, + drafts: [expect.objectContaining({ key: 'new:0' })], + overrides: new Map([['abcdefghijk', { optOut: true }]]), + downloadsDir: '/d', + }); + }); + + it('maps a return to automatic classification', async () => { + planner.planChannel.mockResolvedValue(plan(false)); + await saver.prepare({ channel, rawShows, rawOverrides: [{ youtubeId: 'abcdefghijk', automatic: true }] }); + expect(planner.planChannel.mock.calls[0][0].overrides.get('abcdefghijk')).toEqual({ reset: true }); + }); + + it('maps an assignment to a show of the drafts', async () => { + planner.planChannel.mockResolvedValue(plan(false)); + await saver.prepare({ + channel, + rawShows: [{ ...rawShows[0], id: 3 }], + rawOverrides: [{ youtubeId: 'abcdefghijk', showId: 3, season: 1, episode: 49 }], + }); + expect(planner.planChannel.mock.calls[0][0].overrides.get('abcdefghijk')).toEqual({ showKey: 'title:3', season: 1, episode: 49 }); + }); + + it('refuses an assignment to a show the channel doesn\'t have', async () => { + await expect(saver.prepare({ channel, rawShows, rawOverrides: [{ youtubeId: 'abcdefghijk', showId: 77, season: 1, episode: 2 }] })) + .rejects.toMatchObject({ status: 400 }); + }); + + it('accepts an assignment to an upload-year season', async () => { + planner.planChannel.mockResolvedValue(plan(false)); + await saver.prepare({ + channel, + rawShows: [{ ...rawShows[0], id: 3 }], + rawOverrides: [{ youtubeId: 'abcdefghijk', showId: 3, season: 2024, episode: 5 }], + }); + expect(planner.planChannel.mock.calls[0][0].overrides.get('abcdefghijk')).toEqual({ showKey: 'title:3', season: 2024, episode: 5 }); + }); + + it('refuses a season between the title and year ranges', async () => { + await expect(saver.prepare({ + channel, rawShows: [{ ...rawShows[0], id: 3 }], rawOverrides: [{ youtubeId: 'abcdefghijk', showId: 3, season: 200, episode: 5 }], + })).rejects.toMatchObject({ status: 400 }); + }); + + it('refuses an episode number out of range', async () => { + await expect(saver.prepare({ + channel, rawShows: [{ ...rawShows[0], id: 3 }], rawOverrides: [{ youtubeId: 'abcdefghijk', showId: 3, season: 1, episode: 0 }], + })).rejects.toMatchObject({ status: 400 }); + }); + + it('refuses an override for an invalid video id', async () => { + await expect(saver.prepare({ channel, rawShows, rawOverrides: [{ youtubeId: '../x', notAnEpisode: true }] })) + .rejects.toMatchObject({ status: 400 }); + }); + }); + + describe('save', () => { + it('asks for the reorganize when downloaded videos would move', async () => { + planner.planChannel.mockResolvedValue(plan(true)); + await expect(saver.save({ channel, rawShows })).rejects.toMatchObject({ + status: 409, + reorganizeRequired: true, + change: { type: 'titleShows', channelId: CHANNEL_ID, shows: rawShows, overrides: [] }, + }); + expect(writer.applyPlan).not.toHaveBeenCalled(); + }); + + it('writes the plan and then the archive changes when nothing moves', async () => { + planner.planChannel.mockResolvedValue(plan(false)); + await saver.save({ channel, rawShows }); + expect(writer.applyPlan).toHaveBeenCalledWith(expect.objectContaining({ channel, transaction: 't' })); + expect(suppressor.flush.mock.invocationCallOrder[0]).toBeGreaterThan(writer.applyPlan.mock.invocationCallOrder[0]); + }); + + it('plans again once when a download took a number meanwhile', async () => { + const unique = Object.assign(new Error('dup'), { name: 'SequelizeUniqueConstraintError' }); + planner.planChannel.mockResolvedValue(plan(false)); + writer.applyPlan.mockRejectedValueOnce(unique); + await saver.save({ channel, rawShows }); + expect(planner.planChannel).toHaveBeenCalledTimes(2); + }); + + it('plans again once when a download changed an episode row meanwhile', async () => { + planner.planChannel.mockResolvedValue(plan(false)); + writer.applyPlan.mockRejectedValueOnce(Object.assign(new Error('changed'), { code: 'ROW_CHANGED' })); + await saver.save({ channel, rawShows }); + expect(planner.planChannel).toHaveBeenCalledTimes(2); + }); + + it('refuses while a reorganize moves the channel\'s files', async () => { + lock.assertChannelFree.mockImplementation(() => { throw Object.assign(new Error('busy'), { status: 409 }); }); + await expect(saver.save({ channel, rawShows })).rejects.toMatchObject({ status: 409 }); + }); + }); + + describe('applyPrepared', () => { + it('writes a prepared plan in a transaction and returns the saved show ids', async () => { + writer.applyPlan.mockResolvedValue({ showIds: new Map([['new:0', 9]]), patternIds: new Map() }); + const result = await saver.applyPrepared({ channel, drafts: [], plan: plan(true) }); + expect([result.showIds.get('new:0'), writer.applyPlan.mock.calls[0][0].transaction]).toEqual([9, 't']); + expect(suppressor.flush).toHaveBeenCalled(); + }); + }); + + describe('classifyNew', () => { + const storedShow = { + id: 3, key: 'title:3', name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV Shows', position: 0, + excludeTerms: [], seasonNames: {}, patterns: [{ id: 30, key: 'title:3#0', compiledRegex: 'x', filterRegex: 'f' }], + }; + + it('does nothing for a channel without title shows', async () => { + await saver.classifyNew({ channel, youtubeIds: ['abcdefghijk'] }); + expect(planner.planChannel).not.toHaveBeenCalled(); + }); + + it('classifies only the new videos with the stored shows, without saving them again', async () => { + require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); + planner.planChannel.mockResolvedValue(plan(false)); + await saver.classifyNew({ channel, youtubeIds: ['abcdefghijk'] }); + expect(planner.planChannel).toHaveBeenCalledWith({ channel, drafts: [storedShow], onlyIds: new Set(['abcdefghijk']), downloadsDir: '/d' }); + expect(writer.applyPlan.mock.calls[0][0].definitions).toEqual({ + showIds: new Map([['title:3', 3]]), patternIds: new Map([['title:3#0', 30]]), + }); + }); + + it('leaves out downloaded videos whose files would have to move', async () => { + require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); + planner.planChannel.mockResolvedValue({ ...plan(true), entries: [{ youtubeId: 'a', moves: true }, { youtubeId: 'b', moves: false }] }); + await saver.classifyNew({ channel, youtubeIds: ['a', 'b'] }); + expect(writer.applyPlan.mock.calls[0][0].plan.entries).toEqual([{ youtubeId: 'b', moves: false }]); + }); + + it('classifies again once when a download changed an episode row meanwhile', async () => { + require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); + planner.planChannel.mockResolvedValue(plan(false)); + writer.applyPlan.mockRejectedValueOnce(Object.assign(new Error('changed'), { code: 'ROW_CHANGED' })); + await saver.classifyNew({ channel, youtubeIds: ['abcdefghijk'] }); + expect(planner.planChannel).toHaveBeenCalledTimes(2); + }); + + it('waits for a running reorganize of the channel', async () => { + require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); + lock.coversChannel.mockReturnValue(true); + await saver.classifyNew({ channel, youtubeIds: ['a'] }); + expect(planner.planChannel).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleShowService.test.js b/server/modules/tvShows/__tests__/titleShowService.test.js new file mode 100644 index 000000000..29380cdd5 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleShowService.test.js @@ -0,0 +1,265 @@ +jest.mock('../../../models', () => ({ + Channel: { findOne: jest.fn() }, + Video: { findOne: jest.fn() }, + VideoClassification: { findByPk: jest.fn() }, + EpisodeConflict: { findByPk: jest.fn() }, + TvShow: { findByPk: jest.fn(), findAll: jest.fn(async () => []) }, +})); +jest.mock('../../../models/channelvideo', () => ({ findOne: jest.fn() })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(() => '') })); +jest.mock('../libraryLayouts', () => ({ + getLayoutResolver: jest.fn(async () => (folder) => (folder === 'TV Shows' ? 'tv' : 'videos')), + listTvFolders: jest.fn(async () => ['TV Shows']), +})); +jest.mock('../channelFolders', () => ({ + effectiveLibraryFolder: jest.fn(() => 'Kids'), + showDirectory: (show) => `/data/__TV Shows/${show.folder_name}`, +})); +jest.mock('../../sidecarWriter', () => ({ writeShowMetadata: jest.fn() })); +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); +jest.mock('../titleShowStore', () => ({ + ...jest.requireActual('../titleShowStore'), + listTitleShows: jest.fn(), +})); +jest.mock('../titleShowSaver', () => ({ save: jest.fn(), prepare: jest.fn() })); +jest.mock('../titleShowQueries', () => ({ + countsByShow: jest.fn(async () => new Map()), + describeVideos: jest.fn(async () => new Map()), + missingEpisodes: jest.fn(), +})); +jest.mock('../episodeConflicts', () => ({ listForChannel: jest.fn(async () => []) })); +jest.mock('../titlePreview', () => ({ summarizePlan: jest.fn(() => ({ shows: [] })) })); +jest.mock('../../download/videoActivity', () => ({ isActive: jest.fn(() => false) })); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; + +function storedShow(id, extra = {}) { + return { + id, key: `title:${id}`, channelId: CHANNEL_ID, name: `Show ${id}`, folderName: `Show ${id}`, libraryFolder: 'TV Shows', + position: 0, externalKey: 'u', excludeTerms: [], seasonNames: {}, retired: false, retiredAt: null, + patterns: [{ id: id * 10, key: `title:${id}#0`, position: 0, text: 'Ep {episode}', kind: 'simple', compiledRegex: 'c', filterRegex: 'f', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }], + ...extra, + }; +} + +const draftOf = (id) => expect.objectContaining({ id, name: `Show ${id}` }); + +describe('titleShowService', () => { + let service; + let store; + let saver; + let models; + let ChannelVideo; + let queries; + let conflicts; + const channel = { channel_id: CHANNEL_ID, title: 'BEYBLADE Official', enabled: true, tv_show_only_downloads: false, update: jest.fn() }; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + ChannelVideo = require('../../../models/channelvideo'); + store = require('../titleShowStore'); + saver = require('../titleShowSaver'); + queries = require('../titleShowQueries'); + conflicts = require('../episodeConflicts'); + service = require('../titleShowService'); + store.listTitleShows.mockImplementation(async (channelId, { includeRetired } = {}) => ( + includeRetired ? [storedShow(3), storedShow(4), storedShow(5, { retired: true })] : [storedShow(3), storedShow(4)] + )); + models.Channel.findOne.mockResolvedValue(channel); + }); + + describe('getChannelShows', () => { + it('lists the shows, retired ones included, with their counts', async () => { + queries.countsByShow.mockResolvedValue(new Map([[3, { episodes: 51 }]])); + const result = await service.getChannelShows(channel); + expect(result.shows.map((show) => [show.id, show.retired, show.counts])).toEqual([ + [3, false, { episodes: 51 }], [4, false, null], [5, true, null], + ]); + }); + + it('gives each pattern its compiled regex for regex mode', async () => { + const result = await service.getChannelShows(channel); + expect(result.shows[0].patterns[0]).toEqual({ + text: 'Ep {episode}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title', compiledRegex: 'c', + }); + }); + + it('describes the channel\'s conflicts', async () => { + conflicts.listForChannel.mockResolvedValue([{ youtubeId: 'dup', duplicateOf: 'win', kind: 'duplicate' }]); + queries.describeVideos.mockResolvedValue(new Map([ + ['dup', { title: 'Re-upload', downloaded: true, videoId: 8 }], + ['win', { title: 'Original', downloaded: false, videoId: null }], + ])); + const result = await service.getChannelShows(channel); + expect(result.conflicts).toEqual([expect.objectContaining({ + youtubeId: 'dup', title: 'Re-upload', downloaded: true, videoId: 8, duplicateOfTitle: 'Original', + })]); + }); + + it('tells the editor where new shows go by default', async () => { + const result = await service.getChannelShows(channel); + expect([result.tvFolders, result.defaultLibraryFolder, result.showOnlyDownloads]).toEqual([['TV Shows'], 'TV Shows', false]); + }); + }); + + describe('changes', () => { + it('adds a show after the existing ones', async () => { + await service.createShow(channel, { name: 'New' }); + expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(3), draftOf(4), { name: 'New' }]); + }); + + it('replaces a show\'s definition, keeping its id', async () => { + await service.updateShow(channel, 4, { name: 'Renamed', id: 99 }); + expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(3), { name: 'Renamed', id: 4 }]); + }); + + it('refuses to edit a show the channel doesn\'t have', async () => { + await expect(service.updateShow(channel, 9, { name: 'x' })).rejects.toMatchObject({ status: 404 }); + }); + + it('retires a show by leaving it out', async () => { + await service.retireShow(channel, 3); + expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(4)]); + }); + + it('restores a retired show at the end', async () => { + await service.restoreShow(channel, 5); + expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(3), draftOf(4), draftOf(5)]); + }); + + it('refuses to restore a show that isn\'t retired', async () => { + await expect(service.restoreShow(channel, 3)).rejects.toMatchObject({ status: 400 }); + }); + + it('reorders the shows', async () => { + await service.reorderShows(channel, [4, 3]); + expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(4), draftOf(3)]); + }); + + it('refuses an order that leaves out a show', async () => { + await expect(service.reorderShows(channel, [4])).rejects.toMatchObject({ status: 400 }); + }); + + it('re-checks the channel\'s titles with its current shows', async () => { + await service.recheck(channel); + expect(saver.save.mock.calls[0][0]).toEqual({ channel, rawShows: [draftOf(3), draftOf(4)] }); + }); + }); + + describe('show metadata', () => { + it('rewrites the NFO files of the channel\'s title shows after a save', async () => { + const row = { id: 4, kind: 'title', folder_name: 'Show 4' }; + models.TvShow.findAll.mockResolvedValue([row]); + await service.updateShow(channel, 4, { name: 'Renamed' }); + expect(require('../../sidecarWriter').writeShowMetadata).toHaveBeenCalledWith({ show: row, showDir: '/data/__TV Shows/Show 4' }); + }); + + it('asks only for the channel\'s active title shows', async () => { + await service.updateShow(channel, 4, { name: 'Renamed' }); + expect(models.TvShow.findAll).toHaveBeenCalledWith({ where: { channel_id: CHANNEL_ID, kind: 'title', retired_at: null } }); + }); + + it('keeps a save whose NFO files could not be written', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 4, kind: 'title', folder_name: 'Show 4' }]); + require('../../sidecarWriter').writeShowMetadata.mockRejectedValueOnce(new Error('EACCES')); + await expect(service.updateShow(channel, 4, { name: 'Renamed' })).resolves.toBeDefined(); + }); + }); + + describe('setShowOnly', () => { + it('stores the switch', async () => { + await service.setShowOnly(channel, true); + expect(channel.update).toHaveBeenCalledWith({ tv_show_only_downloads: true }); + }); + }); + + describe('preview', () => { + it('summarizes the plan of the drafts with the compiled patterns', async () => { + saver.prepare.mockResolvedValue({ + drafts: [{ key: 'new:0', patterns: [{ compiledRegex: '(?i)x', filterRegex: 'f' }] }], + plan: { videos: [] }, + }); + const result = await service.preview(channel, { shows: [{ name: 'x' }], overrides: [] }); + expect(result.compiled).toEqual([{ key: 'new:0', patterns: ['(?i)x'] }]); + }); + }); + + describe('useDuplicateCopy', () => { + it('gives the duplicate the number its holder has', async () => { + models.EpisodeConflict.findByPk.mockResolvedValue({ youtube_id: 'dup', channel_id: CHANNEL_ID, kind: 'duplicate', show_id: 3, details: JSON.stringify({ season: 1, episode: 20 }) }); + await service.useDuplicateCopy(channel, 'dup'); + expect(saver.save.mock.calls[0][0].rawOverrides).toEqual([{ youtubeId: 'dup', showId: 3, season: 1, episode: 20 }]); + }); + + it('refuses a video that is not a duplicate of the channel', async () => { + models.EpisodeConflict.findByPk.mockResolvedValue(null); + await expect(service.useDuplicateCopy(channel, 'dup')).rejects.toMatchObject({ status: 404 }); + }); + }); + + describe('getVideoEpisode', () => { + it('describes a video\'s classification and the shows it can join', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ + youtube_id: 'abc', channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 20, source: 'title', title_opt_out: false, + }); + models.TvShow.findByPk.mockResolvedValue({ id: 3, name: 'Show 3', kind: 'title' }); + const result = await service.getVideoEpisode('abc'); + expect(result).toEqual({ + channelId: CHANNEL_ID, + assignable: true, + classification: { showId: 3, showName: 'Show 3', kind: 'title', status: 'assigned', season: 1, episode: 20, code: 'S01E20', source: 'title', notAnEpisode: false }, + shows: [{ id: 3, name: 'Show 3', seasonNames: {} }, { id: 4, name: 'Show 4', seasonNames: {} }], + }); + }); + + it('finds the owner of an unclassified video through its download or listing', async () => { + models.VideoClassification.findByPk.mockResolvedValue(null); + models.Video.findOne.mockResolvedValue(null); + ChannelVideo.findOne.mockResolvedValue({ channel_id: CHANNEL_ID }); + expect((await service.getVideoEpisode('abc')).channelId).toBe(CHANNEL_ID); + }); + + it('is not assignable without a tracked channel', async () => { + models.VideoClassification.findByPk.mockResolvedValue(null); + models.Video.findOne.mockResolvedValue(null); + ChannelVideo.findOne.mockResolvedValue(null); + expect(await service.getVideoEpisode('abc')).toEqual({ channelId: null, assignable: false, classification: null, shows: [] }); + }); + }); + + describe('assignEpisode', () => { + it('saves the assignment as an override with the current shows', async () => { + models.VideoClassification.findByPk.mockResolvedValue(null); + models.Video.findOne.mockResolvedValue({ channel_id: CHANNEL_ID }); + await service.assignEpisode('abcdefghijk', { showId: 3, season: 1, episode: 51 }); + expect(saver.save.mock.calls[0][0]).toEqual({ + channel, + rawShows: [draftOf(3), draftOf(4)], + rawOverrides: [{ youtubeId: 'abcdefghijk', showId: 3, season: 1, episode: 51 }], + }); + }); + + it('refuses "Not an episode" for a video that is not in a title show', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ youtube_id: 'abcdefghijk', channel_id: CHANNEL_ID, show_id: 9, status: 'assigned', season: 2024, episode: 3151200, source: 'date', title_opt_out: false }); + models.TvShow.findByPk.mockResolvedValue({ id: 9, kind: 'channel' }); + await expect(service.assignEpisode('abcdefghijk', { notAnEpisode: true })).rejects.toMatchObject({ status: 400 }); + expect(saver.save).not.toHaveBeenCalled(); + }); + + it('marks an episode of a title show "Not an episode"', async () => { + models.VideoClassification.findByPk.mockResolvedValue({ youtube_id: 'abcdefghijk', channel_id: CHANNEL_ID, show_id: 3, status: 'assigned', season: 1, episode: 5, source: 'title', title_opt_out: false }); + models.TvShow.findByPk.mockResolvedValue({ id: 3, kind: 'title' }); + await service.assignEpisode('abcdefghijk', { notAnEpisode: true }); + expect(saver.save.mock.calls[0][0].rawOverrides).toEqual([{ youtubeId: 'abcdefghijk', notAnEpisode: true }]); + }); + + it('refuses a video whose channel has no shows', async () => { + models.VideoClassification.findByPk.mockResolvedValue(null); + models.Video.findOne.mockResolvedValue({ channel_id: CHANNEL_ID }); + store.listTitleShows.mockResolvedValue([]); + await expect(service.assignEpisode('abcdefghijk', { notAnEpisode: true })).rejects.toMatchObject({ status: 400 }); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/titleShowStore.test.js b/server/modules/tvShows/__tests__/titleShowStore.test.js new file mode 100644 index 000000000..5d2edacd7 --- /dev/null +++ b/server/modules/tvShows/__tests__/titleShowStore.test.js @@ -0,0 +1,219 @@ +jest.mock('../../../models', () => ({ + TvShow: { findAll: jest.fn(), findOne: jest.fn(), create: jest.fn(), update: jest.fn(), destroy: jest.fn() }, + TvShowPattern: { destroy: jest.fn(), bulkCreate: jest.fn() }, + TvShowSeason: { findAll: jest.fn(), findOne: jest.fn(), create: jest.fn(), update: jest.fn() }, +})); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; + +function patternRow(id, position, extra = {}) { + return { + id, position, pattern_text: 'Ep {episode}', pattern_kind: 'simple', compiled_regex: '(?i)Ep\\s+(?P<episode>[0-9]+)', + filter_regex: '(?i:Ep\\s+(?:[0-9]+))', season_source: 'fixed', season_fixed: 1, episode_source: 'title', ...extra, + }; +} + +function showRow(id, extra = {}) { + return { + id, channel_id: CHANNEL_ID, kind: 'title', name: `Show ${id}`, folder_name: `Show ${id}`, library_folder: 'TV Shows', + position: 0, exclude_terms: JSON.stringify(['Official Clip']), external_key: `uuid-${id}`, retired_at: null, + patterns: [patternRow(id * 10 + 1, 1), patternRow(id * 10, 0)], + seasons: [{ season: 1, name: 'Beyblade', order_high_water: 4 }, { season: 2, name: null, order_high_water: 0 }], + ...extra, + }; +} + +function draft(key, extra = {}) { + return { + id: key.startsWith('title:') ? Number(key.slice(6)) : null, + key, position: 0, name: 'Beyblade', folderName: 'Beyblade', libraryFolder: 'TV Shows', excludeTerms: [], seasonNames: { 1: 'Beyblade' }, + patterns: [{ + key: `${key}#0`, position: 0, text: 'Ep {episode}', kind: 'simple', compiledRegex: 'c', filterRegex: 'f', + seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title', + }], + ...extra, + }; +} + +describe('titleShowStore', () => { + let store; + let models; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + models = require('../../../models'); + store = require('../titleShowStore'); + }); + + describe('listTitleShows', () => { + it('serializes shows with their patterns in order and their named seasons', async () => { + models.TvShow.findAll.mockResolvedValue([showRow(3)]); + const [show] = await store.listTitleShows(CHANNEL_ID); + expect(show).toMatchObject({ + id: 3, key: 'title:3', name: 'Show 3', excludeTerms: ['Official Clip'], seasonNames: { 1: 'Beyblade' }, retired: false, + }); + expect(show.patterns.map((pattern) => [pattern.id, pattern.key])).toEqual([[30, 'title:3#0'], [31, 'title:3#1']]); + }); + + it('asks only for active title shows of the channel by default', async () => { + models.TvShow.findAll.mockResolvedValue([]); + await store.listTitleShows(CHANNEL_ID); + expect(models.TvShow.findAll.mock.calls[0][0].where).toEqual({ channel_id: CHANNEL_ID, kind: 'title', retired_at: null }); + }); + + it('includes retired shows when asked', async () => { + models.TvShow.findAll.mockResolvedValue([]); + await store.listTitleShows(CHANNEL_ID, { includeRetired: true }); + expect(models.TvShow.findAll.mock.calls[0][0].where).toEqual({ channel_id: CHANNEL_ID, kind: 'title' }); + }); + }); + + describe('toDraft', () => { + it('turns a stored show back into a draft', async () => { + models.TvShow.findAll.mockResolvedValue([showRow(3)]); + const [show] = await store.listTitleShows(CHANNEL_ID); + expect(store.toDraft(show)).toEqual({ + id: 3, name: 'Show 3', folderName: 'Show 3', libraryFolder: 'TV Shows', excludeTerms: ['Official Clip'], + seasonNames: { 1: 'Beyblade' }, + patterns: [ + { text: 'Ep {episode}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }, + { text: 'Ep {episode}', kind: 'simple', seasonSource: 'fixed', seasonFixed: 1, episodeSource: 'title' }, + ], + }); + }); + }); + + describe('saveDefinitions', () => { + beforeEach(() => { + models.TvShow.findAll.mockResolvedValue([]); + models.TvShow.create.mockImplementation(async (values) => ({ id: 9, ...values })); + models.TvShowPattern.bulkCreate.mockImplementation(async (rows) => rows.map((row, index) => ({ id: 100 + index, ...row }))); + models.TvShowSeason.findAll.mockResolvedValue([]); + }); + + it('creates a new show with a fresh external key and returns its id', async () => { + const { showIds } = await store.saveDefinitions({ channelId: CHANNEL_ID, drafts: [draft('new:0')] }); + expect(showIds.get('new:0')).toBe(9); + expect(models.TvShow.create.mock.calls[0][0]).toMatchObject({ + channel_id: CHANNEL_ID, kind: 'title', name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', + external_key: expect.stringMatching(/^[0-9a-f-]{36}$/), + }); + }); + + it('replaces the patterns of a show and maps their keys to the new ids', async () => { + const { patternIds } = await store.saveDefinitions({ channelId: CHANNEL_ID, drafts: [draft('new:0')] }); + expect(models.TvShowPattern.destroy).toHaveBeenCalledWith(expect.objectContaining({ where: { show_id: 9 } })); + expect(patternIds.get('new:0#0')).toBe(100); + }); + + it('updates an existing show of the channel and clears its retirement', async () => { + const existing = { ...showRow(3), update: jest.fn() }; + models.TvShow.findAll.mockResolvedValue([existing]); + await store.saveDefinitions({ channelId: CHANNEL_ID, drafts: [draft('title:3', { position: 2 })] }); + expect(existing.update).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Beyblade', position: 2, retired_at: null }), + expect.anything() + ); + }); + + it('refuses a show id the channel does not own', async () => { + await expect(store.saveDefinitions({ channelId: CHANNEL_ID, drafts: [draft('title:77')] })).rejects.toMatchObject({ status: 404 }); + }); + + it('retires active shows the drafts leave out', async () => { + const left = { ...showRow(5), update: jest.fn() }; + models.TvShow.findAll.mockResolvedValue([left]); + await store.saveDefinitions({ channelId: CHANNEL_ID, drafts: [] }); + expect(left.update).toHaveBeenCalledWith({ retired_at: expect.any(Date) }, expect.anything()); + }); + + it('names a season and clears the name of one no longer named', async () => { + const named = { season: 2, name: 'Old', update: jest.fn() }; + models.TvShowSeason.findAll.mockResolvedValue([named]); + await store.saveDefinitions({ channelId: CHANNEL_ID, drafts: [draft('new:0')] }); + expect(models.TvShowSeason.create).toHaveBeenCalledWith({ show_id: 9, season: 1, name: 'Beyblade' }, expect.anything()); + expect(named.update).toHaveBeenCalledWith({ name: null }, expect.anything()); + }); + }); + + describe('assertFolderNamesFree', () => { + it('refuses a folder another show uses, suggesting the name with the channel', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 2, name: 'Beyblade', folder_name: 'beyblade', library_folder: 'TV Shows', channel_id: 'UCother', kind: 'title', retired_at: null }]); + await expect(store.assertFolderNamesFree({ channelTitle: 'BEYBLADE Official', channelId: CHANNEL_ID, drafts: [draft('new:0')] })) + .rejects.toMatchObject({ status: 409, details: { suggestion: 'Beyblade (BEYBLADE Official)' } }); + }); + + it('offers to restore a retired show of the same channel at that folder', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 2, name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', channel_id: CHANNEL_ID, kind: 'title', retired_at: new Date() }]); + await expect(store.assertFolderNamesFree({ channelTitle: 'x', channelId: CHANNEL_ID, drafts: [draft('new:0')] })) + .rejects.toMatchObject({ details: { retiredShowId: 2 } }); + }); + + it('lets a show keep its own folder', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 3, name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', channel_id: CHANNEL_ID, kind: 'title', retired_at: null }]); + await expect(store.assertFolderNamesFree({ channelTitle: 'x', channelId: CHANNEL_ID, drafts: [draft('title:3')] })).resolves.toBeUndefined(); + }); + }); + + describe('highWaterMarks', () => { + it('reads the order high-water mark of each show season', async () => { + models.TvShowSeason.findAll.mockResolvedValue([{ show_id: 3, season: 1, order_high_water: 4 }]); + expect(await store.highWaterMarks([3])).toEqual(new Map([['title:3|1', 4]])); + }); + }); + + describe('raiseHighWater', () => { + it('creates the season row when it is missing', async () => { + models.TvShowSeason.findOne.mockResolvedValue(null); + await store.raiseHighWater(3, 0, 5); + expect(models.TvShowSeason.create).toHaveBeenCalledWith({ show_id: 3, season: 0, order_high_water: 5 }, expect.anything()); + }); + + it('raises the mark another writer created meanwhile', async () => { + const season = { order_high_water: 3, update: jest.fn() }; + models.TvShowSeason.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce(season); + models.TvShowSeason.create.mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'SequelizeUniqueConstraintError' })); + await store.raiseHighWater(3, 0, 5); + expect(season.update).toHaveBeenCalledWith({ order_high_water: 5 }, expect.anything()); + }); + + it('never lowers the mark', async () => { + const season = { order_high_water: 9, update: jest.fn() }; + models.TvShowSeason.findOne.mockResolvedValue(season); + await store.raiseHighWater(3, 0, 5); + expect(season.update).not.toHaveBeenCalled(); + }); + }); + + describe('titleShowIds', () => { + it('lists every title show id of the channel, retired ones included', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 3 }, { id: 7 }]); + expect(await store.titleShowIds(CHANNEL_ID, { transaction: 't' })).toEqual([3, 7]); + expect(models.TvShow.findAll).toHaveBeenCalledWith(expect.objectContaining({ where: { channel_id: CHANNEL_ID, kind: 'title' }, transaction: 't' })); + }); + }); + + describe('deleteShows', () => { + it('deletes only title shows with the given ids', async () => { + await store.deleteShows([7], { transaction: 't' }); + expect(models.TvShow.destroy).toHaveBeenCalledWith({ where: { id: [7], kind: 'title' }, transaction: 't' }); + }); + + it('deletes nothing for no ids', async () => { + await store.deleteShows([]); + expect(models.TvShow.destroy).not.toHaveBeenCalled(); + }); + }); + + describe('showFiltersByChannel', () => { + it('gives each channel one download filter per active title show', async () => { + models.TvShow.findAll.mockResolvedValue([showRow(3)]); + const filters = await store.showFiltersByChannel([CHANNEL_ID]); + expect(filters.get(CHANNEL_ID)).toEqual([{ + filterRegex: '(?i:Ep\\s+(?:[0-9]+))|(?i:Ep\\s+(?:[0-9]+))', + excludeRegexes: ['(?i:Official\\s+Clip)'], + }]); + }); + }); +}); diff --git a/server/modules/tvShows/__tests__/tvNfo.test.js b/server/modules/tvShows/__tests__/tvNfo.test.js index 179fafde0..066f4551c 100644 --- a/server/modules/tvShows/__tests__/tvNfo.test.js +++ b/server/modules/tvShows/__tests__/tvNfo.test.js @@ -80,6 +80,47 @@ describe('tvNfo', () => { const bare = tvNfo.buildTvShowNfo({ title: 'X', externalKey: 'UC1' }); expect(bare).not.toMatch(/<plot>|<premiered>/); }); + + it('identifies a title show by its Youtarr key', () => { + const titleShow = tvNfo.buildTvShowNfo({ title: 'Beyblade', externalKey: 'uuid-1', idType: 'youtarr' }); + expect(titleShow).toContain('<uniqueid type="youtarr" default="true">uuid-1</uniqueid>'); + expect(titleShow).not.toContain('type="youtube"'); + }); + + it('names seasons in order', () => { + const named = tvNfo.buildTvShowNfo({ title: 'Beyblade', externalKey: 'k', namedSeasons: { 2: 'V-Force', 1: 'Beyblade & Co' } }); + expect(named).toContain(' <namedseason number="1">Beyblade & Co</namedseason>\n <namedseason number="2">V-Force</namedseason>'); + }); + }); + + describe('buildSeasonNfo', () => { + it('writes the season name and number', () => { + const xml = tvNfo.buildSeasonNfo({ season: 2, name: 'V-Force' }); + expect(xml).toMatch(/<season>\s*<title>V-Force<\/title>\s*<seasonnumber>2<\/seasonnumber>\s*<\/season>/); + }); + }); + + describe('writeSeasonNfo', () => { + let tmpDir; + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tvnfo-season-')); }); + afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + it('writes season.nfo for a named season', async () => { + await tvNfo.writeSeasonNfo(tmpDir, { season: 1, name: 'Beyblade' }); + expect(fs.readFileSync(path.join(tmpDir, 'season.nfo'), 'utf8')).toContain('<title>Beyblade'); + }); + + it('removes Youtarr\'s season.nfo when the season has no name', async () => { + await tvNfo.writeSeasonNfo(tmpDir, { season: 1, name: 'Beyblade' }); + await tvNfo.writeSeasonNfo(tmpDir, { season: 1, name: null }); + expect(fs.existsSync(path.join(tmpDir, 'season.nfo'))).toBe(false); + }); + + it('leaves a season.nfo Youtarr didn\'t write', async () => { + fs.writeFileSync(path.join(tmpDir, 'season.nfo'), 'Mine'); + await tvNfo.writeSeasonNfo(tmpDir, { season: 1, name: null }); + expect(fs.existsSync(path.join(tmpDir, 'season.nfo'))).toBe(true); + }); }); describe('dateFromEpisodeCode', () => { diff --git a/server/modules/tvShows/archiveSuppressor.js b/server/modules/tvShows/archiveSuppressor.js new file mode 100644 index 000000000..2c1e1017c --- /dev/null +++ b/server/modules/tvShows/archiveSuppressor.js @@ -0,0 +1,99 @@ +/** + * The deferred writer for complete.list changes title shows make: a duplicate + * episode is suppressed (its line added, so channel downloads skip it) and a + * released one restored (its line removed). + * + * A channel download job finds what it downloaded by counting archive lines + * from where it started, so the file must not change under a running job. + * Requests are stored on their episode_conflicts row (archive_pending) and + * applied when no download job is In Progress: right away when the queue is + * idle, else when jobModule is about to start the next job, and at startup. + */ + +const { EpisodeConflict } = require('../../models'); +const archiveModule = require('../archiveModule'); +const logger = require('../../logger'); + +const ARCHIVE_ADD = 'add'; +const ARCHIVE_REMOVE = 'remove'; +const KIND_RELEASED = 'released'; + +class ArchiveSuppressor { + constructor() { + // Until wired, assume a download may be running: never write blind. + this.isDownloadRunning = () => true; + this.inFlight = null; + } + + /** + * @param {{isDownloadRunning: () => boolean}} deps + */ + initialize({ isDownloadRunning }) { + this.isDownloadRunning = isDownloadRunning; + } + + /** + * Apply every pending archive write, unless a download job is running. + * Concurrent calls share one pass. + * @returns {Promise} + */ + flush() { + if (!this.inFlight) { + this.inFlight = this._flush().finally(() => { + this.inFlight = null; + }); + } + return this.inFlight; + } + + async _flush() { + if (this.isDownloadRunning()) return; + const rows = await EpisodeConflict.findAll({ where: { archive_pending: [ARCHIVE_ADD, ARCHIVE_REMOVE] } }); + for (const row of rows) { + if (this.isDownloadRunning()) return; + try { + await this._apply(row); + } catch (err) { + logger.error({ err, youtubeId: row.youtube_id, pending: row.archive_pending }, 'Could not update complete.list for a duplicate episode'); + } + } + } + + async _apply(row) { + const youtubeId = row.youtube_id; + if (row.archive_pending === ARCHIVE_ADD) { + if (!row.archive_suppressed) { + // A line already there is a real download's (kept when the video was + // deleted): never Youtarr's to remove. + if (await archiveModule.isVideoInArchive(youtubeId)) { + await row.update({ archive_pending: null, archive_suppressed: false }); + return; + } + // Ownership is recorded before the append: an append that lands + // without this update would otherwise read as someone else's line. + await row.update({ archive_suppressed: true }); + } + if (!(await archiveModule.isVideoInArchive(youtubeId))) { + await archiveModule.addVideoToArchive(youtubeId); + if (!(await archiveModule.isVideoInArchive(youtubeId))) { + logger.warn({ youtubeId }, 'A duplicate episode is still missing from complete.list; retrying later'); + return; + } + } + await row.update({ archive_pending: null }); + return; + } + await archiveModule.removeVideoFromArchive(youtubeId); + if (await archiveModule.isVideoInArchive(youtubeId)) { + logger.warn({ youtubeId }, 'A released episode is still in complete.list; retrying later'); + return; + } + if (row.kind === KIND_RELEASED) await row.destroy(); + else await row.update({ archive_pending: null, archive_suppressed: false }); + } +} + +module.exports = new ArchiveSuppressor(); +module.exports.ARCHIVE_ADD = ARCHIVE_ADD; +module.exports.ARCHIVE_REMOVE = ARCHIVE_REMOVE; +module.exports.KIND_RELEASED = KIND_RELEASED; diff --git a/server/modules/tvShows/episodeConflicts.js b/server/modules/tvShows/episodeConflicts.js new file mode 100644 index 000000000..755cbd3f6 --- /dev/null +++ b/server/modules/tvShows/episodeConflicts.js @@ -0,0 +1,203 @@ +/** + * Episode conflicts: videos that lost their episode number to another upload + * (duplicates) and videos whose title could not be classified. + * + * A duplicate that isn't downloaded is ignored on the channel and suppressed + * in complete.list (through archiveSuppressor), so Download All and channel + * downloads skip it. The conflict row records what Youtarr did, the ignore + * (`youtarr_ignored`) and the archive line (`archive_suppressed`, only when + * Youtarr wrote it), and only that is undone when the conflict is released: + * an ignore the user set, or an archive line that was already there (a real + * download's, kept when the video was deleted), is never touched. A + * downloaded duplicate is never ignored or deleted. + */ + +const { EpisodeConflict } = require('../../models'); +const ChannelVideo = require('../../models/channelvideo'); +const { ARCHIVE_ADD, ARCHIVE_REMOVE, KIND_RELEASED } = require('./archiveSuppressor'); + +const KIND_DUPLICATE = 'duplicate'; +const KIND_ERROR = 'classification_error'; + +function parseDetails(text) { + if (!text) return {}; + try { + return JSON.parse(text) || {}; + } catch (err) { + return {}; + } +} + +// Youtarr wrote the video's archive line, or is waiting to. +function ownsArchiveLine(row) { + return Boolean(row.archive_suppressed) || row.archive_pending === ARCHIVE_ADD; +} + +async function setIgnored(channelId, youtubeId, ignored, transaction) { + const where = { channel_id: channelId, youtube_id: youtubeId }; + if (!ignored) where.ignored = true; + await ChannelVideo.update( + ignored ? { ignored: true, ignored_at: new Date() } : { ignored: false, ignored_at: null }, + { where, transaction } + ); +} + +async function ignoredByUser(channelId, youtubeId, transaction) { + const rows = await ChannelVideo.findAll({ + where: { channel_id: channelId, youtube_id: youtubeId }, attributes: ['ignored'], transaction, + }); + return rows.some((entry) => entry.ignored); +} + +/** + * Record that a video lost its number to another upload. + * + * @param {Object} params + * @param {string} params.youtubeId + * @param {string} params.channelId + * @param {number} params.showId + * @param {number} params.season + * @param {number} params.episode + * @param {string} params.duplicateOf - The video holding the number + * @param {boolean} params.downloaded + * @param {Object} [params.transaction] + */ +async function recordDuplicate({ youtubeId, channelId, showId, season, episode, duplicateOf, downloaded, transaction = null }) { + const values = { + channel_id: channelId, + show_id: showId, + kind: KIND_DUPLICATE, + duplicate_of: duplicateOf, + details: JSON.stringify({ season, episode }), + }; + const existing = await EpisodeConflict.findByPk(youtubeId, { transaction }); + if (existing && existing.kind !== KIND_ERROR) { + if (existing.kind === KIND_RELEASED && existing.archive_pending === ARCHIVE_REMOVE && !downloaded) { + // Released, but its line is still there: suppress it again as it is. + await setIgnored(channelId, youtubeId, true, transaction); + await existing.update({ ...values, archive_pending: null, youtarr_ignored: true }, { transaction }); + return; + } + await existing.update(values, { transaction }); + return; + } + + const suppress = !downloaded && !(await ignoredByUser(channelId, youtubeId, transaction)); + if (suppress) await setIgnored(channelId, youtubeId, true, transaction); + const row = { ...values, youtarr_ignored: suppress, archive_suppressed: false, archive_pending: suppress ? ARCHIVE_ADD : null }; + if (existing) await existing.update(row, { transaction }); + else await EpisodeConflict.create({ youtube_id: youtubeId, ...row }, { transaction }); +} + +/** + * Release a conflict: the video holds its number now, matches no show, or + * its show is gone. Youtarr's ignore is taken back and its archive line + * removed (deferred); anything else stays as the user left it. + */ +async function release(youtubeId, { transaction = null } = {}) { + const row = await EpisodeConflict.findByPk(youtubeId, { transaction }); + if (!row || row.kind === KIND_RELEASED) return; + if (row.youtarr_ignored) await setIgnored(row.channel_id, youtubeId, false, transaction); + if (row.archive_suppressed) { + await row.update({ kind: KIND_RELEASED, archive_pending: ARCHIVE_REMOVE, youtarr_ignored: false }, { transaction }); + } else { + // A pending add goes with the row. + await row.destroy({ transaction }); + } +} + +/** + * An explicit download removed these videos' archive lines before yt-dlp + * ran. A suppressed duplicate's line is written again after the job unless + * the video downloads (noteDownloaded). + */ +async function noteArchiveLinesRemoved(youtubeIds) { + if (!youtubeIds.length) return; + const rows = await EpisodeConflict.findAll({ where: { youtube_id: youtubeIds, archive_suppressed: true } }); + for (const row of rows) await row.update({ archive_suppressed: false, archive_pending: ARCHIVE_ADD }); +} + +/** + * A duplicate was downloaded after all: the download's archive line stands + * and Youtarr's ignore is taken back. It stays a (downloaded) duplicate. + */ +async function noteDownloaded(youtubeId) { + const row = await EpisodeConflict.findByPk(youtubeId); + if (!row || !(row.youtarr_ignored || ownsArchiveLine(row))) return; + if (row.youtarr_ignored) await setIgnored(row.channel_id, youtubeId, false, null); + await row.update({ youtarr_ignored: false, archive_suppressed: false, archive_pending: null }); +} + +/** + * Record that a video's title could not be classified (the Python check + * failed). A duplicate conflict is left as it is; a released row (a + * duplicate whose archive line is still being taken back) becomes the error, + * keeping that pending removal. + */ +async function recordError({ youtubeId, channelId, message, transaction = null }) { + const existing = await EpisodeConflict.findByPk(youtubeId, { transaction }); + const values = { channel_id: channelId, show_id: null, kind: KIND_ERROR, duplicate_of: null, details: JSON.stringify({ message }) }; + if (!existing) { + await EpisodeConflict.create({ youtube_id: youtubeId, ...values }, { transaction }); + } else if (existing.kind !== KIND_DUPLICATE) { + await existing.update(values, { transaction }); + } +} + +async function clearError(youtubeId, { transaction = null } = {}) { + const row = await EpisodeConflict.findByPk(youtubeId, { transaction }); + if (row && row.kind === KIND_ERROR) await row.destroy({ transaction }); +} + +/** + * Forget a channel's classification errors: its titles just classified. + */ +async function clearErrorsForChannel(channelId, { transaction = null } = {}) { + await EpisodeConflict.destroy({ where: { channel_id: channelId, kind: KIND_ERROR }, transaction }); +} + +/** + * The channel's videos recorded as duplicates. + * @returns {Promise>} + */ +async function duplicateIdsForChannel(channelId, { transaction = null } = {}) { + const rows = await EpisodeConflict.findAll({ + where: { channel_id: channelId, kind: KIND_DUPLICATE }, attributes: ['youtube_id'], transaction, + }); + return new Set(rows.map((row) => row.youtube_id)); +} + +/** + * @returns {Promise>} + */ +async function listForChannel(channelId) { + const rows = await EpisodeConflict.findAll({ where: { channel_id: channelId, kind: [KIND_DUPLICATE, KIND_ERROR] } }); + return rows.map((row) => { + const details = parseDetails(row.details); + return { + youtubeId: row.youtube_id, + kind: row.kind, + showId: row.show_id, + duplicateOf: row.duplicate_of, + season: details.season === undefined ? null : details.season, + episode: details.episode === undefined ? null : details.episode, + message: details.message || null, + suppressed: Boolean(row.youtarr_ignored), + }; + }); +} + +module.exports = { + KIND_DUPLICATE, + KIND_ERROR, + recordDuplicate, + release, + noteArchiveLinesRemoved, + noteDownloaded, + recordError, + clearError, + clearErrorsForChannel, + duplicateIdsForChannel, + listForChannel +}; diff --git a/server/modules/tvShows/episodePlacement.js b/server/modules/tvShows/episodePlacement.js index d0731f794..77cae8b66 100644 --- a/server/modules/tvShows/episodePlacement.js +++ b/server/modules/tvShows/episodePlacement.js @@ -11,15 +11,20 @@ const fs = require('fs'); const path = require('path'); const VideoClassification = require('../../models/videoclassification'); +const TvShowSeason = require('../../models/tvshowseason'); const logger = require('../../logger'); const { buildSubfolderSegment, isFileForVideo, replaceFileWithRetries, ensureDirWithRetries } = require('../filesystem'); -const { LAYOUT_TV } = require('./constants'); +const { LAYOUT_TV, KIND_TITLE_SHOW } = require('./constants'); const { getLayoutResolver } = require('./libraryLayouts'); const { resolveDestination } = require('./routing'); const showStore = require('./showStore'); +const titleEpisodeAssigner = require('./titleEpisodeAssigner'); +const episodeConflicts = require('./episodeConflicts'); const { assignDateEpisode, STATUS_ASSIGNED } = require('./episodeAllocator'); const { seasonFolderName, episodeFileName } = require('./episodeNaming'); -const { dateFromEpisodeCode, buildEpisodeNfo, writeTvShowNfoIfChanged } = require('./tvNfo'); +const { + dateFromEpisodeCode, buildEpisodeNfo, writeTvShowNfoIfChanged, writeSeasonNfo, ID_TYPE_YOUTARR, ID_TYPE_YOUTUBE, +} = require('./tvNfo'); const MOVE_RETRY_OPTIONS = { retries: 5, delayMs: 500 }; @@ -27,6 +32,17 @@ function libraryFolderPath(baseDir, libraryFolder) { return libraryFolder ? path.join(baseDir, buildSubfolderSegment(libraryFolder)) : baseDir; } +function placementAt(show, assignment, baseDir) { + const showDir = path.join(libraryFolderPath(baseDir, show.library_folder), show.folder_name); + return { + show, + assignment, + showDir, + seasonDir: path.join(showDir, seasonFolderName(assignment.season)), + stem: assignment.fileStem, + }; +} + /** * Work out where a downloaded video goes. Returns null for a movie-style * destination, which is also the result when folder layouts can't be read. @@ -54,6 +70,21 @@ async function planEpisode({ logger.error({ err, youtubeId }, 'Could not read library folder layouts; saving the video movie-style'); return null; } + try { + await episodeConflicts.noteDownloaded(youtubeId); + } catch (err) { + logger.warn({ err, youtubeId }, 'Could not update the duplicate record of a downloaded episode'); + } + + // A title show of the owner channel wins over the resolved folder. + const titlePlacement = await titleEpisodeAssigner.resolveTitlePlacement({ youtubeId, info, ownerChannelId, channelEnabled }); + if (titlePlacement) { + if (layoutOf(titlePlacement.show.library_folder || '') === LAYOUT_TV) { + return placementAt(titlePlacement.show, titlePlacement.assignment, baseDir); + } + logger.warn({ youtubeId, showId: titlePlacement.show.id }, 'The title show\'s folder is no longer a TV folder; saving the video by the channel layout'); + } + if (layoutOf(resolvedSubfolder || '') !== LAYOUT_TV) return null; let channelShow = ownerChannelId ? await showStore.findChannelShow(ownerChannelId) : null; @@ -84,14 +115,7 @@ async function planEpisode({ }); const assignment = await assignDateEpisode({ show, youtubeId, channelId: ownerChannelId, info }); - const showDir = path.join(libraryFolderPath(baseDir, show.library_folder), show.folder_name); - return { - show, - assignment, - showDir, - seasonDir: path.join(showDir, seasonFolderName(assignment.season)), - stem: assignment.fileStem, - }; + return placementAt(show, assignment, baseDir); } /** @@ -157,17 +181,42 @@ async function writeEpisodeMetadata({ placement, info, showPlot = null }) { episodeTitle: assignment.episodeTitle, }); await fs.promises.writeFile(path.join(seasonDir, `${stem}.nfo`), episodeXml, 'utf8'); + if (show.kind !== KIND_TITLE_SHOW) { + await writeTvShowNfoIfChanged(showDir, { + title: show.name, + plot: showPlot, + premiered: await earliestEpisodeDate(show.id), + externalKey: show.external_key, + idType: ID_TYPE_YOUTUBE, + }); + return; + } + // A title show: identified by its own key, seasons named as the user set + // them (Emby reads season names only from season.nfo). + const namedSeasons = await seasonNamesOf(show.id); await writeTvShowNfoIfChanged(showDir, { title: show.name, - plot: showPlot, - premiered: await earliestEpisodeDate(show.id), externalKey: show.external_key, + idType: ID_TYPE_YOUTARR, + namedSeasons, }); + await writeSeasonNfo(seasonDir, { season: assignment.season, name: namedSeasons[assignment.season] || null }); +} + +/** + * A title show's season names by season number. + */ +async function seasonNamesOf(showId) { + const rows = await TvShowSeason.findAll({ where: { show_id: showId }, attributes: ['season', 'name'] }); + const names = {}; + for (const row of rows) if (row.name) names[row.season] = row.name; + return names; } module.exports = { planEpisode, moveEpisodeFiles, earliestEpisodeDate, + seasonNamesOf, writeEpisodeMetadata }; diff --git a/server/modules/tvShows/index.js b/server/modules/tvShows/index.js index 1096d4c9f..17c600851 100644 --- a/server/modules/tvShows/index.js +++ b/server/modules/tvShows/index.js @@ -14,9 +14,26 @@ * - episodeAllocator: stored, unique episode numbers per show * - routing: where a downloaded video belongs * - episodePlacement: the post-processor's TV branch - * - tvNfo: episode NFO and tvshow.nfo files + * - tvNfo: episode NFO, tvshow.nfo and season.nfo files * - episodeInfo: episode details for API responses * + * Title shows (shows defined on a channel by title patterns): + * - patternCompiler: simple syntax and regex mode to Python regexes and download filters + * - showFolderNames: show folder name rules (no dependencies) + * - titleShowDrafts: validated, compiled drafts of a channel's title shows + * - titleShowStore: stored title shows, patterns and seasons + * - titleMatcher: which show and pattern each title matches (one Python batch) + * - titleNumbering: episode numbers, duplicates and gaps (pure) + * - titlePlanner: a channel's title shows after a change, for every video + * - titlePreview: the editor's preview of a plan + * - titleRowWriter: writes a plan's definitions, rows and conflicts + * - titleShowSaver: saves a change directly or asks for the reorganize + * - titleShowService: title shows as the API sees them + * - titleShowQueries: counts, missing episodes, show filter, planned episodes + * - titleEpisodeAssigner: the post-processor's title show decision + * - episodeConflicts: duplicates and classification errors, with provenance + * - archiveSuppressor: the deferred complete.list writer + * * Callers require the submodule they need; most of them read the database. */ diff --git a/server/modules/tvShows/layoutGuards.js b/server/modules/tvShows/layoutGuards.js index da2bd189c..800ce2602 100644 --- a/server/modules/tvShows/layoutGuards.js +++ b/server/modules/tvShows/layoutGuards.js @@ -14,11 +14,12 @@ const Channel = require('../../models/channel'); const Playlist = require('../../models/playlist'); const Video = require('../../models/video'); const VideoClassification = require('../../models/videoclassification'); +const TvShow = require('../../models/tvshow'); const configModule = require('../configModule'); const { buildSubfolderSegment, directoryHasFiles, GLOBAL_DEFAULT_SENTINEL } = require('../filesystem'); const { effectiveLibraryFolder } = require('./channelFolders'); const { getLayoutResolver } = require('./libraryLayouts'); -const { LAYOUT_TV, folderKey, isMp3Format, MP3_AUDIO_FORMATS } = require('./constants'); +const { LAYOUT_TV, folderKey, isMp3Format, MP3_AUDIO_FORMATS, KIND_TITLE_SHOW } = require('./constants'); function guardError(message, status) { const err = new Error(message); @@ -168,8 +169,26 @@ async function assertVideoOnlyDestination({ audioFormat, subFolderValue }) { } } +/** + * Refuse switching a folder that holds title shows to the Videos layout: + * title shows always live in a TV folder. + * @param {string} libraryFolder - '' for the main folder + */ +async function assertNoTitleShows(libraryFolder) { + const shows = await TvShow.findAll({ + where: { library_folder: libraryFolder || '', kind: KIND_TITLE_SHOW, retired_at: null }, attributes: ['name'], + }); + if (shows.length === 0) return; + throw guardError( + `Title shows always live in a TV folder, and this folder holds ${shows.map((show) => show.name).join(', ')}. ` + + 'Move them to another TV folder or remove them first.', + 400 + ); +} + module.exports = { MP3_AUDIO_FORMATS, + assertNoTitleShows, guardError, reorganizeRequiredError, errorBody, diff --git a/server/modules/tvShows/libraryFolders.js b/server/modules/tvShows/libraryFolders.js index 9ecc99151..0b7b28f5b 100644 --- a/server/modules/tvShows/libraryFolders.js +++ b/server/modules/tvShows/libraryFolders.js @@ -114,6 +114,7 @@ async function setFolderLayout(name, layout, { isDownloadRunning } = {}) { reorganizeLock.assertInactive(MESSAGES.reorganizing); const users = await layoutGuards.usersOfFolder(folder); if (layout === LAYOUT_TV) layoutGuards.assertNoMp3Users(users, 'this folder'); + else await layoutGuards.assertNoTitleShows(folder); const reorganize = { type: CHANGE_FOLDER_LAYOUT, folder, layout }; if (await layoutGuards.folderHasFiles(folder)) { throw layoutGuards.reorganizeRequiredError(MESSAGES.reorganize, reorganize); diff --git a/server/modules/tvShows/patternCompiler.js b/server/modules/tvShows/patternCompiler.js new file mode 100644 index 000000000..c4946a277 --- /dev/null +++ b/server/modules/tvShows/patternCompiler.js @@ -0,0 +1,290 @@ +/** + * Title patterns of title shows, compiled to Python regexes (classification + * and yt-dlp's match filter both run Python's re, so a pattern behaves the + * same in the preview, at save, at finalize and in channel downloads). + * + * Simple syntax: literal text matches ignoring case, a space matches any run + * of whitespace, `*` matches any text, a leading `^` anchors to the start of + * the title, and placeholders capture parts of it: {season}, {episode}, + * {episode_end} and {part} (ASCII digits: Python's \d also matches digits JS + * can't parse) and {title} (the episode title; lazy when more pattern text + * follows, else greedy to the end of the title). + * + * Regex mode takes a Python regex with the same names as named groups. + * + * Each pattern also gets a filter form for yt-dlp's match filter: groups + * unnamed (a show's patterns are joined into one alternation, and repeated + * group names are an error there) and leading flags scoped to a group (a + * global flag that isn't at the start is an error too). + */ + +const PATTERN_KIND = Object.freeze({ SIMPLE: 'simple', REGEX: 'regex' }); +const SEASON_SOURCE = Object.freeze({ TITLE: 'title', FIXED: 'fixed', YEAR: 'year' }); +const EPISODE_SOURCE = Object.freeze({ TITLE: 'title', DATE: 'date', ORDER: 'order' }); +const GROUP_NAMES = Object.freeze(['season', 'episode', 'episode_end', 'part', 'title']); +const NUMBER_GROUPS = new Set(['season', 'episode', 'episode_end', 'part']); +const MAX_SEASON = 199; +// Upload-year seasons; Jellyfin reads 200-1927 and anything above 2500 as no season. +const MIN_YEAR_SEASON = 1928; +const MAX_YEAR_SEASON = 2500; +const MAX_PATTERN_LENGTH = 500; + +// Characters with a meaning in a Python regex outside a character class. +const REGEX_SPECIAL = new Set(['\\', '.', '^', '$', '*', '+', '?', '{', '}', '[', ']', '|', '(', ')']); +const PLACEHOLDER = /^\{([A-Za-z_]+)\}/; +const FLAG_GROUP = /^\(\?([aiLmsux]+)\)/; + +class PatternError extends Error { + constructor(message) { + super(message); + this.name = 'PatternError'; + this.status = 400; + } +} + +function escapeLiteral(text) { + let out = ''; + for (const char of text) out += REGEX_SPECIAL.has(char) ? `\\${char}` : char; + return out; +} + +// Literal text with whitespace runs as \s+. +function literalRegex(text) { + return text.trim().split(/\s+/).map(escapeLiteral).join('\\s+'); +} + +function tokenizeSimple(text) { + const tokens = []; + let literal = ''; + const flush = () => { + if (literal) tokens.push({ type: 'literal', value: literal }); + literal = ''; + }; + for (let i = 0; i < text.length;) { + const rest = text.slice(i); + const placeholder = PLACEHOLDER.exec(rest); + if (placeholder) { + flush(); + tokens.push({ type: 'placeholder', name: placeholder[1] }); + i += placeholder[0].length; + } else if (rest[0] === '*') { + flush(); + tokens.push({ type: 'any' }); + i += 1; + } else if (/\s/.test(rest[0])) { + flush(); + if (tokens.length === 0 || tokens[tokens.length - 1].type !== 'space') tokens.push({ type: 'space' }); + i += 1; + } else { + literal += rest[0]; + i += 1; + } + } + flush(); + return tokens; +} + +function compileSimple(text) { + let body = text.trim(); + const anchored = body.startsWith('^'); + if (anchored) body = body.slice(1).trimStart(); + if (!body) throw new PatternError('The pattern is empty.'); + + const tokens = tokenizeSimple(body); + while (tokens.length && tokens[tokens.length - 1].type === 'space') tokens.pop(); + const groups = []; + const parts = tokens.map((token, index) => { + if (token.type === 'literal') return escapeLiteral(token.value); + if (token.type === 'space') return '\\s+'; + if (token.type === 'any') return '.*?'; + const { name } = token; + if (!GROUP_NAMES.includes(name)) { + throw new PatternError(`Unknown placeholder {${name}}. Use {season}, {episode}, {episode_end}, {part} or {title}.`); + } + if (groups.includes(name)) throw new PatternError(`{${name}} appears more than once.`); + groups.push(name); + if (NUMBER_GROUPS.has(name)) return `(?P<${name}>[0-9]+)`; + return index === tokens.length - 1 ? '(?P.+)$' : '(?P<title>.+?)'; + }); + const source = `${anchored ? '^' : ''}${parts.join('')}`; + return { + compiledRegex: `(?i)${source}`, + filterRegex: `(?i:${unnameGroups(source)})`, + groups, + }; +} + +// Walk a regex outside escapes and character classes, calling visit(i) at +// each '(' that opens a group; visit returns how many characters it consumed +// (0 to copy the '(' as is). +function scanGroups(source, visit) { + let out = ''; + let inClass = false; + for (let i = 0; i < source.length;) { + const char = source[i]; + if (char === '\\') { + out += source.slice(i, i + 2); + i += 2; + continue; + } + if (inClass) { + if (char === ']') inClass = false; + out += char; + i += 1; + continue; + } + if (char === '[') { + inClass = true; + out += char; + // A ']' right after '[' or '[^' is a literal member. + const next = source.slice(i + 1, i + 3); + const literalClose = next.startsWith(']') ? 1 : next === '^]' ? 2 : 0; + out += source.slice(i + 1, i + 1 + literalClose); + i += 1 + literalClose; + continue; + } + if (char === '(') { + const result = visit(i); + if (result) { + out += result.text; + i += result.consumed; + continue; + } + } + out += char; + i += 1; + } + return out; +} + +function unnameGroups(source) { + return scanGroups(source, (i) => { + const named = /^\(\?P<([A-Za-z_][A-Za-z0-9_]*)>/.exec(source.slice(i)); + return named ? { text: '(?:', consumed: named[0].length } : null; + }); +} + +function compileRegex(text) { + const source = text.trim(); + if (!source) throw new PatternError('The pattern is empty.'); + + let flags = ''; + let rest = source; + for (let match = FLAG_GROUP.exec(rest); match; match = FLAG_GROUP.exec(rest)) { + for (const flag of match[1]) if (!flags.includes(flag)) flags += flag; + rest = rest.slice(match[0].length); + } + if (!rest.trim()) throw new PatternError('The pattern is empty.'); + + const groups = []; + scanGroups(rest, (i) => { + const tail = rest.slice(i); + if (tail.startsWith('(?P=')) { + throw new PatternError('Named backreferences such as (?P=episode) are not supported: the download filter drops group names.'); + } + if (FLAG_GROUP.test(tail)) throw new PatternError('Global flags such as (?i) must be at the start of the pattern.'); + const named = /^\(\?P<([A-Za-z_][A-Za-z0-9_]*)>/.exec(tail); + if (named) { + const name = named[1]; + if (!GROUP_NAMES.includes(name)) { + throw new PatternError(`Unknown group name "${name}". Use season, episode, episode_end, part or title.`); + } + if (groups.includes(name)) throw new PatternError(`The group "${name}" appears more than once.`); + groups.push(name); + } + return null; + }); + + return { + compiledRegex: source, + filterRegex: `(?${flags}:${unnameGroups(rest)})`, + groups, + }; +} + +/** + * @param {{text: string, kind: 'simple'|'regex'}} pattern + * @returns {{compiledRegex: string, filterRegex: string, groups: string[]}} + * @throws {PatternError} + */ +function compilePattern({ text, kind }) { + if (typeof text !== 'string') throw new PatternError('The pattern must be text.'); + if (text.length > MAX_PATTERN_LENGTH) throw new PatternError(`Patterns are limited to ${MAX_PATTERN_LENGTH} characters.`); + if (kind === PATTERN_KIND.SIMPLE) return compileSimple(text); + if (kind === PATTERN_KIND.REGEX) return compileRegex(text); + throw new PatternError('The pattern kind must be "simple" or "regex".'); +} + +/** + * One show's download filter: its patterns' filter forms as one alternation. + * @param {string[]} filterRegexes + */ +function buildShowFilter(filterRegexes) { + return filterRegexes.join('|'); +} + +/** + * An exclude term as a case-insensitive literal regex. + * @param {string} term + */ +function excludeTermRegex(term) { + const text = typeof term === 'string' ? term.trim() : ''; + if (!text) throw new PatternError('Exclude terms can\'t be empty.'); + return `(?i:${literalRegex(text)})`; +} + +/** + * A season number a video can be assigned to or a season name given for: a + * title or fixed season (0..199) or an upload year (1928..2500). Captured + * seasons stay limited to 0..199 (validateSources, titleMatcher). + * @param {*} season + * @returns {boolean} + */ +function isAssignableSeason(season) { + return Number.isInteger(season) + && ((season >= 0 && season <= MAX_SEASON) || (season >= MIN_YEAR_SEASON && season <= MAX_YEAR_SEASON)); +} + +/** + * Why a pattern's season and episode sources don't fit its placeholders, or null. + * @param {Object} params + * @param {string[]} params.groups - The pattern's placeholders + * @param {string} params.seasonSource + * @param {number|null} [params.seasonFixed] + * @param {string} params.episodeSource + * @returns {string|null} + */ +function validateSources({ groups, seasonSource, seasonFixed = null, episodeSource }) { + if (!Object.values(SEASON_SOURCE).includes(seasonSource)) return 'Choose a season source: title, fixed or year.'; + if (!Object.values(EPISODE_SOURCE).includes(episodeSource)) return 'Choose an episode source: title, date or order.'; + if (seasonSource === SEASON_SOURCE.TITLE && !groups.includes('season')) { + return 'A season taken from the title needs {season} in the pattern.'; + } + if (seasonSource === SEASON_SOURCE.FIXED + && !(Number.isInteger(seasonFixed) && seasonFixed >= 0 && seasonFixed <= MAX_SEASON)) { + return `A fixed season must be a whole number between 0 and ${MAX_SEASON}.`; + } + if (episodeSource === EPISODE_SOURCE.TITLE && !groups.includes('episode')) { + return 'An episode taken from the title needs {episode} in the pattern.'; + } + if (episodeSource === EPISODE_SOURCE.DATE && seasonSource !== SEASON_SOURCE.YEAR) { + return 'Upload-time episode numbers need year seasons: numbers from different years would collide.'; + } + return null; +} + +module.exports = { + PATTERN_KIND, + SEASON_SOURCE, + EPISODE_SOURCE, + GROUP_NAMES, + MAX_SEASON, + MIN_YEAR_SEASON, + MAX_YEAR_SEASON, + PatternError, + compilePattern, + buildShowFilter, + excludeTermRegex, + isAssignableSeason, + validateSources +}; diff --git a/server/modules/tvShows/showFolderNames.js b/server/modules/tvShows/showFolderNames.js new file mode 100644 index 000000000..d3e37b893 --- /dev/null +++ b/server/modules/tvShows/showFolderNames.js @@ -0,0 +1,51 @@ +/** + * Show folder names: what a show folder may be called and how names compare. + * Dependency-free (no config, no database), so pure modules can use it. + */ + +const { sanitizeFilenameLikeYtDlp, sanitizeNameLikeYtDlp } = require('../filesystem/sanitizer'); +const { SEASON_FOLDER_PATTERN, SUBFOLDER_PREFIX } = require('../filesystem/constants'); + +// Same cap as the channel folder in the yt-dlp output template (.80B). +const SHOW_FOLDER_MAX_BYTES = 80; + +// Folder names Jellyfin reads as extras inside a show (compared ignoring case). +const EXTRAS_FOLDER_NAMES = new Set([ + 'trailers', 'backdrops', 'theme-music', 'behind the scenes', 'deleted scenes', 'interviews', + 'scenes', 'samples', 'sample', 'shorts', 'featurettes', 'extras', 'extra', 'other', 'clips', +]); + +/** + * Why a name can't be a show folder, or null when it can. + * @param {string} name + * @returns {string|null} + */ +function showFolderNameProblem(name) { + const value = typeof name === 'string' ? name.trim() : ''; + if (!value) return 'Show folder name is empty'; + if (value.startsWith(SUBFOLDER_PREFIX)) return `Show folder name can't start with ${SUBFOLDER_PREFIX}`; + if (value.startsWith('.')) return 'Show folder name can\'t start with .'; + if (/[/\\]/.test(value)) return 'Show folder name can\'t contain a path separator'; + if (SEASON_FOLDER_PATTERN.test(value)) return 'Show folder name can\'t be a season folder name'; + if (EXTRAS_FOLDER_NAMES.has(value.toLowerCase())) return `"${value}" is a media-server extras folder name`; + return null; +} + +function sanitizeShowFolderName(name) { + const sanitized = sanitizeFilenameLikeYtDlp(String(name || ''), { maxBytes: SHOW_FOLDER_MAX_BYTES }); + return sanitized ? sanitizeNameLikeYtDlp(sanitized) : ''; +} + +// Folder names compare like the unique key's utf8mb4_unicode_ci collation: +// ignoring case and accents. +function folderNameKey(libraryFolder, folderName) { + const fold = (value) => String(value || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase(); + return `${fold(libraryFolder)}/${fold(folderName)}`; +} + +module.exports = { + SHOW_FOLDER_MAX_BYTES, + showFolderNameProblem, + sanitizeShowFolderName, + folderNameKey +}; diff --git a/server/modules/tvShows/showStore.js b/server/modules/tvShows/showStore.js index ba15b1c2b..c0929a035 100644 --- a/server/modules/tvShows/showStore.js +++ b/server/modules/tvShows/showStore.js @@ -5,39 +5,13 @@ */ const TvShow = require('../../models/tvshow'); -const { sanitizeFilenameLikeYtDlp, sanitizeNameLikeYtDlp } = require('../filesystem/sanitizer'); -const { SEASON_FOLDER_PATTERN, SUBFOLDER_PREFIX } = require('../filesystem/constants'); const { KIND_CHANNEL_SHOW } = require('./constants'); - -// Same cap as the channel folder in the yt-dlp output template (.80B). -const SHOW_FOLDER_MAX_BYTES = 80; - -// Folder names Jellyfin reads as extras inside a show (compared ignoring case). -const EXTRAS_FOLDER_NAMES = new Set([ - 'trailers', 'backdrops', 'theme-music', 'behind the scenes', 'deleted scenes', 'interviews', - 'scenes', 'samples', 'sample', 'shorts', 'featurettes', 'extras', 'extra', 'other', 'clips', -]); - -/** - * Why a name can't be a show folder, or null when it can. - * @param {string} name - * @returns {string|null} - */ -function showFolderNameProblem(name) { - const value = typeof name === 'string' ? name.trim() : ''; - if (!value) return 'Show folder name is empty'; - if (value.startsWith(SUBFOLDER_PREFIX)) return `Show folder name can't start with ${SUBFOLDER_PREFIX}`; - if (value.startsWith('.')) return 'Show folder name can\'t start with .'; - if (/[/\\]/.test(value)) return 'Show folder name can\'t contain a path separator'; - if (SEASON_FOLDER_PATTERN.test(value)) return 'Show folder name can\'t be a season folder name'; - if (EXTRAS_FOLDER_NAMES.has(value.toLowerCase())) return `"${value}" is a media-server extras folder name`; - return null; -} - -function sanitizeShowFolderName(name) { - const sanitized = sanitizeFilenameLikeYtDlp(String(name || ''), { maxBytes: SHOW_FOLDER_MAX_BYTES }); - return sanitized ? sanitizeNameLikeYtDlp(sanitized) : ''; -} +const { + SHOW_FOLDER_MAX_BYTES, + showFolderNameProblem, + sanitizeShowFolderName, + folderNameKey, +} = require('./showFolderNames'); // Folder names to try in order: the name itself, then the name with the // channel id (a second channel with the same name, or a name media servers @@ -111,13 +85,6 @@ async function relocateChannelShow(show, libraryFolder) { }); } -// Folder names compare like the unique key's utf8mb4_unicode_ci collation: -// ignoring case and accents. -function folderNameKey(libraryFolder, folderName) { - const fold = (value) => String(value || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase(); - return `${fold(libraryFolder)}/${fold(folderName)}`; -} - /** * The folder name a channel show would get in a library folder, without * writing anything: the first candidate no other show there uses and no @@ -182,6 +149,8 @@ function toLocation(show) { module.exports = { SHOW_FOLDER_MAX_BYTES, showFolderNameProblem, + sanitizeShowFolderName, + folderNameKey, findChannelShow, createChannelShow, relocateChannelShow, diff --git a/server/modules/tvShows/titleEpisodeAssigner.js b/server/modules/tvShows/titleEpisodeAssigner.js new file mode 100644 index 000000000..9a177252f --- /dev/null +++ b/server/modules/tvShows/titleEpisodeAssigner.js @@ -0,0 +1,200 @@ +/** + * The post-processor's title-show decision for a downloaded video of a + * tracked, enabled channel (routing rule step 2: a title show wins over the + * resolved folder, dialog override included). + * + * A video with a stored episode reuses it (no Python call), so re-downloads + * keep their number and file name. A video without one (an upload newer than + * the listing, a manual download) or still waiting for an upload-time number + * is classified now by its full title; classification decides, even when a + * flat listing showed another title. Duplicates, compilations and parts, + * "Not an episode" and classification errors are left to the channel layout, + * and recorded. + */ + +const { Op } = require('sequelize'); +const { VideoClassification, TvShow } = require('../../models'); +const logger = require('../../logger'); +const titleShowStore = require('./titleShowStore'); +const episodeConflicts = require('./episodeConflicts'); +const { matchVideos, MATCH_KIND } = require('./titleMatcher'); +const { ROW_STATUS, SOURCE } = require('./titleNumbering'); +const { SEASON_SOURCE, EPISODE_SOURCE } = require('./patternCompiler'); +const { releaseTime, dateEpisodeFor, allocateEpisode } = require('./dateNumbering'); +const { buildEpisodeStem } = require('./episodeNaming'); +const { KIND_TITLE_SHOW } = require('./constants'); + +function isUniqueConstraintError(err) { + return Boolean(err && err.name === 'SequelizeUniqueConstraintError'); +} + +function holdsNumber(row) { + return row.season !== null && row.season !== undefined && row.episode !== null && row.episode !== undefined; +} + +function toAssignment(row) { + return { + season: row.season, + episode: row.episode, + dateNumbered: row.source === SOURCE.DATE, + episodeTitle: row.episode_title, + fileStem: row.file_stem, + }; +} + +function videoTitleOf(info) { + return String(info.fulltitle || info.title || '').trim(); +} + +async function numbersInSeason(showId, season, youtubeId) { + return VideoClassification.findAll({ + where: { show_id: showId, season, youtube_id: { [Op.ne]: youtubeId } }, + attributes: ['youtube_id', 'season', 'episode'], + }); +} + +async function writeRow(stored, youtubeId, values) { + if (stored) { + await stored.update(values); + return values; + } + return VideoClassification.create({ youtube_id: youtubeId, ...values }); +} + +/** + * The season and episode a match gets now that the upload time is known. + * @returns {Promise<{season: number, episode: number|null, source: string, timestampSource: string|null, holder: string|null}>} + * episode null with holder set: another video holds the title number + */ +async function numberMatch({ match, pattern, show, youtubeId, info, now }) { + let season = match.season; + let release = null; + if (pattern.seasonSource === SEASON_SOURCE.YEAR || pattern.episodeSource === EPISODE_SOURCE.DATE) { + release = releaseTime(info); + if (!release) { + logger.warn({ youtubeId }, 'Title show episode has no upload time; numbering it by the download time'); + release = { epochSeconds: Math.floor(now() / 1000), source: null }; + } + } + const dated = release ? dateEpisodeFor(release.epochSeconds) : null; + if (pattern.seasonSource === SEASON_SOURCE.YEAR) season = dated.season; + + const others = await numbersInSeason(show.id, season, youtubeId); + const taken = new Set(others.map((row) => row.episode)); + if (pattern.episodeSource === EPISODE_SOURCE.TITLE) { + const holder = others.find((row) => row.episode === match.episode); + return { season, episode: holder ? null : match.episode, source: SOURCE.TITLE, timestampSource: null, holder: holder ? holder.youtube_id : null }; + } + if (pattern.episodeSource === EPISODE_SOURCE.DATE) { + return { season, episode: allocateEpisode(dated.episode, taken), source: SOURCE.DATE, timestampSource: release.source, holder: null }; + } + const marks = await titleShowStore.highWaterMarks([show.id]); + let episode = (marks.get(`title:${show.id}|${season}`) || 0) + 1; + while (taken.has(episode)) episode += 1; + return { season, episode, source: SOURCE.ORDER, timestampSource: null, holder: null }; +} + +async function placeMatch({ match, shows, stored, youtubeId, channelId, info, now }) { + const titleShow = shows.find((show) => show.key === match.showKey); + const pattern = titleShow.patterns.find((entry) => entry.key === match.patternKey); + const show = await TvShow.findByPk(titleShow.id); + const base = { channel_id: channelId, show_id: show.id, pattern_id: pattern.id, timestamp_source: null, title_opt_out: false }; + const videoTitle = videoTitleOf(info); + + if (match.kind === MATCH_KIND.UNSUPPORTED) { + await writeRow(stored, youtubeId, { + ...base, status: ROW_STATUS.UNSUPPORTED, season: null, episode: null, source: null, + episode_title: match.episodeTitle || videoTitle || null, file_stem: null, + }); + logger.info({ youtubeId, reason: match.reason }, 'Title show match is not supported yet; saving the video by the channel layout'); + return null; + } + + const numbers = await numberMatch({ match, pattern, show, youtubeId, info, now }); + if (numbers.holder) { + await writeRow(stored, youtubeId, { + ...base, status: ROW_STATUS.DUPLICATE, season: null, episode: null, source: SOURCE.TITLE, + episode_title: match.episodeTitle || videoTitle || null, file_stem: null, + }); + await episodeConflicts.recordDuplicate({ + youtubeId, channelId, showId: show.id, season: numbers.season, episode: match.episode, duplicateOf: numbers.holder, downloaded: true, + }); + logger.info({ youtubeId, duplicateOf: numbers.holder }, 'Another upload holds this episode; saving the duplicate by the channel layout'); + return null; + } + + const dateNumbered = numbers.source === SOURCE.DATE; + const row = { + ...base, + status: ROW_STATUS.ASSIGNED, + season: numbers.season, + episode: numbers.episode, + source: numbers.source, + timestamp_source: numbers.timestampSource, + episode_title: match.episodeTitle || videoTitle || null, + file_stem: buildEpisodeStem({ + season: numbers.season, episode: numbers.episode, dateNumbered, episodeTitle: match.episodeTitle, videoTitle, youtubeId, + }), + }; + await writeRow(stored, youtubeId, row); + if (numbers.source === SOURCE.ORDER) await titleShowStore.raiseHighWater(show.id, numbers.season, numbers.episode); + return { show, assignment: toAssignment(row) }; +} + +/** + * @param {Object} params + * @param {string} params.youtubeId + * @param {Object} params.info - yt-dlp info dict + * @param {string|null} params.ownerChannelId - YouTube id of the channel that owns the video + * @param {boolean} params.channelEnabled - The owner channel is tracked and enabled + * @param {() => number} [params.now] - Clock for videos with no upload time + * @returns {Promise<null|{show: Object, assignment: Object}>} null: not a title-show episode + * (the channel layout decides); show is the tv_shows row + */ +async function resolveTitlePlacement({ youtubeId, info, ownerChannelId, channelEnabled, now = Date.now }) { + if (!ownerChannelId || !channelEnabled) return null; + const channelId = ownerChannelId; + const shows = await titleShowStore.listTitleShows(channelId); + if (shows.length === 0) return null; + + let stored = await VideoClassification.findByPk(youtubeId); + if (stored && stored.channel_id !== channelId) return null; + if (stored && stored.title_opt_out) return null; + const storedShow = stored ? shows.find((show) => show.id === stored.show_id) : null; + if (storedShow) { + if (stored.status === ROW_STATUS.ASSIGNED && holdsNumber(stored) && stored.file_stem) { + return { show: await TvShow.findByPk(storedShow.id), assignment: toAssignment(stored) }; + } + if (stored.status === ROW_STATUS.DUPLICATE || stored.status === ROW_STATUS.UNSUPPORTED) return null; + } else if (stored) { + const show = await TvShow.findByPk(stored.show_id); + if (!show || show.kind !== KIND_TITLE_SHOW) return null; + } + + for (let attempt = 0; ; attempt += 1) { + let matches; + try { + matches = await matchVideos(shows, [{ youtubeId, title: videoTitleOf(info) }]); + } catch (err) { + logger.error({ err, youtubeId, channelId }, 'Could not check the title against the channel\'s title shows; saving the video by the channel layout'); + await episodeConflicts.recordError({ youtubeId, channelId, message: err.message }); + return null; + } + const match = matches.get(youtubeId); + if (!match) { + if (stored && stored.status === ROW_STATUS.PENDING) await stored.destroy(); + return null; + } + try { + return await placeMatch({ match, shows, stored, youtubeId, channelId, info, now }); + } catch (err) { + if (attempt > 0 || !isUniqueConstraintError(err)) throw err; + logger.info({ youtubeId }, 'Episode number taken meanwhile; numbering again once'); + stored = await VideoClassification.findByPk(youtubeId); + } + } +} + +module.exports = { + resolveTitlePlacement +}; diff --git a/server/modules/tvShows/titleMatcher.js b/server/modules/tvShows/titleMatcher.js new file mode 100644 index 000000000..4815c2ad1 --- /dev/null +++ b/server/modules/tvShows/titleMatcher.js @@ -0,0 +1,138 @@ +/** + * Which title show each video of a channel belongs to: every pattern of the + * channel's title shows is tried in order (shows by position, then their + * patterns) in one Python batch, and the first that matches, without one of + * its show's exclude terms matching too, wins. The captures are then read as + * season and episode numbers. + */ + +const titleFilterRegex = require('../titleFilterRegex'); +const { excludeTermRegex, SEASON_SOURCE, EPISODE_SOURCE, MAX_SEASON } = require('./patternCompiler'); + +const MAX_EPISODE = 2147483647; + +const MATCH_KIND = Object.freeze({ + // Season and episode known from the title (or a fixed season). + NUMBERED: 'numbered', + // Season known; the episode is the season's next order number. + ORDER: 'order', + // Season or episode depends on the upload time, known once downloaded. + PENDING: 'pending', + // A compilation, a part, or a number out of range or missing: not placed in v1. + UNSUPPORTED: 'unsupported', +}); + +const UNSUPPORTED_REASON = Object.freeze({ + COMPILATION: 'compilation', + PART: 'part', + OUT_OF_RANGE: 'number-out-of-range', + // An optional group (regex mode) the title skipped. + MISSING_NUMBER: 'missing-number', +}); + +// Captures are ASCII digits ([0-9]+ in simple patterns); a regex-mode group +// may still capture something else. +function toNumber(capture) { + if (capture === null || capture === undefined) return null; + const text = String(capture).trim(); + return /^[0-9]+$/.test(text) ? Number(text) : NaN; +} + +function inRange(value, min, max) { + return Number.isInteger(value) && value >= min && value <= max; +} + +/** + * Read a match's captures as numbers. + * @param {{seasonSource: string, seasonFixed?: number|null, episodeSource: string}} pattern + * @param {Object<string, string|null>} groups - The match's named groups + * @param {{uploadYear?: number|null}} [video] - The video's upload year when it is known exactly + * (a downloaded video's info.json); a year season with a title episode is numbered with it + * @returns {{kind: string, season: number|null, episode: number|null, episodeTitle: string|null, reason: string|null}} + * plus episodeEnd and part for unsupported matches + */ +function interpretMatch(pattern, groups, { uploadYear = null } = {}) { + const titleText = typeof groups.title === 'string' ? groups.title.trim() : ''; + const episodeTitle = titleText || null; + const season = pattern.seasonSource === SEASON_SOURCE.FIXED + ? pattern.seasonFixed + : pattern.seasonSource === SEASON_SOURCE.TITLE ? toNumber(groups.season) : null; + const episode = pattern.episodeSource === EPISODE_SOURCE.TITLE ? toNumber(groups.episode) : null; + const base = { season, episode, episodeTitle, reason: null }; + + const episodeEnd = toNumber(groups.episode_end); + if (episodeEnd !== null) { + return { ...base, kind: MATCH_KIND.UNSUPPORTED, reason: UNSUPPORTED_REASON.COMPILATION, episodeEnd }; + } + const part = toNumber(groups.part); + if (part !== null) { + return { ...base, kind: MATCH_KIND.UNSUPPORTED, reason: UNSUPPORTED_REASON.PART, part }; + } + const seasonFromTitle = pattern.seasonSource === SEASON_SOURCE.TITLE; + const episodeFromTitle = pattern.episodeSource === EPISODE_SOURCE.TITLE; + if ((seasonFromTitle && season === null) || (episodeFromTitle && episode === null)) { + return { ...base, kind: MATCH_KIND.UNSUPPORTED, reason: UNSUPPORTED_REASON.MISSING_NUMBER }; + } + const seasonOk = season === null || inRange(season, 0, MAX_SEASON); + const episodeOk = !episodeFromTitle || inRange(episode, 1, MAX_EPISODE); + if (!seasonOk || !episodeOk) { + return { ...base, kind: MATCH_KIND.UNSUPPORTED, reason: UNSUPPORTED_REASON.OUT_OF_RANGE }; + } + if (pattern.seasonSource === SEASON_SOURCE.YEAR && episodeFromTitle && Number.isInteger(uploadYear)) { + // The upload year is known (the video is downloaded): the number is + // decided now, as the post-processor would decide it. + return { ...base, season: uploadYear, kind: MATCH_KIND.NUMBERED }; + } + if (pattern.seasonSource === SEASON_SOURCE.YEAR || pattern.episodeSource === EPISODE_SOURCE.DATE) { + // The season (and a date episode) come from the upload time at download; + // a title episode is kept for then. + return { ...base, season: null, kind: MATCH_KIND.PENDING }; + } + if (pattern.episodeSource === EPISODE_SOURCE.ORDER) { + return { ...base, kind: MATCH_KIND.ORDER }; + } + return { ...base, kind: MATCH_KIND.NUMBERED }; +} + +/** + * Match videos against a channel's title shows. + * + * @param {Array<{key: string, excludeTerms: string[], patterns: Array<Object>}>} shows - In position order; + * each pattern { key, compiledRegex, seasonSource, seasonFixed, episodeSource } + * @param {Array<{youtubeId: string, title: string, uploadYear?: number|null}>} videos - uploadYear only when + * known exactly (a downloaded video); listing dates can be approximate + * @returns {Promise<Map<string, Object>>} By youtube id, only matched videos: + * { showKey, patternKey, ...interpretMatch } + */ +async function matchVideos(shows, videos) { + const entries = []; + for (const show of shows) { + const excludes = (show.excludeTerms || []).map(excludeTermRegex); + for (const pattern of show.patterns) entries.push({ show, pattern, excludes }); + } + const result = new Map(); + if (entries.length === 0 || videos.length === 0) return result; + + const matches = await titleFilterRegex.classifyTitles( + entries.map(({ pattern, excludes }) => ({ regex: pattern.compiledRegex, excludes })), + videos.map((video) => video.title || '') + ); + videos.forEach((video, index) => { + const match = matches[index]; + if (!match) return; + const { show, pattern } = entries[match.index]; + result.set(video.youtubeId, { + showKey: show.key, + patternKey: pattern.key, + ...interpretMatch(pattern, match.groups || {}, { uploadYear: video.uploadYear }), + }); + }); + return result; +} + +module.exports = { + MATCH_KIND, + UNSUPPORTED_REASON, + interpretMatch, + matchVideos +}; diff --git a/server/modules/tvShows/titleNumbering.js b/server/modules/tvShows/titleNumbering.js new file mode 100644 index 000000000..e7e53db9c --- /dev/null +++ b/server/modules/tvShows/titleNumbering.js @@ -0,0 +1,307 @@ +/** + * Episode numbers for a channel's title shows, decided from the title matches + * and the stored classifications (pure: no database, no Python). + * + * - A number taken from a title follows the title. The video holding it keeps + * it while it still claims it; among new claimants the earliest available + * upload wins and the others become duplicates (no number). + * - Order numbers are allocated once, past the season's high-water mark, + * oldest upload first, and kept while the video stays in that season. + * - Upload-time numbers (year seasons, date episodes) wait for the download; + * a downloaded video keeps the number it already holds in the show. + * - "Not an episode" rows and manual assignments are never touched by + * classification; overrides (the user's assignments) come first. + * - A video that no longer matches leaves its title show (its row is + * released), except order and manual numbers of a retired show, which stay + * while nothing else claims the video. + */ + +const { MATCH_KIND } = require('./titleMatcher'); +const { MIN_YEAR_SEASON } = require('./patternCompiler'); + +const ROW_STATUS = Object.freeze({ + ASSIGNED: 'assigned', + PENDING: 'pending_number', + DUPLICATE: 'duplicate', + UNSUPPORTED: 'unsupported', + OPTED_OUT: 'opted_out', + ERROR: 'error', +}); + +const SOURCE = Object.freeze({ TITLE: 'title', ORDER: 'order', DATE: 'date', MANUAL: 'manual', ADOPTED: 'adopted' }); +const DEFAULT_GAP_LIMIT = 50; + +const numberKey = (showKey, season, episode) => `${showKey}|${season}|${episode}`; +const seasonKey = (showKey, season) => `${showKey}|${season}`; + +function keepRow(row) { + return { + showKey: row.showKey, + status: row.status, + season: row.season, + episode: row.episode, + source: row.source, + patternKey: row.patternKey || null, + episodeTitle: row.episodeTitle || null, + titleOptOut: Boolean(row.titleOptOut), + keep: true, + }; +} + +function newRow(values) { + return { + season: null, episode: null, source: null, patternKey: null, episodeTitle: null, titleOptOut: false, keep: false, ...values, + }; +} + +function holdsNumber(row) { + return row && row.status === ROW_STATUS.ASSIGNED && row.season !== null && row.episode !== null; +} + +function sameNumber(a, b) { + return a.showKey === b.showKey && a.season === b.season && a.episode === b.episode; +} + +// The earliest available upload first; ids break ties so the result never +// depends on listing order. +function byUploadOrder(a, b) { + if (a.available !== b.available) return a.available ? -1 : 1; + if (a.publishedAtMs !== b.publishedAtMs) return a.publishedAtMs - b.publishedAtMs; + return a.youtubeId < b.youtubeId ? -1 : a.youtubeId > b.youtubeId ? 1 : 0; +} + +/** + * @param {Object} params + * @param {Array<{youtubeId: string, publishedAtMs: number, available: boolean, downloaded: boolean}>} params.videos + * @param {Map<string, Object>} params.matches - titleMatcher results by youtube id (active title shows only) + * @param {Map<string, Object>} params.stored - Stored rows by youtube id: + * { showKey, showKind, showActive, status, season, episode, source, titleOptOut, patternKey, episodeTitle } + * @param {Map<string, number>} params.highWater - Order high-water marks by `${showKey}|${season}` + * @param {Set<string>} [params.frozen] - Videos left exactly as stored (a listing refresh classifies only new videos) + * @param {Map<string, Object>} [params.overrides] - By youtube id: { showKey, season, episode } (manual), + * { optOut: true } ("Not an episode") or { reset: true } (back to automatic classification) + * @returns {{rows: Map<string, Object|null>, duplicates: Array<Object>, unsupported: Array<Object>, highWater: Map<string, number>}} + * rows: the row each video should have; null releases its stored row. Videos + * with neither a match nor a stored row are absent. + */ +function planNumbers({ videos, matches, stored, highWater, overrides = new Map(), frozen = new Set() }) { + const rows = new Map(); + const duplicates = []; + const unsupported = []; + const nextHighWater = new Map(highWater); + const claims = new Map(); + const toAllocate = []; + const taken = new Map(); + + const take = (row, youtubeId) => taken.set(numberKey(row.showKey, row.season, row.episode), youtubeId); + // A manual assignment takes its number from whichever video holds it. + const overridden = new Map(); + for (const [youtubeId, override] of overrides) { + if (override && !override.reset && !override.optOut) overridden.set(numberKey(override.showKey, override.season, override.episode), youtubeId); + } + const displacedBy = (row, youtubeId) => { + if (!holdsNumber(row)) return false; + const holder = overridden.get(numberKey(row.showKey, row.season, row.episode)); + return holder !== undefined && holder !== youtubeId; + }; + const duplicateOf = (youtubeId, match, holderId) => { + rows.set(youtubeId, newRow({ + showKey: match.showKey, status: ROW_STATUS.DUPLICATE, source: SOURCE.TITLE, + patternKey: match.patternKey, episodeTitle: match.episodeTitle, + })); + duplicates.push({ youtubeId, showKey: match.showKey, season: match.season, episode: match.episode, duplicateOf: holderId }); + }; + + for (const video of videos) { + const { youtubeId } = video; + if (frozen.has(youtubeId)) { + const kept = stored.get(youtubeId); + if (kept) { + rows.set(youtubeId, keepRow(kept)); + if (holdsNumber(kept)) take(kept, youtubeId); + } + continue; + } + const override = overrides.get(youtubeId); + const reset = Boolean(override && override.reset); + let row = stored.get(youtubeId) || null; + // Back to automatic classification: a manual number or "Not an episode" + // is forgotten (a channel-show episode keeps its number). + if (reset && row) row = row.showKind === 'channel' ? { ...row, titleOptOut: false } : null; + // A holder whose number an override takes is classified as if it held nothing. + const displaced = displacedBy(row, youtubeId); + if (displaced) row = null; + const match = matches.get(youtubeId) || null; + + if (override && override.optOut) { + const showKey = (match && match.showKey) || (row && row.showKey); + if (showKey) rows.set(youtubeId, newRow({ showKey, status: ROW_STATUS.OPTED_OUT, titleOptOut: true })); + continue; + } + if (override && !reset) { + const manual = newRow({ + showKey: override.showKey, status: ROW_STATUS.ASSIGNED, season: override.season, episode: override.episode, + source: SOURCE.MANUAL, episodeTitle: (match && match.episodeTitle) || (row && row.episodeTitle) || null, + titleOptOut: false, + }); + rows.set(youtubeId, manual); + take(manual, youtubeId); + continue; + } + if (row && (row.titleOptOut || (row.source === SOURCE.MANUAL && row.showActive))) { + rows.set(youtubeId, keepRow(row)); + if (holdsNumber(row)) take(row, youtubeId); + continue; + } + + if (!match) { + if (!row) { + if ((reset || displaced) && stored.has(youtubeId)) rows.set(youtubeId, null); + continue; + } + const keepsRow = row.showKind === 'channel' + || (!row.showActive && holdsNumber(row) && [SOURCE.ORDER, SOURCE.MANUAL].includes(row.source)); + if (keepsRow) { + rows.set(youtubeId, keepRow(row)); + if (holdsNumber(row)) take(row, youtubeId); + } else { + rows.set(youtubeId, null); + } + continue; + } + + const inShow = row && row.showKey === match.showKey && holdsNumber(row); + if (match.kind === MATCH_KIND.UNSUPPORTED) { + rows.set(youtubeId, newRow({ + showKey: match.showKey, status: ROW_STATUS.UNSUPPORTED, patternKey: match.patternKey, episodeTitle: match.episodeTitle, + })); + unsupported.push({ + youtubeId, showKey: match.showKey, reason: match.reason, season: match.season, episode: match.episode, + episodeEnd: match.episodeEnd || null, part: match.part || null, + }); + } else if (match.kind === MATCH_KIND.PENDING) { + // A downloaded video's verdict was decided at its download, with the + // upload time this plan lacks: a duplicate stays one. + const decidedAtDownload = video.downloaded && row && row.showKey === match.showKey && row.status === ROW_STATUS.DUPLICATE; + // A number held from an earlier pattern (a fixed season, another title + // episode) doesn't fit an upload-year season: the video waits again. + const fitsUploadTime = inShow && row.season >= MIN_YEAR_SEASON + && (match.episode === null || match.episode === undefined || row.episode === match.episode); + if (fitsUploadTime) { + rows.set(youtubeId, { ...keepRow(row), patternKey: match.patternKey, keep: row.patternKey === match.patternKey }); + take(row, youtubeId); + } else if (decidedAtDownload) { + rows.set(youtubeId, keepRow(row)); + } else { + // A title episode waits for the season its upload year gives (kept + // for the reorganize; never stored). + rows.set(youtubeId, newRow({ + showKey: match.showKey, status: ROW_STATUS.PENDING, patternKey: match.patternKey, episodeTitle: match.episodeTitle, + titleEpisode: match.episode, + })); + } + } else if (match.kind === MATCH_KIND.ORDER) { + if (inShow && row.source === SOURCE.ORDER && row.season === match.season) { + rows.set(youtubeId, { ...keepRow(row), patternKey: match.patternKey, episodeTitle: match.episodeTitle, keep: row.patternKey === match.patternKey && row.episodeTitle === match.episodeTitle }); + take(row, youtubeId); + } else { + toAllocate.push({ video, match }); + } + } else { + const key = numberKey(match.showKey, match.season, match.episode); + if (!claims.has(key)) claims.set(key, []); + claims.get(key).push({ video, match, row }); + } + } + + for (const [key, claimants] of claims) { + let winner = null; + if (!taken.has(key)) { + // The stored holder keeps its number while it can still be had. + winner = claimants.find(({ video, row, match }) => holdsNumber(row) && row.source === SOURCE.TITLE && sameNumber(row, match) + && (video.available || video.downloaded)) + || [...claimants].sort((a, b) => byUploadOrder(a.video, b.video))[0]; + } + const holderId = winner ? winner.video.youtubeId : taken.get(key); + for (const claimant of claimants) { + const { video, match, row } = claimant; + if (claimant !== winner) { + duplicateOf(video.youtubeId, match, holderId); + // A channel-show episode stays one; the conflict records the duplicate. + if (row && row.showKind === 'channel') { + rows.set(video.youtubeId, keepRow(row)); + if (holdsNumber(row)) take(row, video.youtubeId); + } + continue; + } + const unchanged = holdsNumber(row) && row.source === SOURCE.TITLE && sameNumber(row, match) + && row.patternKey === match.patternKey && (row.episodeTitle || null) === (match.episodeTitle || null); + rows.set(video.youtubeId, newRow({ + showKey: match.showKey, status: ROW_STATUS.ASSIGNED, season: match.season, episode: match.episode, + source: SOURCE.TITLE, patternKey: match.patternKey, episodeTitle: match.episodeTitle, keep: unchanged, + })); + taken.set(key, video.youtubeId); + } + } + + toAllocate.sort((a, b) => byUploadOrder({ ...a.video, available: true }, { ...b.video, available: true })); + for (const { video, match } of toAllocate) { + const season = seasonKey(match.showKey, match.season); + let episode = (nextHighWater.get(season) || 0) + 1; + while (taken.has(numberKey(match.showKey, match.season, episode))) episode += 1; + nextHighWater.set(season, episode); + const allocated = newRow({ + showKey: match.showKey, status: ROW_STATUS.ASSIGNED, season: match.season, episode, + source: SOURCE.ORDER, patternKey: match.patternKey, episodeTitle: match.episodeTitle, + }); + rows.set(video.youtubeId, allocated); + take(allocated, video.youtubeId); + } + + return { rows, duplicates, unsupported, highWater: nextHighWater }; +} + +/** + * Missing episode numbers per title- or order-numbered season (1 up to the + * highest number held). Seasons holding date-numbered episodes have none: + * they are sparse by design. + * + * @param {Array<{showKey: string, status: string, season: number, episode: number, source: string}>} rows + * @param {{limit?: number}} [options] + * @returns {Array<{showKey: string, season: number, have: number, highest: number, missing: number[], truncated: boolean}>} + */ +function computeGaps(rows, { limit = DEFAULT_GAP_LIMIT } = {}) { + const seasons = new Map(); + for (const row of rows) { + if (!holdsNumber(row)) continue; + const key = seasonKey(row.showKey, row.season); + if (!seasons.has(key)) seasons.set(key, { showKey: row.showKey, season: row.season, episodes: new Set(), dated: false }); + const entry = seasons.get(key); + entry.episodes.add(row.episode); + if (row.source === SOURCE.DATE) entry.dated = true; + } + const gaps = []; + for (const { showKey, season, episodes, dated } of seasons.values()) { + if (dated) continue; + const highest = Math.max(...episodes); + const missing = []; + let truncated = false; + for (let episode = 1; episode < highest; episode += 1) { + if (episodes.has(episode)) continue; + if (missing.length >= limit) { + truncated = true; + break; + } + missing.push(episode); + } + gaps.push({ showKey, season, have: episodes.size, highest, missing, truncated }); + } + return gaps.sort((a, b) => (a.showKey === b.showKey ? a.season - b.season : a.showKey < b.showKey ? -1 : 1)); +} + +module.exports = { + ROW_STATUS, + SOURCE, + planNumbers, + computeGaps +}; diff --git a/server/modules/tvShows/titlePlanner.js b/server/modules/tvShows/titlePlanner.js new file mode 100644 index 000000000..fcdb9fb28 --- /dev/null +++ b/server/modules/tvShows/titlePlanner.js @@ -0,0 +1,280 @@ +/** + * A channel's title shows after a change, worked out for every video of the + * channel (its listing plus its downloads): which show, season and episode + * each video gets, duplicates and unsupported matches, and which downloaded + * videos have to move (so the change goes through the reorganize). + * + * Nothing is written here. The preview, direct saves and the reorganize all + * plan through planChannel, so they agree. + */ + +const path = require('path'); +const { Op } = require('sequelize'); +const { Video, VideoClassification, TvShow } = require('../../models'); +const ChannelVideo = require('../../models/channelvideo'); +const titleShowStore = require('./titleShowStore'); +const { matchVideos } = require('./titleMatcher'); +const { planNumbers, ROW_STATUS } = require('./titleNumbering'); +const { folderNameKey } = require('./showFolderNames'); +const { KIND_TITLE_SHOW } = require('./constants'); + +const MEMBERS_ONLY = 'subscriber_only'; +const PLACEMENT_LAYOUT = 'layout'; + +function showKeyOfRow(show) { + return show.kind === KIND_TITLE_SHOW ? `title:${show.id}` : `channel:${show.channel_id}`; +} + +function dateMs(value) { + if (!value) return null; + if (/^\d{8}$/.test(String(value))) { + const text = String(value); + return Date.UTC(Number(text.slice(0, 4)), Number(text.slice(4, 6)) - 1, Number(text.slice(6, 8))); + } + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +// The UTC year of a download's upload date (yt-dlp's upload_date, YYYYMMDD): +// exact, unlike a listing date, so a year season can be decided from it. +function uploadYearOf(originalDate) { + return /^\d{8}$/.test(String(originalDate || '')) ? Number(String(originalDate).slice(0, 4)) : null; +} + +function locationKey(show) { + return folderNameKey(show.libraryFolder, show.folderName); +} + +// The reorganize moves only files under the downloads folder. +function isOutside(filePath, downloadsDir) { + if (!filePath || !downloadsDir) return false; + const relative = path.relative(path.resolve(downloadsDir), path.resolve(filePath)); + return !relative || relative.startsWith('..') || path.isAbsolute(relative); +} + +/** + * Load a channel's videos and their stored classifications. + * @param {Object} channel - channels row + * @param {Object} [options] + * @param {string|null} [options.downloadsDir] - Marks downloads whose file is outside it (`outsideDownloads`) + * @returns {Promise<{videos: Array<Object>, stored: Map<string, Object>, storedShows: Map<string, Object>}>} + */ +async function loadChannelState(channel, { downloadsDir = null } = {}) { + const channelId = channel.channel_id; + const listing = await ChannelVideo.findAll({ + where: { channel_id: channelId }, + attributes: ['youtube_id', 'title', 'publishedAt', 'youtube_removed', 'availability'], + raw: true, + }); + const listedIds = listing.map((row) => row.youtube_id); + const downloads = await Video.findAll({ + where: { [Op.or]: [{ channel_id: channelId }, ...(listedIds.length ? [{ youtubeId: listedIds }] : [])] }, + attributes: ['id', 'youtubeId', 'youTubeVideoName', 'originalDate', 'channel_id', 'filePath', 'audioFilePath', 'removed'], + raw: true, + }); + const downloadById = new Map(downloads.map((row) => [row.youtubeId, row])); + + const videos = []; + const seen = new Set(); + const addVideo = (youtubeId, { title, publishedAtMs, available }) => { + if (seen.has(youtubeId)) return; + seen.add(youtubeId); + const download = downloadById.get(youtubeId); + const filePath = download && !download.removed ? download.filePath || download.audioFilePath || null : null; + videos.push({ + youtubeId, + title: title || (download && download.youTubeVideoName) || '', + publishedAtMs: publishedAtMs !== null ? publishedAtMs : (download && dateMs(download.originalDate)) || 0, + available, + downloaded: Boolean(filePath), + filePath, + outsideDownloads: isOutside(filePath, downloadsDir), + // Known exactly only for a download (its info.json); a listing date can be approximate. + uploadYear: filePath ? uploadYearOf(download.originalDate) : null, + videoId: download ? download.id : null, + }); + }; + for (const row of listing) { + addVideo(row.youtube_id, { + title: row.title, + publishedAtMs: dateMs(row.publishedAt), + available: !row.youtube_removed && row.availability !== MEMBERS_ONLY, + }); + } + for (const row of downloads) { + if (row.channel_id === channelId) addVideo(row.youtubeId, { title: row.youTubeVideoName, publishedAtMs: null, available: true }); + } + // Episodes classified for this channel that it neither lists nor downloaded + // under its id (a VEVO or Topic upload) still hold their numbers. + // A row with no video record at all is held as stored (`orphan`). + const unlisted = (await VideoClassification.findAll({ + where: { channel_id: channelId }, attributes: ['youtube_id', 'episode_title'], raw: true, + })).filter((row) => !seen.has(row.youtube_id)); + if (unlisted.length) { + const elsewhere = await Video.findAll({ + where: { youtubeId: unlisted.map((row) => row.youtube_id) }, + attributes: ['id', 'youtubeId', 'youTubeVideoName', 'originalDate', 'channel_id', 'filePath', 'audioFilePath', 'removed'], + raw: true, + }); + for (const row of elsewhere) downloadById.set(row.youtubeId, row); + for (const row of unlisted) { + const download = downloadById.get(row.youtube_id); + addVideo(row.youtube_id, { title: download ? download.youTubeVideoName : row.episode_title, publishedAtMs: null, available: true }); + if (!download) videos[videos.length - 1].orphan = true; + } + } + + const rows = videos.length + ? await VideoClassification.findAll({ where: { youtube_id: videos.map((entry) => entry.youtubeId) } }) + : []; + const foreign = new Set(rows.filter((row) => row.channel_id !== channelId).map((row) => row.youtube_id)); + const ownRows = rows.filter((row) => row.channel_id === channelId); + + const titleShows = await titleShowStore.listTitleShows(channelId, { includeRetired: true }); + const patternKeyById = new Map(); + for (const show of titleShows) for (const pattern of show.patterns) patternKeyById.set(pattern.id, pattern.key); + const showRows = ownRows.length + ? await TvShow.findAll({ where: { id: [...new Set(ownRows.map((row) => row.show_id))] } }) + : []; + const storedShows = new Map(); + for (const show of showRows) { + storedShows.set(showKeyOfRow(show), { + key: showKeyOfRow(show), id: show.id, kind: show.kind, active: !show.retired_at, + name: show.name, folderName: show.folder_name, libraryFolder: show.library_folder || '', + }); + } + for (const show of titleShows) { + storedShows.set(show.key, { + key: show.key, id: show.id, kind: KIND_TITLE_SHOW, active: !show.retired, + name: show.name, folderName: show.folderName, libraryFolder: show.libraryFolder, + }); + } + const showKeyById = new Map([...storedShows.values()].map((show) => [show.id, show.key])); + + const stored = new Map(); + for (const row of ownRows) { + const showKey = showKeyById.get(row.show_id); + const show = storedShows.get(showKey); + if (!show) continue; + stored.set(row.youtube_id, { + showKey, + showId: row.show_id, + showKind: show.kind, + showActive: show.active, + status: row.status, + season: row.season, + episode: row.episode, + source: row.source, + titleOptOut: Boolean(row.title_opt_out), + patternKey: patternKeyById.get(row.pattern_id) || null, + patternId: row.pattern_id, + episodeTitle: row.episode_title, + fileStem: row.file_stem, + timestampSource: row.timestamp_source, + }); + } + return { videos: videos.filter((entry) => !foreign.has(entry.youtubeId)), stored, storedShows }; +} + +function beforePlacement(entry, stored, storedShows) { + const row = stored.get(entry.youtubeId); + if (!entry.downloaded || !row || row.showKind !== KIND_TITLE_SHOW || row.status !== ROW_STATUS.ASSIGNED || !row.fileStem) { + return PLACEMENT_LAYOUT; + } + const show = storedShows.get(row.showKey); + if (!show || !path.basename(entry.filePath).startsWith(row.fileStem)) return PLACEMENT_LAYOUT; + return `title|${row.showKey}|${row.season}|${row.episode}|${locationKey(show)}`; +} + +function afterPlacement(row, draftsByKey) { + if (!row || !draftsByKey.has(row.showKey)) return PLACEMENT_LAYOUT; + if (row.status === ROW_STATUS.PENDING) return `pending|${row.showKey}`; + if (row.status !== ROW_STATUS.ASSIGNED) return PLACEMENT_LAYOUT; + return `title|${row.showKey}|${row.season}|${row.episode}|${locationKey(draftsByKey.get(row.showKey))}`; +} + +/** + * Plan a channel's title shows (pure). + * + * @param {Object} params + * @param {Array<Object>} params.drafts - Normalized title shows after the change + * @param {Map<string, Object>} params.storedShows - Stored shows by key (all of the channel's title shows) + * @param {Array<Object>} params.videos - loadChannelState videos + * @param {Map<string, Object>} params.stored - Stored rows by youtube id + * @param {Map<string, Object>} params.matches - titleMatcher results against the drafts + * @param {Map<string, number>} params.highWater + * @param {Map<string, Object>} [params.overrides] + * @param {Set<string>|null} [params.onlyIds] - Classify only these videos; every other one stays as stored + * @returns {Object} { entries, duplicates, unsupported, highWater, retired, relocated, requiresReorganize, knownVideos } + * Each entry { youtubeId, title, downloaded, available, filePath, videoId, before, after, moves, staysOutside, classified }; + * only videos with a match or a stored row + */ +function buildChannelPlan({ drafts, storedShows, videos, stored, matches, highWater, overrides = new Map(), onlyIds = null }) { + const draftsByKey = new Map(drafts.map((draft) => [draft.key, draft])); + const planningRows = new Map(); + for (const [youtubeId, row] of stored) { + const active = row.showKind !== KIND_TITLE_SHOW || draftsByKey.has(row.showKey); + planningRows.set(youtubeId, { ...row, showActive: active }); + } + const frozen = new Set(videos.filter((video) => video.orphan || (onlyIds && !onlyIds.has(video.youtubeId))).map((video) => video.youtubeId)); + const numbers = planNumbers({ videos, matches, stored: planningRows, highWater, overrides, frozen }); + + const entries = []; + for (const video of videos) { + const before = stored.get(video.youtubeId) || null; + if (!before && !numbers.rows.has(video.youtubeId)) continue; + const after = numbers.rows.has(video.youtubeId) ? numbers.rows.get(video.youtubeId) : before; + const placementChanges = video.downloaded + && beforePlacement(video, stored, storedShows) !== afterPlacement(after, draftsByKey); + // A file outside the downloads folder stays where it is: its row follows + // the change, and no review is needed for it. + const staysOutside = placementChanges && Boolean(video.outsideDownloads); + const moves = placementChanges && !staysOutside; + entries.push({ ...video, before, after, moves, staysOutside, classified: !frozen.has(video.youtubeId) }); + } + + const retired = [...storedShows.values()] + .filter((show) => show.kind === KIND_TITLE_SHOW && show.active && !draftsByKey.has(show.key)) + .map((show) => show.key); + const relocated = drafts + .filter((draft) => storedShows.has(draft.key) && locationKey(storedShows.get(draft.key)) !== locationKey(draft)) + .map((draft) => draft.key); + + return { + entries, + duplicates: numbers.duplicates, + unsupported: numbers.unsupported, + highWater: numbers.highWater, + retired, + relocated, + requiresReorganize: entries.some((entry) => entry.moves), + knownVideos: videos.length, + }; +} + +/** + * Plan a channel's title shows after a change. + * + * @param {Object} params + * @param {Object} params.channel - channels row + * @param {Array<Object>} params.drafts - Normalized title shows after the change + * @param {Map<string, Object>} [params.overrides] - Manual assignments and opt-outs by youtube id + * @param {Set<string>|null} [params.onlyIds] - Classify only these videos (new listing rows) + * @param {string|null} [params.downloadsDir] - The downloads folder (files outside it never move) + * @returns {Promise<Object>} buildChannelPlan's result plus { drafts, storedShows, stored, videos } + */ +async function planChannel({ channel, drafts, overrides = new Map(), onlyIds = null, downloadsDir = null }) { + const state = await loadChannelState(channel, { downloadsDir }); + const toMatch = state.videos.filter((video) => !video.orphan && (!onlyIds || onlyIds.has(video.youtubeId))); + const matches = await matchVideos(drafts, toMatch); + const existingIds = [...state.storedShows.values()].filter((show) => show.kind === KIND_TITLE_SHOW).map((show) => show.id); + const highWater = await titleShowStore.highWaterMarks(existingIds); + const plan = buildChannelPlan({ drafts, ...state, matches, highWater, overrides, onlyIds }); + return { ...plan, drafts, storedShows: state.storedShows, stored: state.stored, videos: state.videos }; +} + +module.exports = { + loadChannelState, + buildChannelPlan, + planChannel +}; diff --git a/server/modules/tvShows/titlePreview.js b/server/modules/tvShows/titlePreview.js new file mode 100644 index 000000000..d24645d5f --- /dev/null +++ b/server/modules/tvShows/titlePreview.js @@ -0,0 +1,139 @@ +/** + * The title show preview (pure): a titlePlanner plan shaped for the editor's + * tabs - each show's episodes with their download state, duplicates with the + * upload that keeps the number, gaps per season, compilations and parts not + * placed yet, the videos no show takes, and stored episodes that would change. + */ + +const { episodeCode } = require('./episodeNaming'); +const { computeGaps, ROW_STATUS, SOURCE } = require('./titleNumbering'); + +const DEFAULT_LIST_LIMIT = 500; +const DOWNLOAD_STATE = Object.freeze({ DOWNLOADED: 'downloaded', QUEUED: 'queued', NOT_DOWNLOADED: 'not_downloaded' }); + +function codeOf(row) { + if (!row || row.season === null || row.season === undefined || row.episode === null || row.episode === undefined) return null; + return episodeCode({ season: row.season, episode: row.episode, dateNumbered: row.source === SOURCE.DATE }); +} + +function sameOutcome(a, b) { + if (!a || !b) return a === b; + return a.showKey === b.showKey && a.status === b.status && a.season === b.season && a.episode === b.episode; +} + +/** + * @param {Object} plan - titlePlanner.planChannel's result + * @param {Object} options + * @param {Array<Object>} options.videos - Every known video of the channel (loadChannelState) + * @param {(youtubeId: string) => boolean} [options.isQueued] + * @param {number} [options.listLimit] - Longest list per tab; counts stay complete + */ +function summarizePlan(plan, { videos, isQueued = () => false, listLimit = DEFAULT_LIST_LIMIT }) { + const titles = new Map(videos.map((video) => [video.youtubeId, video.title])); + const downloaded = new Set(videos.filter((video) => video.downloaded).map((video) => video.youtubeId)); + const draftsByKey = new Map(plan.drafts.map((draft) => [draft.key, draft])); + const showName = (key) => (draftsByKey.get(key) || plan.storedShows.get(key) || {}).name || null; + const describe = (row, names) => (row ? { showKey: row.showKey, showName: names(row.showKey), code: codeOf(row), status: row.status } : null); + + const shows = plan.drafts.map((draft) => ({ + key: draft.key, id: draft.id, name: draft.name, folderName: draft.folderName, libraryFolder: draft.libraryFolder, + counts: { episodes: 0, downloaded: 0, pending: 0, duplicates: 0, unsupported: 0 }, + episodes: [], + })); + const showsByKey = new Map(shows.map((show) => [show.key, show])); + // Videos a show matched (episodes, duplicates, compilations and parts), so not unmatched. + const matched = new Set(); + const countedDuplicates = new Set(); + const afterRows = []; + + for (const entry of plan.entries) { + const { after } = entry; + const show = after ? showsByKey.get(after.showKey) : null; + if (!show) continue; + if (after.status === ROW_STATUS.DUPLICATE || after.status === ROW_STATUS.UNSUPPORTED) matched.add(entry.youtubeId); + if (after.status === ROW_STATUS.DUPLICATE) { + show.counts.duplicates += 1; + countedDuplicates.add(entry.youtubeId); + } + if (after.status === ROW_STATUS.UNSUPPORTED) show.counts.unsupported += 1; + if (after.status !== ROW_STATUS.ASSIGNED && after.status !== ROW_STATUS.PENDING) continue; + matched.add(entry.youtubeId); + afterRows.push(after); + show.counts.episodes += 1; + if (after.status === ROW_STATUS.PENDING) show.counts.pending += 1; + if (entry.downloaded) show.counts.downloaded += 1; + const draft = draftsByKey.get(after.showKey); + show.episodes.push({ + youtubeId: entry.youtubeId, + title: entry.title, + season: after.season, + episode: after.episode, + code: codeOf(after), + status: after.status, + episodeTitle: after.episodeTitle, + patternIndex: draft.patterns.findIndex((pattern) => pattern.key === after.patternKey), + downloadState: entry.downloaded + ? DOWNLOAD_STATE.DOWNLOADED + : isQueued(entry.youtubeId) ? DOWNLOAD_STATE.QUEUED : DOWNLOAD_STATE.NOT_DOWNLOADED, + }); + } + for (const show of shows) { + show.episodes.sort((a, b) => ((a.season ?? Infinity) - (b.season ?? Infinity)) || ((a.episode ?? Infinity) - (b.episode ?? Infinity)) + || (a.youtubeId < b.youtubeId ? -1 : 1)); + show.truncated = show.episodes.length > listLimit; + show.episodes = show.episodes.slice(0, listLimit); + } + + // A channel-show episode that loses a title claim keeps its channel-show + // row, so only the plan's duplicates list names it. + for (const duplicate of plan.duplicates) { + const show = showsByKey.get(duplicate.showKey); + if (!show || countedDuplicates.has(duplicate.youtubeId)) continue; + matched.add(duplicate.youtubeId); + show.counts.duplicates += 1; + } + + const unmatched = videos.filter((video) => !matched.has(video.youtubeId)); + const changes = plan.entries + .filter((entry) => entry.moves || (entry.before && !sameOutcome(entry.before, entry.after))) + .map((entry) => ({ + youtubeId: entry.youtubeId, + title: entry.title, + downloaded: entry.downloaded, + from: describe(entry.before, (key) => (plan.storedShows.get(key) || {}).name || null), + to: describe(entry.after && draftsByKey.has(entry.after.showKey) ? entry.after : null, showName), + })); + + return { + knownVideos: plan.knownVideos, + shows, + duplicates: plan.duplicates.slice(0, listLimit).map((duplicate) => ({ + ...duplicate, + title: titles.get(duplicate.youtubeId) || null, + showName: showName(duplicate.showKey), + code: codeOf({ ...duplicate, source: SOURCE.TITLE }), + duplicateOfTitle: titles.get(duplicate.duplicateOf) || null, + downloaded: downloaded.has(duplicate.youtubeId), + })), + gaps: computeGaps(afterRows), + unsupported: plan.unsupported.slice(0, listLimit).map((entry) => ({ + ...entry, title: titles.get(entry.youtubeId) || null, showName: showName(entry.showKey), + })), + unmatched: { + count: unmatched.length, + videos: unmatched.slice(0, listLimit).map((video) => ({ youtubeId: video.youtubeId, title: video.title, downloaded: video.downloaded })), + }, + changes: changes.slice(0, listLimit), + changeCount: changes.length, + // Downloads outside the downloads folder whose files stay where they are. + staysOutside: plan.entries.filter((entry) => entry.staysOutside).length, + filesToMove: plan.entries.filter((entry) => entry.moves).length, + retired: plan.retired.map((key) => ({ key, name: showName(key) })), + relocated: plan.relocated.map((key) => ({ key, name: showName(key) })), + }; +} + +module.exports = { + DOWNLOAD_STATE, + summarizePlan +}; diff --git a/server/modules/tvShows/titleRowWriter.js b/server/modules/tvShows/titleRowWriter.js new file mode 100644 index 000000000..ecb62c295 --- /dev/null +++ b/server/modules/tvShows/titleRowWriter.js @@ -0,0 +1,227 @@ +/** + * Writes a titlePlanner plan: the channel's show definitions, every changed + * video_classifications row, duplicate conflicts and their releases, and the + * order high-water marks. One transaction (the caller's), so the unique + * episode key never sees half a renumbering: rows that change give up their + * numbers first, then take their new ones. + * + * Archive writes for duplicates are only queued here (archiveSuppressor); + * the caller flushes them after the commit. + */ + +const { VideoClassification } = require('../../models'); +const titleShowStore = require('./titleShowStore'); +const episodeConflicts = require('./episodeConflicts'); +const { buildEpisodeStem } = require('./episodeNaming'); +const { ROW_STATUS, SOURCE } = require('./titleNumbering'); + +function holdsNumber(row) { + return Boolean(row) && row.season !== null && row.season !== undefined && row.episode !== null && row.episode !== undefined; +} + +function sameRow(before, after) { + return before.showKey === after.showKey && before.status === after.status && before.season === after.season + && before.episode === after.episode && before.source === after.source + && Boolean(before.titleOptOut) === Boolean(after.titleOptOut); +} + +function isTitleShowKey(key) { + return key.startsWith('title:') || key.startsWith('new:'); +} + +function storedOrNull(value) { + return value === undefined ? null : value; +} + +// The columns a plan entry's row should have. +function rowValues(entry, { channelId, patternId, showIdOf }) { + const { youtubeId, before, after } = entry; + const assigned = after.status === ROW_STATUS.ASSIGNED && holdsNumber(after); + const sameNumber = assigned && before && before.showKey === after.showKey + && before.season === after.season && before.episode === after.episode; + return { + channel_id: channelId, + show_id: showIdOf(after.showKey, before ? before.showId : null), + status: after.status, + season: assigned ? after.season : null, + episode: assigned ? after.episode : null, + source: after.source || null, + // A number that stays keeps its time source and its stem. + timestamp_source: sameNumber ? storedOrNull(before.timestampSource) : null, + pattern_id: patternId, + episode_title: after.episodeTitle || entry.title || null, + file_stem: sameNumber && before.fileStem + ? before.fileStem + : assigned + ? buildEpisodeStem({ + season: after.season, episode: after.episode, dateNumbered: after.source === SOURCE.DATE, + episodeTitle: after.episodeTitle, videoTitle: entry.title, youtubeId, + }) + : null, + title_opt_out: Boolean(after.titleOptOut), + }; +} + +const STORED_FIELDS = { + show_id: 'showId', + status: 'status', + season: 'season', + episode: 'episode', + source: 'source', + timestamp_source: 'timestampSource', + pattern_id: 'patternId', + episode_title: 'episodeTitle', + file_stem: 'fileStem', +}; + +// The columns whose planned value differs from the stored row. +function changedColumns(before, values) { + const changed = Object.entries(STORED_FIELDS) + .filter(([column, field]) => storedOrNull(before[field]) !== values[column]) + .map(([column]) => column); + if (Boolean(before.titleOptOut) !== values.title_opt_out) changed.push('title_opt_out'); + return changed; +} + +const ROW_CHANGED = 'ROW_CHANGED'; + +/** Another writer (a download's post-processor) changed a row after it was planned. */ +function rowChangedError(youtubeId) { + const err = new Error(`The episode row of ${youtubeId} changed while the shows were saved`); + err.code = ROW_CHANGED; + return err; +} + +function isRowChangedError(err) { + return Boolean(err) && err.code === ROW_CHANGED; +} + +// The row only while it is still as planned. A row whose number this save +// freed is matched without it. +function unchangedSincePlan(youtubeId, before, numberFreed) { + const where = { youtube_id: youtubeId, status: before.status, show_id: before.showId }; + if (!numberFreed) { + where.season = before.season === undefined ? null : before.season; + where.episode = before.episode === undefined ? null : before.episode; + } + return where; +} + +/** + * @param {Object} params + * @param {Object} params.channel - channels row + * @param {Array<Object>} params.drafts - Normalized title shows + * @param {Object} params.plan - titlePlanner plan for those drafts + * @param {Map<string, number>} params.highWaterBefore - The marks the plan started from + * @param {Object} params.transaction + * @param {{showIds: Map<string, number>, patternIds: Map<string, number>}} [params.definitions] - The stored + * shows' ids when the definitions are unchanged (a listing refresh); otherwise the drafts are saved + * @returns {Promise<{showIds: Map<string, number>, patternIds: Map<string, number>}>} + */ +async function applyPlan({ channel, drafts, plan, highWaterBefore, transaction, definitions = null }) { + const channelId = channel.channel_id; + const { showIds, patternIds } = definitions || await titleShowStore.saveDefinitions({ channelId, drafts, transaction }); + const showIdOf = (key, fallback = null) => (key.startsWith('channel:') ? fallback : showIds.get(key) || null); + + const patternIdOf = (row) => (row.patternKey ? patternIds.get(row.patternKey) || null : null); + const writes = []; + // Rows whose only change is their pattern's id (patterns are replaced on + // save), by new pattern id. + const repointed = new Map(); + const repoint = (patternId, youtubeId) => { + if (!repointed.has(patternId)) repointed.set(patternId, []); + repointed.get(patternId).push(youtubeId); + }; + for (const entry of plan.entries) { + const { before, after } = entry; + if (!after) { + if (before) writes.push({ entry, destroy: true }); + continue; + } + if (after.keep && before && sameRow(before, after)) { + const patternId = patternIdOf(after); + if (isTitleShowKey(after.showKey) && patternId !== storedOrNull(before.patternId)) repoint(patternId, entry.youtubeId); + continue; + } + const values = rowValues(entry, { channelId, patternId: patternIdOf(after), showIdOf }); + const changed = before ? changedColumns(before, values) : null; + if (changed && changed.length === 0) continue; + if (changed && changed.length === 1 && changed[0] === 'pattern_id') { + repoint(values.pattern_id, entry.youtubeId); + continue; + } + writes.push({ entry, values }); + } + + for (const [patternId, youtubeIds] of repointed) { + await VideoClassification.update({ pattern_id: patternId }, { where: { youtube_id: youtubeIds }, transaction }); + } + + const renumbered = writes.filter(({ entry }) => holdsNumber(entry.before)).map(({ entry }) => entry.youtubeId); + if (renumbered.length) { + await VideoClassification.update({ season: null, episode: null }, { where: { youtube_id: renumbered }, transaction }); + } + const freed = new Set(renumbered); + + for (const { entry, destroy, values } of writes) { + const { youtubeId, before } = entry; + if (destroy) { + const removed = await VideoClassification.destroy({ where: unchangedSincePlan(youtubeId, before, freed.has(youtubeId)), transaction }); + if (!removed) throw rowChangedError(youtubeId); + continue; + } + if (!before) { + // A row created meanwhile makes this fail on the primary key. + await VideoClassification.create({ youtube_id: youtubeId, ...values }, { transaction }); + continue; + } + // Only rows with a changed column are written, so zero affected rows + // means the row no longer matches (MySQL counts changed rows, not matched ones). + const [updated] = await VideoClassification.update(values, { + where: unchangedSincePlan(youtubeId, before, freed.has(youtubeId)), transaction, + }); + if (!updated) throw rowChangedError(youtubeId); + } + + const downloaded = new Set(plan.entries.filter((entry) => entry.downloaded).map((entry) => entry.youtubeId)); + for (const duplicate of plan.duplicates) { + await episodeConflicts.recordDuplicate({ + youtubeId: duplicate.youtubeId, + channelId, + showId: showIdOf(duplicate.showKey), + season: duplicate.season, + episode: duplicate.episode, + duplicateOf: duplicate.duplicateOf, + downloaded: downloaded.has(duplicate.youtubeId), + transaction, + }); + } + // A classified video that is no longer a duplicate gives its conflict up, + // whatever its row says (a TV channel's duplicate keeps its channel-show row). + // A duplicate row the plan kept (decided at the video's download) stays one. + const stillDuplicate = new Set([ + ...plan.duplicates.map((duplicate) => duplicate.youtubeId), + ...plan.entries.filter(({ after }) => after && after.status === ROW_STATUS.DUPLICATE).map(({ youtubeId }) => youtubeId), + ]); + const conflicted = await episodeConflicts.duplicateIdsForChannel(channelId, { transaction }); + for (const { youtubeId, classified } of plan.entries) { + if (classified !== false && conflicted.has(youtubeId) && !stillDuplicate.has(youtubeId)) { + await episodeConflicts.release(youtubeId, { transaction }); + } + } + + await episodeConflicts.clearErrorsForChannel(channelId, { transaction }); + + for (const [key, value] of plan.highWater) { + if (value <= (highWaterBefore.get(key) || 0)) continue; + const separator = key.lastIndexOf('|'); + const showId = showIdOf(key.slice(0, separator)); + if (showId) await titleShowStore.raiseHighWater(showId, Number(key.slice(separator + 1)), value, { transaction }); + } + return { showIds, patternIds }; +} + +module.exports = { + applyPlan, + isRowChangedError +}; diff --git a/server/modules/tvShows/titleShowDrafts.js b/server/modules/tvShows/titleShowDrafts.js new file mode 100644 index 000000000..ffd8d9726 --- /dev/null +++ b/server/modules/tvShows/titleShowDrafts.js @@ -0,0 +1,217 @@ +/** + * The title shows a save sends: validated, normalized and compiled, before + * anything is written or classified. A channel's drafts are always its whole + * set of active title shows, in order (the first matching show wins). + * + * Draft (from the API): + * { id?, name, folderName?, libraryFolder?, excludeTerms?, seasonNames?, + * patterns: [{ text, kind, seasonSource, seasonFixed?, episodeSource }] } + * Normalized: the same with key ('title:<id>' or 'new:<index>'), position, + * folder defaults applied, and each pattern compiled (key '<show key>#<index>'). + */ + +const titleFilterRegex = require('../titleFilterRegex'); +const { LAYOUT_TV, folderKey } = require('./constants'); +const { showFolderNameProblem, sanitizeShowFolderName, folderNameKey } = require('./showFolderNames'); +const { + compilePattern, + validateSources, + excludeTermRegex, + buildShowFilter, + isAssignableSeason, + MAX_SEASON, + MIN_YEAR_SEASON, + MAX_YEAR_SEASON, + PatternError, +} = require('./patternCompiler'); + +const MAX_SHOWS_PER_CHANNEL = 50; +const MAX_PATTERNS_PER_SHOW = 20; +const MAX_EXCLUDE_TERMS = 20; +const MAX_EXCLUDE_TERM_LENGTH = 100; +const MAX_NAME_LENGTH = 255; + +function invalid(message) { + return new PatternError(message); +} + +function folderLabel(folder) { + return folder ? `__${folder}` : 'The main folder'; +} + +function showKeyOf(raw, index) { + if (raw.id === undefined || raw.id === null) return `new:${index}`; + if (!Number.isInteger(raw.id) || raw.id <= 0) throw invalid('A show id must be a positive whole number.'); + return `title:${raw.id}`; +} + +function normalizeName(raw) { + const name = typeof raw.name === 'string' ? raw.name.trim() : ''; + if (!name) throw invalid('Every show needs a name.'); + if (name.length > MAX_NAME_LENGTH) throw invalid(`Show names are limited to ${MAX_NAME_LENGTH} characters.`); + return name; +} + +function normalizeFolderName(raw, name) { + const wanted = typeof raw.folderName === 'string' && raw.folderName.trim() ? raw.folderName.trim() : name; + const folderName = sanitizeShowFolderName(wanted); + const problem = showFolderNameProblem(folderName); + if (problem) throw invalid(`${name}: ${problem}.`); + return folderName; +} + +function normalizeLibraryFolder(raw, { layoutOf, defaultLibraryFolder, tvFolders = [] }) { + if (raw.libraryFolder === undefined || raw.libraryFolder === null) { + if (defaultLibraryFolder === null || defaultLibraryFolder === undefined) { + throw invalid('Choose a TV folder for this show.'); + } + return defaultLibraryFolder; + } + if (typeof raw.libraryFolder !== 'string') throw invalid('libraryFolder must be a folder name ("" for the main folder).'); + const folder = raw.libraryFolder.trim(); + if (layoutOf(folder) !== LAYOUT_TV) throw invalid(`${folderLabel(folder)} is not a TV folder.`); + // Folder names compare without case; the path uses the registered name. + return tvFolders.find((name) => folderKey(name) === folderKey(folder)) ?? folder; +} + +function normalizeExcludeTerms(raw) { + if (raw.excludeTerms === undefined || raw.excludeTerms === null) return []; + if (!Array.isArray(raw.excludeTerms)) throw invalid('excludeTerms must be a list.'); + const seen = new Set(); + const terms = []; + for (const entry of raw.excludeTerms) { + if (typeof entry !== 'string') throw invalid('Exclude terms must be text.'); + const term = entry.trim(); + if (!term || seen.has(term.toLowerCase())) continue; + if (term.length > MAX_EXCLUDE_TERM_LENGTH) throw invalid(`Exclude terms are limited to ${MAX_EXCLUDE_TERM_LENGTH} characters.`); + seen.add(term.toLowerCase()); + terms.push(term); + } + if (terms.length > MAX_EXCLUDE_TERMS) throw invalid(`A show can have at most ${MAX_EXCLUDE_TERMS} exclude terms.`); + return terms; +} + +function normalizeSeasonNames(raw) { + if (raw.seasonNames === undefined || raw.seasonNames === null) return {}; + if (typeof raw.seasonNames !== 'object' || Array.isArray(raw.seasonNames)) throw invalid('seasonNames must map season numbers to names.'); + const names = {}; + for (const [key, value] of Object.entries(raw.seasonNames)) { + const season = Number(key); + if (!isAssignableSeason(season)) { + throw invalid(`Season numbers must be 0 to ${MAX_SEASON}, or an upload year from ${MIN_YEAR_SEASON} to ${MAX_YEAR_SEASON}.`); + } + if (value !== null && typeof value !== 'string') throw invalid('Season names must be text.'); + const name = (value || '').trim(); + if (!name) continue; + if (name.length > MAX_NAME_LENGTH) throw invalid(`Season names are limited to ${MAX_NAME_LENGTH} characters.`); + names[season] = name; + } + return names; +} + +function normalizePattern(raw, showName, showKey, index) { + if (!raw || typeof raw !== 'object') throw invalid(`${showName}, pattern ${index + 1}: invalid pattern.`); + const seasonFixed = raw.seasonFixed === undefined || raw.seasonFixed === null ? null : Number(raw.seasonFixed); + try { + const compiled = compilePattern({ text: raw.text, kind: raw.kind }); + const problem = validateSources({ + groups: compiled.groups, seasonSource: raw.seasonSource, seasonFixed, episodeSource: raw.episodeSource, + }); + if (problem) throw invalid(problem); + return { + key: `${showKey}#${index}`, + position: index, + text: raw.text.trim(), + kind: raw.kind, + compiledRegex: compiled.compiledRegex, + filterRegex: compiled.filterRegex, + groups: compiled.groups, + seasonSource: raw.seasonSource, + seasonFixed: raw.seasonSource === 'fixed' ? seasonFixed : null, + episodeSource: raw.episodeSource, + }; + } catch (err) { + if (err instanceof PatternError) throw invalid(`${showName}, pattern ${index + 1}: ${err.message}`); + throw err; + } +} + +/** + * @param {Array<Object>} rawDrafts - The channel's title shows after the change, in order + * @param {Object} context + * @param {(libraryFolder: string) => string} context.layoutOf + * @param {string|null} context.defaultLibraryFolder - For drafts without one; null when the user must choose + * @param {string[]} [context.tvFolders] - The registered TV folders, for their names' case + * @returns {Array<Object>} Normalized drafts + * @throws {PatternError} (status 400) + */ +function normalizeDrafts(rawDrafts, context) { + if (!Array.isArray(rawDrafts)) throw invalid('shows must be a list.'); + if (rawDrafts.length > MAX_SHOWS_PER_CHANNEL) throw invalid(`A channel can have at most ${MAX_SHOWS_PER_CHANNEL} shows.`); + const locations = new Set(); + return rawDrafts.map((raw, index) => { + if (!raw || typeof raw !== 'object') throw invalid('Each show must be an object.'); + const key = showKeyOf(raw, index); + const name = normalizeName(raw); + const folderName = normalizeFolderName(raw, name); + const libraryFolder = normalizeLibraryFolder(raw, context); + const location = folderNameKey(libraryFolder, folderName); + if (locations.has(location)) throw invalid(`Two shows can't use the same folder: "${folderName}".`); + locations.add(location); + + if (!Array.isArray(raw.patterns) || raw.patterns.length === 0) throw invalid(`${name} needs at least one pattern.`); + if (raw.patterns.length > MAX_PATTERNS_PER_SHOW) throw invalid(`A show can have at most ${MAX_PATTERNS_PER_SHOW} patterns.`); + return { + id: key.startsWith('title:') ? raw.id : null, + key, + position: index, + name, + folderName, + libraryFolder, + excludeTerms: normalizeExcludeTerms(raw), + seasonNames: normalizeSeasonNames(raw), + patterns: raw.patterns.map((pattern, patternIndex) => normalizePattern(pattern, name, key, patternIndex)), + }; + }); +} + +/** + * The library folder a new title show gets when none is chosen: the + * channel's folder when it is a TV folder, else the default subfolder when it + * is one, else the only TV folder; null when the user must choose. + */ +function defaultLibraryFolder({ channelFolder, defaultFolder, tvFolders, layoutOf }) { + if (layoutOf(channelFolder || '') === LAYOUT_TV) return channelFolder || ''; + if (layoutOf(defaultFolder || '') === LAYOUT_TV) return defaultFolder || ''; + return tvFolders.length === 1 ? tvFolders[0] : null; +} + +/** + * Compile every pattern, filter form and exclude term of the drafts in Python + * (yt-dlp doesn't catch a bad regex in a match filter: it would end the run). + * @throws {PatternError} + */ +async function assertDraftsCompile(drafts) { + const checks = []; + for (const show of drafts) { + show.patterns.forEach((pattern, index) => { + const label = `${show.name}, pattern ${index + 1}`; + checks.push({ label, regex: pattern.compiledRegex }, { label, regex: pattern.filterRegex }); + }); + if (show.patterns.length > 1) { + checks.push({ label: `${show.name}, download filter`, regex: buildShowFilter(show.patterns.map((p) => p.filterRegex)) }); + } + for (const term of show.excludeTerms) checks.push({ label: `${show.name}, exclude term "${term}"`, regex: excludeTermRegex(term) }); + } + const errors = await titleFilterRegex.checkPatterns(checks.map((check) => check.regex)); + const failed = errors.findIndex((error) => error); + if (failed >= 0) throw invalid(`${checks[failed].label}: ${errors[failed]}`); +} + +module.exports = { + MAX_SHOWS_PER_CHANNEL, + MAX_PATTERNS_PER_SHOW, + normalizeDrafts, + defaultLibraryFolder, + assertDraftsCompile +}; diff --git a/server/modules/tvShows/titleShowQueries.js b/server/modules/tvShows/titleShowQueries.js new file mode 100644 index 000000000..69e49e95f --- /dev/null +++ b/server/modules/tvShows/titleShowQueries.js @@ -0,0 +1,202 @@ +/** + * Read-only queries about title shows for the UI: per-show counts, how many + * shows each channel has, a show's missing episodes, the videos of a show + * (the channel page's show filter), and the planned episode of videos not + * downloaded yet (their episode chip). + */ + +const { TvShow, VideoClassification, Video, TvShowSeason } = require('../../models'); +const ChannelVideo = require('../../models/channelvideo'); +const { KIND_TITLE_SHOW } = require('./constants'); +const { episodeCode } = require('./episodeNaming'); +const { computeGaps, ROW_STATUS, SOURCE } = require('./titleNumbering'); + +const EPISODE_STATUSES = [ROW_STATUS.ASSIGNED, ROW_STATUS.PENDING]; + +async function presentDownloads(youtubeIds) { + if (youtubeIds.length === 0) return new Set(); + const rows = await Video.findAll({ where: { youtubeId: youtubeIds, removed: false }, attributes: ['youtubeId'], raw: true }); + return new Set(rows.map((row) => row.youtubeId)); +} + +function codeOf(row) { + return episodeCode({ season: row.season, episode: row.episode, dateNumbered: row.source === SOURCE.DATE }); +} + +/** + * @param {number[]} showIds + * @returns {Promise<Map<number, {episodes: number, downloaded: number, duplicates: number, unsupported: number}>>} + */ +async function countsByShow(showIds) { + const counts = new Map(showIds.map((id) => [id, { episodes: 0, downloaded: 0, duplicates: 0, unsupported: 0 }])); + if (showIds.length === 0) return counts; + const rows = await VideoClassification.findAll({ where: { show_id: showIds }, attributes: ['youtube_id', 'show_id', 'status'], raw: true }); + const episodes = rows.filter((row) => EPISODE_STATUSES.includes(row.status)); + const downloaded = await presentDownloads(episodes.map((row) => row.youtube_id)); + for (const row of rows) { + const count = counts.get(row.show_id); + if (!count) continue; + if (EPISODE_STATUSES.includes(row.status)) { + count.episodes += 1; + if (downloaded.has(row.youtube_id)) count.downloaded += 1; + } else if (row.status === ROW_STATUS.DUPLICATE) { + count.duplicates += 1; + } else if (row.status === ROW_STATUS.UNSUPPORTED) { + count.unsupported += 1; + } + } + return counts; +} + +/** + * @param {string[]} channelIds + * @returns {Promise<Map<string, number>>} Active title shows per channel (channels without any are absent) + */ +async function countActiveByChannel(channelIds) { + const counts = new Map(); + if (channelIds.length === 0) return counts; + const rows = await TvShow.findAll({ + where: { channel_id: channelIds, kind: KIND_TITLE_SHOW, retired_at: null }, attributes: ['channel_id'], raw: true, + }); + for (const row of rows) counts.set(row.channel_id, (counts.get(row.channel_id) || 0) + 1); + return counts; +} + +/** + * A title show's episodes that aren't downloaded, and the numbers missing + * from its title- and order-numbered seasons. + * @returns {Promise<Object|null>} null when the channel has no such show + */ +async function missingEpisodes(channelId, showId) { + const show = await TvShow.findOne({ where: { id: showId, channel_id: channelId, kind: KIND_TITLE_SHOW } }); + if (!show) return null; + const rows = await VideoClassification.findAll({ + where: { show_id: showId, status: ROW_STATUS.ASSIGNED }, + attributes: ['youtube_id', 'season', 'episode', 'source'], + raw: true, + }); + const ids = rows.map((row) => row.youtube_id); + const downloaded = await presentDownloads(ids); + const titleRows = ids.length + ? await ChannelVideo.findAll({ where: { channel_id: channelId, youtube_id: ids }, attributes: ['youtube_id', 'title'], raw: true }) + : []; + const titles = new Map(titleRows.map((row) => [row.youtube_id, row.title])); + const seasonRows = await TvShowSeason.findAll({ where: { show_id: showId }, attributes: ['season', 'name'], raw: true }); + const seasonNames = new Map(seasonRows.map((row) => [row.season, row.name])); + const gaps = new Map(computeGaps(rows.map((row) => ({ ...row, showKey: 'show', status: ROW_STATUS.ASSIGNED }))) + .map((gap) => [gap.season, gap])); + + const seasons = new Map(); + for (const row of [...rows].sort((a, b) => a.season - b.season || a.episode - b.episode)) { + if (!seasons.has(row.season)) { + const gap = gaps.get(row.season); + seasons.set(row.season, { + season: row.season, + name: seasonNames.get(row.season) || null, + episodes: 0, + downloaded: 0, + notDownloaded: [], + gaps: gap ? gap.missing : [], + gapsTruncated: gap ? gap.truncated : false, + }); + } + const season = seasons.get(row.season); + season.episodes += 1; + if (downloaded.has(row.youtube_id)) { + season.downloaded += 1; + } else { + season.notDownloaded.push({ youtubeId: row.youtube_id, title: titles.get(row.youtube_id) || null, episode: row.episode, code: codeOf(row) }); + } + } + return { showId: show.id, name: show.name, seasons: [...seasons.values()] }; +} + +/** + * Title, presence of the downloaded file and videos row id of each video + * (listing title first, else the downloaded title). + * @returns {Promise<Map<string, {title: string|null, downloaded: boolean, videoId: number|null}>>} + */ +async function describeVideos(channelId, youtubeIds) { + const result = new Map(); + if (youtubeIds.length === 0) return result; + const listed = await ChannelVideo.findAll({ where: { channel_id: channelId, youtube_id: youtubeIds }, attributes: ['youtube_id', 'title'], raw: true }); + const titles = new Map(listed.map((row) => [row.youtube_id, row.title])); + const downloads = await Video.findAll({ where: { youtubeId: youtubeIds }, attributes: ['id', 'youtubeId', 'youTubeVideoName', 'removed'], raw: true }); + const byId = new Map(downloads.map((row) => [row.youtubeId, row])); + for (const youtubeId of youtubeIds) { + const download = byId.get(youtubeId); + result.set(youtubeId, { + title: titles.get(youtubeId) || (download && download.youTubeVideoName) || null, + downloaded: Boolean(download && !download.removed), + videoId: download ? download.id : null, + }); + } + return result; +} + +/** + * Which of a channel's videos are episodes of one of its active title shows + * (numbered or waiting for a number): what "only download videos that + * belong to a show" downloads. + * @returns {Promise<Set<string>|null>} null when the channel has no active title show + */ +async function showEpisodeIds(channelId, youtubeIds) { + const shows = await TvShow.findAll({ + where: { channel_id: channelId, kind: KIND_TITLE_SHOW, retired_at: null }, attributes: ['id'], raw: true, + }); + if (shows.length === 0) return null; + if (youtubeIds.length === 0) return new Set(); + const rows = await VideoClassification.findAll({ + where: { youtube_id: youtubeIds, show_id: shows.map((show) => show.id), status: EPISODE_STATUSES }, + attributes: ['youtube_id'], + raw: true, + }); + return new Set(rows.map((row) => row.youtube_id)); +} + +/** + * Videos classified into a show as episodes (numbered or waiting for one). + * @returns {Promise<Set<string>>} + */ +async function youtubeIdsForShow(showId) { + const rows = await VideoClassification.findAll({ where: { show_id: showId, status: EPISODE_STATUSES }, attributes: ['youtube_id'], raw: true }); + return new Set(rows.map((row) => row.youtube_id)); +} + +/** + * The numbered episode each video has in an active title show, whether or + * not it is downloaded yet. + * @param {string[]} youtubeIds + * @returns {Promise<Map<string, {showName: string, season: number, episode: number, code: string}>>} + */ +async function plannedEpisodes(youtubeIds) { + const result = new Map(); + if (youtubeIds.length === 0) return result; + const rows = (await VideoClassification.findAll({ + where: { youtube_id: youtubeIds, status: ROW_STATUS.ASSIGNED }, + attributes: ['youtube_id', 'show_id', 'season', 'episode', 'source'], + raw: true, + })).filter((row) => row.season !== null && row.episode !== null); + if (rows.length === 0) return result; + const shows = await TvShow.findAll({ + where: { id: [...new Set(rows.map((row) => row.show_id))], kind: KIND_TITLE_SHOW, retired_at: null }, + attributes: ['id', 'name'], + raw: true, + }); + const names = new Map(shows.map((show) => [show.id, show.name])); + for (const row of rows) { + if (!names.has(row.show_id)) continue; + result.set(row.youtube_id, { showName: names.get(row.show_id), season: row.season, episode: row.episode, code: codeOf(row) }); + } + return result; +} + +module.exports = { + countsByShow, + countActiveByChannel, + missingEpisodes, + describeVideos, + showEpisodeIds, + youtubeIdsForShow, + plannedEpisodes +}; diff --git a/server/modules/tvShows/titleShowSaver.js b/server/modules/tvShows/titleShowSaver.js new file mode 100644 index 000000000..526c06b0f --- /dev/null +++ b/server/modules/tvShows/titleShowSaver.js @@ -0,0 +1,205 @@ +/** + * Saving a channel's title shows. Every change (a show added, edited, + * retired, restored or reordered, a manual episode assignment, "Not an + * episode") is a new set of drafts plus overrides for the whole channel, + * planned through titlePlanner. When no downloaded video's show, number or + * folder changes it is saved right away; otherwise the caller gets a + * reorganizeRequired 409 naming the change, which the reorganize previews and + * applies (it moves the files). + */ + +const { sequelize } = require('../../db'); +const configModule = require('../configModule'); +const reorganizeLock = require('../reorganize/reorganizeLock'); +const { getLayoutResolver, listTvFolders } = require('./libraryLayouts'); +const { effectiveLibraryFolder } = require('./channelFolders'); +const { normalizeDrafts, defaultLibraryFolder, assertDraftsCompile } = require('./titleShowDrafts'); +const titleShowStore = require('./titleShowStore'); +const { planChannel } = require('./titlePlanner'); +const { applyPlan, isRowChangedError } = require('./titleRowWriter'); +const archiveSuppressor = require('./archiveSuppressor'); +const { reorganizeRequiredError } = require('./layoutGuards'); +const { MAX_SEASON, MIN_YEAR_SEASON, MAX_YEAR_SEASON, isAssignableSeason, PatternError } = require('./patternCompiler'); + +const CHANGE_TITLE_SHOWS = 'titleShows'; +const MAX_EPISODE = 2147483647; +const MAX_OVERRIDES = 100; +const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/; +const MOVE_MESSAGE = 'Downloaded videos of this channel move with this change. Review the move first.'; + +function invalid(message) { + return new PatternError(message); +} + +function isUniqueConstraintError(err) { + return Boolean(err && err.name === 'SequelizeUniqueConstraintError'); +} + +/** + * @param {Array<Object>} rawOverrides - [{ youtubeId, showId, season, episode }], [{ youtubeId, notAnEpisode: true }] + * or [{ youtubeId, automatic: true }] (back to automatic classification) + * @param {Array<Object>} drafts - Normalized drafts the assignments must point into + * @returns {Map<string, Object>} + */ +function normalizeOverrides(rawOverrides, drafts) { + if (!Array.isArray(rawOverrides)) throw invalid('overrides must be a list.'); + if (rawOverrides.length > MAX_OVERRIDES) throw invalid(`At most ${MAX_OVERRIDES} episode assignments at a time.`); + const keys = new Set(drafts.map((draft) => draft.key)); + const overrides = new Map(); + for (const raw of rawOverrides) { + if (!raw || typeof raw.youtubeId !== 'string' || !YOUTUBE_ID.test(raw.youtubeId)) throw invalid('Each assignment needs a video id.'); + if (raw.notAnEpisode === true) { + overrides.set(raw.youtubeId, { optOut: true }); + continue; + } + if (raw.automatic === true) { + overrides.set(raw.youtubeId, { reset: true }); + continue; + } + const showKey = `title:${raw.showId}`; + if (!Number.isInteger(raw.showId) || !keys.has(showKey)) throw invalid('Choose one of this channel\'s shows.'); + if (!isAssignableSeason(raw.season)) { + throw invalid(`The season must be 0 to ${MAX_SEASON}, or an upload year from ${MIN_YEAR_SEASON} to ${MAX_YEAR_SEASON}.`); + } + if (!Number.isInteger(raw.episode) || raw.episode < 1 || raw.episode > MAX_EPISODE) { + throw invalid('The episode must be a whole number of at least 1.'); + } + overrides.set(raw.youtubeId, { showKey, season: raw.season, episode: raw.episode }); + } + return overrides; +} + +/** + * Validate the drafts and overrides and plan the channel with them. + * + * @param {Object} params + * @param {Object} params.channel - channels row + * @param {Array<Object>} params.rawShows - The channel's title shows after the change, in order + * @param {Array<Object>} [params.rawOverrides] + * @returns {Promise<{drafts: Array<Object>, overrides: Map<string, Object>, plan: Object}>} + */ +async function prepare({ channel, rawShows, rawOverrides = [] }) { + const layoutOf = await getLayoutResolver(); + const tvFolders = await listTvFolders(); + const drafts = normalizeDrafts(rawShows, { + layoutOf, + tvFolders, + defaultLibraryFolder: defaultLibraryFolder({ + channelFolder: effectiveLibraryFolder(channel.sub_folder), + defaultFolder: configModule.getDefaultSubfolder() || '', + tvFolders, + layoutOf, + }), + }); + const overrides = normalizeOverrides(rawOverrides, drafts); + await assertDraftsCompile(drafts); + await titleShowStore.assertFolderNamesFree({ + channelId: channel.channel_id, + channelTitle: channel.title || channel.uploader || channel.channel_id, + drafts, + }); + const plan = await planChannel({ channel, drafts, overrides, downloadsDir: configModule.directoryPath }); + return { drafts, overrides, plan }; +} + +/** + * The marks a plan started from (its allocations raise them). + */ +async function highWaterOf(plan) { + const ids = [...plan.storedShows.values()].filter((show) => show.kind === 'title').map((show) => show.id); + return titleShowStore.highWaterMarks(ids); +} + +/** + * Write a prepared plan (definitions, rows, conflicts) in one transaction, + * then apply the archive changes it queued. The reorganize calls this when + * it starts a title show change. + * @param {{channel: Object, drafts: Array<Object>, plan: Object}} prepared + * @returns {Promise<{showIds: Map<string, number>, patternIds: Map<string, number>}>} + */ +async function applyPrepared({ channel, drafts, plan }) { + const highWaterBefore = await highWaterOf(plan); + const saved = await sequelize.transaction((transaction) => applyPlan({ channel, drafts, plan, highWaterBefore, transaction })); + await archiveSuppressor.flush(); + return saved; +} + +/** + * Save a change to a channel's title shows, or refuse with reorganizeRequired. + * + * @param {Object} params + * @param {Object} params.channel + * @param {Array<Object>} params.rawShows + * @param {Array<Object>} [params.rawOverrides] + * @returns {Promise<Object>} The plan that was saved + */ +async function save({ channel, rawShows, rawOverrides = [] }) { + reorganizeLock.assertChannelFree(channel.channel_id); + for (let attempt = 0; ; attempt += 1) { + const { drafts, plan } = await prepare({ channel, rawShows, rawOverrides }); + if (plan.requiresReorganize) { + throw reorganizeRequiredError(MOVE_MESSAGE, { + type: CHANGE_TITLE_SHOWS, channelId: channel.channel_id, shows: rawShows, overrides: rawOverrides, + }); + } + try { + await applyPrepared({ channel, drafts, plan }); + } catch (err) { + // A download post-processed meanwhile took a number this plan gave + // away, or numbered a row this plan rewrites: plan again, once. + if (attempt === 0 && (isUniqueConstraintError(err) || isRowChangedError(err))) continue; + throw err; + } + return plan; + } +} + +/** + * Classify videos a listing refresh just found, with the channel's stored + * shows. Every other video keeps its row exactly (a creator's retitle never + * renumbers); a new video whose file is already downloaded somewhere else is + * left for the next show save, which moves it through the reorganize. + * + * @param {Object} params + * @param {Object} params.channel + * @param {string[]} params.youtubeIds - The new videos + * @returns {Promise<Object|null>} The plan written, or null when there was nothing to classify + */ +async function classifyNew({ channel, youtubeIds }) { + if (youtubeIds.length === 0) return null; + const shows = await titleShowStore.listTitleShows(channel.channel_id); + if (shows.length === 0 || reorganizeLock.coversChannel(channel.channel_id)) return null; + const definitions = { + showIds: new Map(shows.map((show) => [show.key, show.id])), + patternIds: new Map(shows.flatMap((show) => show.patterns.map((pattern) => [pattern.key, pattern.id]))), + }; + for (let attempt = 0; ; attempt += 1) { + const planned = await planChannel({ channel, drafts: shows, onlyIds: new Set(youtubeIds), downloadsDir: configModule.directoryPath }); + const moving = new Set(planned.entries.filter((entry) => entry.moves).map((entry) => entry.youtubeId)); + const plan = { + ...planned, + entries: planned.entries.filter((entry) => !moving.has(entry.youtubeId)), + duplicates: planned.duplicates.filter((duplicate) => !moving.has(duplicate.youtubeId)), + }; + const highWaterBefore = await highWaterOf(plan); + try { + await sequelize.transaction((transaction) => applyPlan({ + channel, drafts: shows, plan, highWaterBefore, transaction, definitions, + })); + } catch (err) { + if (attempt === 0 && (isUniqueConstraintError(err) || isRowChangedError(err))) continue; + throw err; + } + await archiveSuppressor.flush(); + return plan; + } +} + +module.exports = { + CHANGE_TITLE_SHOWS, + applyPrepared, + classifyNew, + normalizeOverrides, + prepare, + save +}; diff --git a/server/modules/tvShows/titleShowService.js b/server/modules/tvShows/titleShowService.js new file mode 100644 index 000000000..8e90895e2 --- /dev/null +++ b/server/modules/tvShows/titleShowService.js @@ -0,0 +1,275 @@ +/** + * Title shows as the API sees them. Each change (add, edit, retire, restore, + * reorder, an episode assignment, using a duplicate's copy, re-checking + * titles) becomes the channel's full set of drafts plus overrides and goes + * through titleShowSaver, which saves it or asks for the reorganize. + */ + +const { Channel, Video, VideoClassification, EpisodeConflict, TvShow } = require('../../models'); +const ChannelVideo = require('../../models/channelvideo'); +const configModule = require('../configModule'); +const videoActivity = require('../download/videoActivity'); +const { getLayoutResolver, listTvFolders } = require('./libraryLayouts'); +const { effectiveLibraryFolder, showDirectory } = require('./channelFolders'); +const sidecarWriter = require('../sidecarWriter'); +const logger = require('../../logger'); +const titleShowStore = require('./titleShowStore'); +const titleShowSaver = require('./titleShowSaver'); +const titleShowQueries = require('./titleShowQueries'); +const episodeConflicts = require('./episodeConflicts'); +const { summarizePlan } = require('./titlePreview'); +const { defaultLibraryFolder } = require('./titleShowDrafts'); +const { episodeCode } = require('./episodeNaming'); +const { SOURCE } = require('./titleNumbering'); +const { KIND_TITLE_SHOW } = require('./constants'); + +const ASSIGNMENT_FIELDS = ['showId', 'season', 'episode', 'notAnEpisode', 'automatic']; + +function serviceError(message, status) { + const err = new Error(message); + err.status = status; + return err; +} + +function publicShow(show, counts) { + return { + id: show.id, + name: show.name, + folderName: show.folderName, + libraryFolder: show.libraryFolder, + position: show.position, + retired: show.retired, + excludeTerms: show.excludeTerms, + seasonNames: show.seasonNames, + patterns: show.patterns.map((pattern) => ({ + text: pattern.text, + kind: pattern.kind, + seasonSource: pattern.seasonSource, + seasonFixed: pattern.seasonFixed, + episodeSource: pattern.episodeSource, + compiledRegex: pattern.compiledRegex, + })), + counts: counts || null, + }; +} + +function parseDetails(text) { + try { + return JSON.parse(text || '{}') || {}; + } catch (err) { + return {}; + } +} + +class TitleShowService { + async currentDrafts(channelId) { + return (await titleShowStore.listTitleShows(channelId)).map(titleShowStore.toDraft); + } + + async refreshedChannel(channel) { + return (await Channel.findOne({ where: { channel_id: channel.channel_id } })) || channel; + } + + /** + * A channel's title shows (retired ones included), conflicts, show-only + * switch and where new shows go. + */ + async getChannelShows(channel) { + const channelId = channel.channel_id; + const shows = await titleShowStore.listTitleShows(channelId, { includeRetired: true }); + const counts = await titleShowQueries.countsByShow(shows.map((show) => show.id)); + const conflictRows = await episodeConflicts.listForChannel(channelId); + const ids = [...new Set(conflictRows.flatMap((row) => [row.youtubeId, row.duplicateOf].filter(Boolean)))]; + const videos = await titleShowQueries.describeVideos(channelId, ids); + const layoutOf = await getLayoutResolver(); + const tvFolders = await listTvFolders(); + const describe = (id) => videos.get(id) || { title: null, downloaded: false, videoId: null }; + return { + shows: shows.map((show) => publicShow(show, counts.get(show.id))), + conflicts: conflictRows.map((row) => ({ + ...row, + ...describe(row.youtubeId), + duplicateOfTitle: row.duplicateOf ? describe(row.duplicateOf).title : null, + })), + showOnlyDownloads: Boolean(channel.tv_show_only_downloads), + tvFolders, + defaultLibraryFolder: defaultLibraryFolder({ + channelFolder: effectiveLibraryFolder(channel.sub_folder), + defaultFolder: configModule.getDefaultSubfolder() || '', + tvFolders, + layoutOf, + }), + }; + } + + async saveAndDescribe(channel, rawShows, rawOverrides) { + const params = rawOverrides ? { channel, rawShows, rawOverrides } : { channel, rawShows }; + await titleShowSaver.save(params); + await this.refreshShowMetadata(channel.channel_id); + return this.getChannelShows(await this.refreshedChannel(channel)); + } + + /** + * Media servers read a title show's name and season names from its NFO + * files, and a save that changes them moves no file: rewrite them (a show + * folder that doesn't exist yet is skipped). + */ + async refreshShowMetadata(channelId) { + const shows = await TvShow.findAll({ where: { channel_id: channelId, kind: KIND_TITLE_SHOW, retired_at: null } }); + for (const show of shows) { + try { + await sidecarWriter.writeShowMetadata({ show, showDir: showDirectory(show) }); + } catch (err) { + logger.warn({ err, showId: show.id }, 'Could not update a title show\'s NFO files'); + } + } + } + + async createShow(channel, rawDraft) { + const drafts = await this.currentDrafts(channel.channel_id); + return this.saveAndDescribe(channel, [...drafts, rawDraft]); + } + + async updateShow(channel, showId, rawDraft) { + const drafts = await this.currentDrafts(channel.channel_id); + if (!drafts.some((draft) => draft.id === showId)) throw serviceError('Show not found', 404); + return this.saveAndDescribe(channel, drafts.map((draft) => (draft.id === showId ? { ...rawDraft, id: showId } : draft))); + } + + async retireShow(channel, showId) { + const drafts = await this.currentDrafts(channel.channel_id); + if (!drafts.some((draft) => draft.id === showId)) throw serviceError('Show not found', 404); + return this.saveAndDescribe(channel, drafts.filter((draft) => draft.id !== showId)); + } + + async restoreShow(channel, showId) { + const shows = await titleShowStore.listTitleShows(channel.channel_id, { includeRetired: true }); + const show = shows.find((entry) => entry.id === showId); + if (!show) throw serviceError('Show not found', 404); + if (!show.retired) throw serviceError('That show isn\'t removed.', 400); + const active = shows.filter((entry) => !entry.retired).map(titleShowStore.toDraft); + return this.saveAndDescribe(channel, [...active, titleShowStore.toDraft(show)]); + } + + async reorderShows(channel, showIds) { + const drafts = await this.currentDrafts(channel.channel_id); + const ids = drafts.map((draft) => draft.id); + const valid = Array.isArray(showIds) && showIds.length === ids.length + && new Set(showIds).size === ids.length && showIds.every((id) => ids.includes(id)); + if (!valid) throw serviceError('showIds must list every show of the channel once.', 400); + return this.saveAndDescribe(channel, showIds.map((id) => drafts.find((draft) => draft.id === id))); + } + + /** Classify the channel's titles again with its current shows (after a classification error). */ + async recheck(channel) { + await titleShowSaver.save({ channel, rawShows: await this.currentDrafts(channel.channel_id) }); + return this.getChannelShows(await this.refreshedChannel(channel)); + } + + async setShowOnly(channel, enabled) { + await channel.update({ tv_show_only_downloads: enabled }); + return { showOnlyDownloads: enabled }; + } + + /** + * What a set of draft shows would do, without saving anything. + */ + async preview(channel, { shows, overrides = [] }) { + const { drafts, plan } = await titleShowSaver.prepare({ channel, rawShows: shows, rawOverrides: overrides }); + return { + ...summarizePlan(plan, { videos: plan.videos, isQueued: (id) => videoActivity.isActive(id) }), + compiled: drafts.map((draft) => ({ key: draft.key, patterns: draft.patterns.map((pattern) => pattern.compiledRegex) })), + }; + } + + /** "Use this copy instead": the duplicate takes the number its holder has. */ + async useDuplicateCopy(channel, youtubeId) { + const conflict = await EpisodeConflict.findByPk(youtubeId); + if (!conflict || conflict.channel_id !== channel.channel_id || conflict.kind !== 'duplicate') { + throw serviceError('No duplicate of this channel with that id', 404); + } + const { season, episode } = parseDetails(conflict.details); + return this.saveAndDescribe(channel, await this.currentDrafts(channel.channel_id), [ + { youtubeId, showId: conflict.show_id, season, episode }, + ]); + } + + async missingEpisodes(channel, showId) { + const result = await titleShowQueries.missingEpisodes(channel.channel_id, showId); + if (!result) throw serviceError('Show not found', 404); + return result; + } + + // The tracked channel a video belongs to: its classification's owner, else + // the channel it was downloaded or listed under. + async ownerChannelOf(youtubeId, classification) { + let channelId = classification ? classification.channel_id : null; + if (!channelId) { + const download = await Video.findOne({ where: { youtubeId }, attributes: ['channel_id'] }); + channelId = download ? download.channel_id : null; + } + if (!channelId) { + const listed = await ChannelVideo.findOne({ where: { youtube_id: youtubeId }, attributes: ['channel_id'] }); + channelId = listed ? listed.channel_id : null; + } + return channelId ? Channel.findOne({ where: { channel_id: channelId } }) : null; + } + + /** + * A video's episode: its classification and the title shows it can join. + */ + async getVideoEpisode(youtubeId) { + const row = await VideoClassification.findByPk(youtubeId); + const channel = await this.ownerChannelOf(youtubeId, row); + if (!channel) return { channelId: null, assignable: false, classification: null, shows: [] }; + const shows = await titleShowStore.listTitleShows(channel.channel_id); + let classification = null; + if (row) { + const show = await TvShow.findByPk(row.show_id); + const numbered = row.season !== null && row.episode !== null; + classification = { + showId: row.show_id, + showName: show ? show.name : null, + kind: show ? show.kind : null, + status: row.status, + season: row.season, + episode: row.episode, + code: numbered ? episodeCode({ season: row.season, episode: row.episode, dateNumbered: row.source === SOURCE.DATE }) : null, + source: row.source, + notAnEpisode: Boolean(row.title_opt_out), + }; + } + return { + channelId: channel.channel_id, + assignable: Boolean(channel.enabled) && shows.length > 0, + classification, + shows: shows.map((show) => ({ id: show.id, name: show.name, seasonNames: show.seasonNames })), + }; + } + + /** + * Assign a video to a title show episode by hand, mark it "Not an + * episode", or return it to automatic classification. + * @param {Object} body - { showId, season, episode } | { notAnEpisode: true } | { automatic: true } + */ + async assignEpisode(youtubeId, body) { + const row = await VideoClassification.findByPk(youtubeId); + const channel = await this.ownerChannelOf(youtubeId, row); + if (!channel || !channel.enabled) throw serviceError('This video\'s channel isn\'t subscribed.', 400); + const rawShows = await this.currentDrafts(channel.channel_id); + if (rawShows.length === 0) throw serviceError('This video\'s channel has no shows.', 400); + if (row && row.channel_id !== channel.channel_id) throw serviceError('This video belongs to another channel\'s show.', 400); + if (body && body.notAnEpisode) { + // "Not an episode" takes a video out of the title shows; a channel-show + // episode, or a video in no show, has nothing to leave. + const show = row && !row.title_opt_out ? await TvShow.findByPk(row.show_id) : null; + if (!show || show.kind !== KIND_TITLE_SHOW) throw serviceError('Only an episode of a title show can be marked "Not an episode".', 400); + } + const override = { youtubeId }; + for (const field of ASSIGNMENT_FIELDS) if (body && body[field] !== undefined) override[field] = body[field]; + await titleShowSaver.save({ channel, rawShows, rawOverrides: [override] }); + return this.getVideoEpisode(youtubeId); + } +} + +module.exports = new TitleShowService(); diff --git a/server/modules/tvShows/titleShowStore.js b/server/modules/tvShows/titleShowStore.js new file mode 100644 index 000000000..7187a10a6 --- /dev/null +++ b/server/modules/tvShows/titleShowStore.js @@ -0,0 +1,311 @@ +/** + * Stored title shows (tv_shows rows of kind 'title' with their patterns and + * seasons): reading them, saving a channel's whole set of definitions, order + * high-water marks, folder availability and download filters. + * + * A save replaces each show's patterns and retires the active shows it + * leaves out (a retired show keeps its row, location and numbers so it can + * be restored). + */ + +const { randomUUID } = require('crypto'); +const { TvShow, TvShowPattern, TvShowSeason } = require('../../models'); +const { KIND_TITLE_SHOW } = require('./constants'); +const { folderNameKey } = require('./showFolderNames'); +const { buildShowFilter, excludeTermRegex } = require('./patternCompiler'); + +function storeError(message, status, details = null) { + const err = new Error(message); + err.status = status; + if (details) err.details = details; + return err; +} + +function parseTerms(text) { + if (!text) return []; + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed.filter((term) => typeof term === 'string') : []; + } catch (err) { + return []; + } +} + +function serializePattern(row, showKey) { + return { + id: row.id, + key: `${showKey}#${row.position}`, + position: row.position, + text: row.pattern_text, + kind: row.pattern_kind, + compiledRegex: row.compiled_regex, + filterRegex: row.filter_regex, + seasonSource: row.season_source, + seasonFixed: row.season_fixed, + episodeSource: row.episode_source, + }; +} + +function serializeShow(row) { + const key = `title:${row.id}`; + const seasonNames = {}; + for (const season of row.seasons || []) { + if (season.name) seasonNames[season.season] = season.name; + } + return { + id: row.id, + key, + channelId: row.channel_id, + name: row.name, + folderName: row.folder_name, + libraryFolder: row.library_folder || '', + position: row.position, + externalKey: row.external_key, + excludeTerms: parseTerms(row.exclude_terms), + seasonNames, + retired: Boolean(row.retired_at), + retiredAt: row.retired_at || null, + patterns: [...(row.patterns || [])].sort((a, b) => a.position - b.position).map((pattern) => serializePattern(pattern, key)), + }; +} + +const INCLUDE = [ + { model: TvShowPattern, as: 'patterns' }, + { model: TvShowSeason, as: 'seasons' }, +]; + +/** + * @param {string} channelId + * @param {{includeRetired?: boolean}} [options] + * @returns {Promise<Array<Object>>} Serialized shows, active ones by position first + */ +async function listTitleShows(channelId, { includeRetired = false } = {}) { + const where = { channel_id: channelId, kind: KIND_TITLE_SHOW }; + if (!includeRetired) where.retired_at = null; + const rows = await TvShow.findAll({ where, include: INCLUDE }); + return rows.map(serializeShow).sort((a, b) => (a.retired === b.retired ? a.position - b.position : a.retired ? 1 : -1)); +} + +/** + * Active title shows of several channels, by channel id. + * @returns {Promise<Map<string, Array<Object>>>} + */ +async function listActiveByChannel(channelIds) { + const result = new Map(); + if (channelIds.length === 0) return result; + const rows = await TvShow.findAll({ where: { channel_id: channelIds, kind: KIND_TITLE_SHOW, retired_at: null }, include: INCLUDE }); + for (const show of rows.map(serializeShow).sort((a, b) => a.position - b.position)) { + if (!result.has(show.channelId)) result.set(show.channelId, []); + result.get(show.channelId).push(show); + } + return result; +} + +/** + * A stored show as the draft that would save it unchanged. + */ +function toDraft(show) { + return { + id: show.id, + name: show.name, + folderName: show.folderName, + libraryFolder: show.libraryFolder, + excludeTerms: [...show.excludeTerms], + seasonNames: { ...show.seasonNames }, + patterns: show.patterns.map((pattern) => ({ + text: pattern.text, + kind: pattern.kind, + seasonSource: pattern.seasonSource, + seasonFixed: pattern.seasonFixed, + episodeSource: pattern.episodeSource, + })), + }; +} + +async function saveSeasonNames(showId, seasonNames, transaction) { + const rows = await TvShowSeason.findAll({ where: { show_id: showId }, transaction }); + const bySeason = new Map(rows.map((row) => [row.season, row])); + for (const [key, name] of Object.entries(seasonNames)) { + const season = Number(key); + const row = bySeason.get(season); + if (!row) await TvShowSeason.create({ show_id: showId, season, name }, { transaction }); + else if (row.name !== name) await row.update({ name }, { transaction }); + } + for (const row of rows) { + if (row.name && !Object.prototype.hasOwnProperty.call(seasonNames, row.season)) { + await row.update({ name: null }, { transaction }); + } + } +} + +/** + * Write a channel's title shows: create or update each draft (by position), + * replace its patterns and season names, and retire the channel's active + * shows the drafts leave out. + * + * @param {Object} params + * @param {string} params.channelId + * @param {Array<Object>} params.drafts - Normalized drafts (titleShowDrafts) + * @param {Object} [params.transaction] + * @returns {Promise<{showIds: Map<string, number>, patternIds: Map<string, number>}>} By draft and pattern key + */ +async function saveDefinitions({ channelId, drafts, transaction = null }) { + const existing = await TvShow.findAll({ where: { channel_id: channelId, kind: KIND_TITLE_SHOW }, transaction }); + const byId = new Map(existing.map((row) => [row.id, row])); + const showIds = new Map(); + const patternIds = new Map(); + const kept = new Set(); + + for (const draft of drafts) { + const values = { + name: draft.name, + folder_name: draft.folderName, + library_folder: draft.libraryFolder || '', + position: draft.position, + exclude_terms: JSON.stringify(draft.excludeTerms), + retired_at: null, + }; + let showId; + if (draft.id) { + const row = byId.get(draft.id); + if (!row) throw storeError('Show not found', 404); + await row.update(values, { transaction }); + showId = row.id; + kept.add(row.id); + } else { + const row = await TvShow.create({ + ...values, channel_id: channelId, kind: KIND_TITLE_SHOW, external_key: randomUUID(), + }, { transaction }); + showId = row.id; + } + showIds.set(draft.key, showId); + + await TvShowPattern.destroy({ where: { show_id: showId }, transaction }); + const created = await TvShowPattern.bulkCreate(draft.patterns.map((pattern) => ({ + show_id: showId, + position: pattern.position, + pattern_text: pattern.text, + pattern_kind: pattern.kind, + compiled_regex: pattern.compiledRegex, + filter_regex: pattern.filterRegex, + season_source: pattern.seasonSource, + season_fixed: pattern.seasonFixed, + episode_source: pattern.episodeSource, + })), { transaction }); + draft.patterns.forEach((pattern, index) => patternIds.set(pattern.key, created[index].id)); + await saveSeasonNames(showId, draft.seasonNames, transaction); + } + + for (const row of existing) { + if (!kept.has(row.id) && !row.retired_at) await row.update({ retired_at: new Date() }, { transaction }); + } + return { showIds, patternIds }; +} + +/** + * Refuse drafts whose folder another show already uses in that library + * folder (retired shows included: their folder is kept for a restore). + * @throws {Error} status 409 with details { suggestion, retiredShowId? } + */ +async function assertFolderNamesFree({ channelId, channelTitle, drafts }) { + if (drafts.length === 0) return; + const folders = [...new Set(drafts.map((draft) => draft.libraryFolder || ''))]; + const rows = await TvShow.findAll({ + where: { library_folder: folders }, + attributes: ['id', 'name', 'folder_name', 'library_folder', 'channel_id', 'kind', 'retired_at'], + }); + const ownIds = new Set(drafts.filter((draft) => draft.id).map((draft) => draft.id)); + for (const draft of drafts) { + const key = folderNameKey(draft.libraryFolder, draft.folderName); + const holder = rows.find((row) => !ownIds.has(row.id) && folderNameKey(row.library_folder, row.folder_name) === key); + if (!holder) continue; + const restorable = holder.kind === KIND_TITLE_SHOW && holder.retired_at && holder.channel_id === channelId; + const details = { suggestion: `${draft.name} (${channelTitle})` }; + if (restorable) details.retiredShowId = holder.id; + throw storeError( + restorable + ? `The removed show "${holder.name}" used the folder "${draft.folderName}". Restore it, or choose another folder name.` + : `The folder "${draft.folderName}" is already used by the show "${holder.name}". Try "${details.suggestion}".`, + 409, + details + ); + } +} + +/** + * @returns {Promise<Map<string, number>>} order_high_water by `title:<id>|<season>` + */ +async function highWaterMarks(showIds) { + const marks = new Map(); + if (showIds.length === 0) return marks; + const rows = await TvShowSeason.findAll({ where: { show_id: showIds }, attributes: ['show_id', 'season', 'order_high_water'] }); + for (const row of rows) marks.set(`title:${row.show_id}|${row.season}`, row.order_high_water); + return marks; +} + +/** + * Every title show id of a channel, retired ones included. + * @returns {Promise<number[]>} + */ +async function titleShowIds(channelId, { transaction = null } = {}) { + const rows = await TvShow.findAll({ where: { channel_id: channelId, kind: KIND_TITLE_SHOW }, attributes: ['id'], transaction }); + return rows.map((row) => row.id); +} + +/** + * Delete title shows outright (their patterns and seasons go with them), for + * shows a change created and then took back. Nothing may still reference them. + */ +async function deleteShows(showIds, { transaction = null } = {}) { + if (showIds.length === 0) return; + await TvShow.destroy({ where: { id: showIds, kind: KIND_TITLE_SHOW }, transaction }); +} + +/** + * Record an allocated order number; the mark never goes down. + */ +async function raiseHighWater(showId, season, value, { transaction = null } = {}) { + let row = await TvShowSeason.findOne({ where: { show_id: showId, season }, transaction }); + if (!row) { + try { + await TvShowSeason.create({ show_id: showId, season, order_high_water: value }, { transaction }); + return; + } catch (err) { + // Another writer (a download or a save) created the season meanwhile. + if (err.name !== 'SequelizeUniqueConstraintError') throw err; + row = await TvShowSeason.findOne({ where: { show_id: showId, season }, transaction }); + if (!row) throw err; + } + } + if (value > row.order_high_water) await row.update({ order_high_water: value }, { transaction }); +} + +/** + * The show-only download filters of channels: per active title show, its + * patterns as one alternation and its exclude terms. + * @returns {Promise<Map<string, Array<{filterRegex: string, excludeRegexes: string[]}>>>} + */ +async function showFiltersByChannel(channelIds) { + const filters = new Map(); + for (const [channelId, shows] of await listActiveByChannel(channelIds)) { + filters.set(channelId, shows.map((show) => ({ + filterRegex: buildShowFilter(show.patterns.map((pattern) => pattern.filterRegex)), + excludeRegexes: show.excludeTerms.map(excludeTermRegex), + }))); + } + return filters; +} + +module.exports = { + serializeShow, + listTitleShows, + listActiveByChannel, + toDraft, + saveDefinitions, + assertFolderNamesFree, + highWaterMarks, + raiseHighWater, + titleShowIds, + deleteShows, + showFiltersByChannel +}; diff --git a/server/modules/tvShows/tvNfo.js b/server/modules/tvShows/tvNfo.js index 410d1e7c3..8f7ee9f44 100644 --- a/server/modules/tvShows/tvNfo.js +++ b/server/modules/tvShows/tvNfo.js @@ -14,6 +14,9 @@ const path = require('path'); const nfoGenerator = require('../nfoGenerator'); const TV_SHOW_NFO_NAME = 'tvshow.nfo'; +const SEASON_NFO_NAME = 'season.nfo'; +const ID_TYPE_YOUTUBE = 'youtube'; +const ID_TYPE_YOUTARR = 'youtarr'; const SHOW_STUDIO = 'YouTube'; const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'; @@ -78,10 +81,12 @@ function buildEpisodeNfo({ info, showTitle, season, episode, episodeTitle }) { * @param {string} params.title - Show title * @param {string} [params.plot] * @param {string} [params.premiered] - YYYY-MM-DD of the earliest episode - * @param {string} params.externalKey - Stable show id (the channel id for channel shows) + * @param {string} params.externalKey - Stable show id (the channel id for channel shows, a UUID for title shows) + * @param {string} [params.idType] - 'youtube' (channel shows) or 'youtarr' (title shows) + * @param {Object<string, string>} [params.namedSeasons] - Season names by season number * @returns {string} */ -function buildTvShowNfo({ title, plot, premiered, externalKey }) { +function buildTvShowNfo({ title, plot, premiered, externalKey, idType = ID_TYPE_YOUTUBE, namedSeasons = {} }) { let xml = XML_HEADER; xml += '<tvshow>\n'; xml += element('title', title); @@ -89,13 +94,52 @@ function buildTvShowNfo({ title, plot, premiered, externalKey }) { if (premiered) xml += element('premiered', premiered); xml += element('studio', SHOW_STUDIO); // The custom id keeps Jellyfin's played state across a folder rename and - // stops Jellyfin 12 merging two same-named shows. - xml += ` <uniqueid type="youtube" default="true">${escape(externalKey)}</uniqueid>\n`; + // stops Jellyfin 12 merging two same-named shows. Plex's NFO agent builds + // the show's GUID from the default id, so neither ever changes. + xml += ` <uniqueid type="${idType}" default="true">${escape(externalKey)}</uniqueid>\n`; xml += ` <uniqueid type="custom">${escape(externalKey)}</uniqueid>\n`; + const seasons = Object.keys(namedSeasons).map(Number).sort((a, b) => a - b); + for (const season of seasons) { + xml += ` <namedseason number="${season}">${escape(namedSeasons[season])}</namedseason>\n`; + } xml += '</tvshow>\n'; return xml; } +/** + * season.nfo for a named season (Emby reads season names only from it). + * @param {{season: number, name: string}} params + */ +function buildSeasonNfo({ season, name }) { + return `${XML_HEADER}<season>\n${element('title', name)} <seasonnumber>${season}</seasonnumber>\n</season>\n`; +} + +function isYoutarrSeasonNfo(content) { + return content.startsWith(`${XML_HEADER}<season>\n <title>`); +} + +/** + * Write a named season's season.nfo, or remove the one Youtarr wrote when the + * season lost its name. A season.nfo written by someone else stays. + * @param {string} seasonDir + * @param {{season: number, name: string|null}} params + */ +async function writeSeasonNfo(seasonDir, { season, name }) { + const nfoPath = path.join(seasonDir, SEASON_NFO_NAME); + let current = null; + try { + current = await fs.promises.readFile(nfoPath, 'utf8'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + if (!name) { + if (current !== null && isYoutarrSeasonNfo(current)) await fs.promises.unlink(nfoPath); + return; + } + const xml = buildSeasonNfo({ season, name }); + if (current !== xml) await fs.promises.writeFile(nfoPath, xml, 'utf8'); +} + /** * Write tvshow.nfo when it is missing or its content would change. * @returns {Promise<boolean>} true when the file was written @@ -116,8 +160,13 @@ async function writeTvShowNfoIfChanged(showDir, params) { module.exports = { TV_SHOW_NFO_NAME, + SEASON_NFO_NAME, + ID_TYPE_YOUTUBE, + ID_TYPE_YOUTARR, dateFromEpisodeCode, buildEpisodeNfo, buildTvShowNfo, + buildSeasonNfo, + writeSeasonNfo, writeTvShowNfoIfChanged }; diff --git a/server/routes/__tests__/tvTitleShows.test.js b/server/routes/__tests__/tvTitleShows.test.js new file mode 100644 index 000000000..0417ef02a --- /dev/null +++ b/server/routes/__tests__/tvTitleShows.test.js @@ -0,0 +1,203 @@ +/* eslint-env jest */ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../logger', () => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() })); + +const CHANNEL_ID = 'UCDrqiuwNRbEahL1UEB0hkKQ'; +const VIDEO_ID = 'y7xVT7DTt2k'; + +function refusal(message, status, extra = {}) { + return Object.assign(new Error(message), { status }, extra); +} + +describe('title show routes', () => { + let app; + let titleShowService; + let models; + const channel = { channel_id: CHANNEL_ID, title: 'BEYBLADE Official' }; + const SHOWS = { shows: [], conflicts: [] }; + + beforeEach(() => { + jest.resetModules(); + titleShowService = { + getChannelShows: jest.fn().mockResolvedValue(SHOWS), + createShow: jest.fn().mockResolvedValue(SHOWS), + updateShow: jest.fn().mockResolvedValue(SHOWS), + retireShow: jest.fn().mockResolvedValue(SHOWS), + restoreShow: jest.fn().mockResolvedValue(SHOWS), + reorderShows: jest.fn().mockResolvedValue(SHOWS), + recheck: jest.fn().mockResolvedValue(SHOWS), + setShowOnly: jest.fn().mockResolvedValue({ showOnlyDownloads: true }), + preview: jest.fn().mockResolvedValue({ shows: [] }), + useDuplicateCopy: jest.fn().mockResolvedValue(SHOWS), + missingEpisodes: jest.fn().mockResolvedValue({ seasons: [] }), + getVideoEpisode: jest.fn().mockResolvedValue({ channelId: CHANNEL_ID }), + assignEpisode: jest.fn().mockResolvedValue({ channelId: CHANNEL_ID }), + }; + models = { Channel: { findOne: jest.fn().mockResolvedValue(channel) } }; + const createRoutes = require('../tvTitleShows'); + app = express(); + app.use(express.json()); + app.use(createRoutes({ + verifyToken: (req, res, next) => next(), + titleShowService, + models, + layoutGuards: { + errorBody: (error) => ({ + error: error.message, + ...(error.reorganizeRequired ? { reorganizeRequired: true, change: error.change } : {}), + }), + }, + })); + }); + + describe('GET /api/channels/:channelId/tv/shows', () => { + test('returns the channel\'s shows', async () => { + const res = await request(app).get(`/api/channels/${CHANNEL_ID}/tv/shows`); + expect([res.status, res.body]).toEqual([200, SHOWS]); + }); + + test('answers 404 for an unknown channel', async () => { + models.Channel.findOne.mockResolvedValue(null); + const res = await request(app).get(`/api/channels/${CHANNEL_ID}/tv/shows`); + expect(res.status).toBe(404); + }); + }); + + describe('POST /api/channels/:channelId/tv/shows', () => { + test('adds a show', async () => { + const draft = { name: 'Beyblade', patterns: [] }; + await request(app).post(`/api/channels/${CHANNEL_ID}/tv/shows`).send(draft); + expect(titleShowService.createShow).toHaveBeenCalledWith(channel, draft); + }); + + test('passes a reorganize request through with the change', async () => { + titleShowService.createShow.mockRejectedValue(refusal('Review the move first.', 409, { + reorganizeRequired: true, change: { type: 'titleShows', channelId: CHANNEL_ID }, + })); + const res = await request(app).post(`/api/channels/${CHANNEL_ID}/tv/shows`).send({ name: 'x' }); + expect([res.status, res.body.reorganizeRequired, res.body.change.type]).toEqual([409, true, 'titleShows']); + }); + + test('adds the suggestion of a folder refusal to the body', async () => { + titleShowService.createShow.mockRejectedValue(refusal('taken', 409, { details: { suggestion: 'Beyblade (BEYBLADE Official)', retiredShowId: 5 } })); + const res = await request(app).post(`/api/channels/${CHANNEL_ID}/tv/shows`).send({ name: 'x' }); + expect(res.body).toEqual({ error: 'taken', suggestion: 'Beyblade (BEYBLADE Official)', retiredShowId: 5 }); + }); + + test('refuses a body that is not a show', async () => { + const res = await request(app).post(`/api/channels/${CHANNEL_ID}/tv/shows`).send([1, 2]); + expect(res.status).toBe(400); + }); + + test('answers 500 for an unexpected failure', async () => { + titleShowService.createShow.mockRejectedValue(new Error('python crashed')); + const res = await request(app).post(`/api/channels/${CHANNEL_ID}/tv/shows`).send({ name: 'x' }); + expect(res.status).toBe(500); + }); + }); + + describe('PUT /api/channels/:channelId/tv/shows/order', () => { + test('reorders the shows', async () => { + await request(app).put(`/api/channels/${CHANNEL_ID}/tv/shows/order`).send({ showIds: [4, 3] }); + expect(titleShowService.reorderShows).toHaveBeenCalledWith(channel, [4, 3]); + }); + }); + + describe('PUT /api/channels/:channelId/tv/shows/:showId', () => { + test('edits a show', async () => { + await request(app).put(`/api/channels/${CHANNEL_ID}/tv/shows/4`).send({ name: 'Renamed' }); + expect(titleShowService.updateShow).toHaveBeenCalledWith(channel, 4, { name: 'Renamed' }); + }); + + test('refuses a show id that is not a number', async () => { + const res = await request(app).put(`/api/channels/${CHANNEL_ID}/tv/shows/abc`).send({ name: 'x' }); + expect(res.status).toBe(400); + }); + }); + + describe('DELETE /api/channels/:channelId/tv/shows/:showId', () => { + test('retires a show', async () => { + await request(app).delete(`/api/channels/${CHANNEL_ID}/tv/shows/4`); + expect(titleShowService.retireShow).toHaveBeenCalledWith(channel, 4); + }); + }); + + describe('POST /api/channels/:channelId/tv/shows/:showId/restore', () => { + test('restores a show', async () => { + await request(app).post(`/api/channels/${CHANNEL_ID}/tv/shows/5/restore`); + expect(titleShowService.restoreShow).toHaveBeenCalledWith(channel, 5); + }); + }); + + describe('GET /api/channels/:channelId/tv/shows/:showId/missing', () => { + test('returns a show\'s missing episodes', async () => { + const res = await request(app).get(`/api/channels/${CHANNEL_ID}/tv/shows/3/missing`); + expect([res.status, titleShowService.missingEpisodes.mock.calls[0]]).toEqual([200, [channel, 3]]); + }); + }); + + describe('POST /api/channels/:channelId/tv/preview', () => { + test('previews draft shows', async () => { + const shows = [{ name: 'Beyblade' }]; + await request(app).post(`/api/channels/${CHANNEL_ID}/tv/preview`).send({ shows }); + expect(titleShowService.preview).toHaveBeenCalledWith(channel, { shows, overrides: [] }); + }); + + test('refuses a preview without a list of shows', async () => { + const res = await request(app).post(`/api/channels/${CHANNEL_ID}/tv/preview`).send({ shows: 'x' }); + expect(res.status).toBe(400); + }); + }); + + describe('PUT /api/channels/:channelId/tv/show-only', () => { + test('sets the switch', async () => { + const res = await request(app).put(`/api/channels/${CHANNEL_ID}/tv/show-only`).send({ enabled: true }); + expect([res.status, titleShowService.setShowOnly.mock.calls[0]]).toEqual([200, [channel, true]]); + }); + + test('refuses a value that is not a boolean', async () => { + const res = await request(app).put(`/api/channels/${CHANNEL_ID}/tv/show-only`).send({ enabled: 'yes' }); + expect(res.status).toBe(400); + }); + }); + + describe('POST /api/channels/:channelId/tv/conflicts/:youtubeId/use-copy', () => { + test('swaps a duplicate in', async () => { + await request(app).post(`/api/channels/${CHANNEL_ID}/tv/conflicts/${VIDEO_ID}/use-copy`); + expect(titleShowService.useDuplicateCopy).toHaveBeenCalledWith(channel, VIDEO_ID); + }); + + test('refuses an invalid video id', async () => { + const res = await request(app).post(`/api/channels/${CHANNEL_ID}/tv/conflicts/bad/use-copy`); + expect(res.status).toBe(400); + }); + }); + + describe('POST /api/channels/:channelId/tv/recheck', () => { + test('classifies the channel\'s titles again', async () => { + await request(app).post(`/api/channels/${CHANNEL_ID}/tv/recheck`); + expect(titleShowService.recheck).toHaveBeenCalledWith(channel); + }); + }); + + describe('GET /api/videos/:youtubeId/episode', () => { + test('returns the video\'s episode', async () => { + const res = await request(app).get(`/api/videos/${VIDEO_ID}/episode`); + expect([res.status, res.body]).toEqual([200, { channelId: CHANNEL_ID }]); + }); + }); + + describe('PUT /api/videos/:youtubeId/episode', () => { + test('assigns an episode', async () => { + await request(app).put(`/api/videos/${VIDEO_ID}/episode`).send({ showId: 3, season: 1, episode: 20 }); + expect(titleShowService.assignEpisode).toHaveBeenCalledWith(VIDEO_ID, { showId: 3, season: 1, episode: 20 }); + }); + + test('refuses an invalid video id', async () => { + const res = await request(app).put('/api/videos/x/episode').send({ notAnEpisode: true }); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/server/routes/channels.js b/server/routes/channels.js index e51c355ae..898c1270a 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -909,11 +909,16 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * type: string * enum: [G, PG, PG-13, R, NC-17, TV-Y, TV-Y7, TV-G, TV-PG, TV-14, TV-MA] * description: Hide videos rated above this rating. Unrated videos are always included. A video not yet downloaded is judged by the channel's default rating, if one is set. + * - in: query + * name: showId + * schema: + * type: integer + * description: Only the episodes of this title show of the channel (numbered or waiting for a number). * responses: * 200: * description: List of channel videos * 400: - * description: Invalid maxRating + * description: Invalid maxRating or showId */ router.get('/getchannelvideos/:channelId', verifyToken, async (req, res) => { req.log.info({ channelId: req.params.channelId }, 'Getting channel videos'); @@ -940,7 +945,12 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch if (!maxRating.valid) { return res.status(400).json({ error: 'Invalid maxRating' }); } - const result = await channelModule.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating.value); + // Only the episodes of one of the channel's title shows. + const showId = req.query.showId === undefined || req.query.showId === '' ? null : Number(req.query.showId); + if (showId !== null && (!Number.isInteger(showId) || showId <= 0)) { + return res.status(400).json({ error: 'Invalid showId' }); + } + const result = await channelModule.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating.value, showId); if (Array.isArray(result)) { res.status(200).json({ videos: result }); diff --git a/server/routes/index.js b/server/routes/index.js index 2a662155a..708c3d088 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -21,6 +21,7 @@ const createSchedulesRoutes = require('./schedules'); const createLogRoutes = require('./logs'); const createTvShowRoutes = require('./tvShows'); const createTvReorganizeRoutes = require('./tvReorganize'); +const createTvTitleShowRoutes = require('./tvTitleShows'); const videoMetadataModule = require('../modules/videoMetadataModule'); const videoOembedEnricher = require('../modules/videoOembedEnricher'); const playlistModule = require('../modules/playlistModule'); @@ -48,6 +49,7 @@ const logFilesModule = require('../modules/logFilesModule'); const libraryFolders = require('../modules/tvShows/libraryFolders'); const channelLayout = require('../modules/tvShows/channelLayout'); const layoutGuards = require('../modules/tvShows/layoutGuards'); +const titleShowService = require('../modules/tvShows/titleShowService'); const reorganize = require('../modules/reorganize'); const watchStatusHolds = require('../modules/mediaServers/watchStatusHolds'); const watchStatusPushBack = require('../modules/mediaServers/watchStatusPushBack'); @@ -165,6 +167,9 @@ function registerRoutes(app, deps) { libraryCheck, })); + // Title shows (shows defined on a channel by title patterns) and episode assignment + app.use(createTvTitleShowRoutes({ verifyToken, titleShowService, models, layoutGuards })); + // Reorganize (moving downloads between the Videos and TV layouts) and watch-state restores app.use(createTvReorganizeRoutes({ verifyToken, reorganize, watchStatusHolds, watchStatusPushBack })); diff --git a/server/routes/tvTitleShows.js b/server/routes/tvTitleShows.js new file mode 100644 index 000000000..21575801a --- /dev/null +++ b/server/routes/tvTitleShows.js @@ -0,0 +1,450 @@ +const express = require('express'); +const logger = require('../logger'); + +const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/; + +/** + * Title show routes: shows defined on a channel by title patterns, their + * preview, the show-only download switch, duplicates, and per-video episode + * assignment. + * @param {Object} deps + * @param {Function} deps.verifyToken + * @param {Object} deps.titleShowService - tvShows/titleShowService + * @param {Object} deps.models + * @param {Object} deps.layoutGuards - tvShows/layoutGuards (refusal bodies) + * @returns {express.Router} + */ +function createTvTitleShowRoutes({ verifyToken, titleShowService, models, layoutGuards }) { + const router = express.Router(); + + // Refusals carry .status (a change that moves files also names the change + // to preview; a taken folder also suggests a name); anything else is unexpected. + const sendError = (res, error, failure, context) => { + if (error.status) { + return res.status(error.status).json({ ...layoutGuards.errorBody(error), ...(error.details || {}) }); + } + logger.error({ err: error, ...context }, failure); + return res.status(500).json({ error: failure }); + }; + + const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value); + + const parseShowId = (req, res) => { + const showId = Number(req.params.showId); + if (!Number.isInteger(showId) || showId <= 0) { + res.status(400).json({ error: 'showId must be a positive whole number' }); + return null; + } + return showId; + }; + + // Runs handler(channel) for an existing channel, answering 404 otherwise. + const withChannel = (failure, handler) => async (req, res) => { + const { channelId } = req.params; + try { + const channel = await models.Channel.findOne({ where: { channel_id: channelId } }); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + return await handler(channel, req, res); + } catch (error) { + return sendError(res, error, failure, { channelId }); + } + }; + + /** + * @swagger + * /api/channels/{channelId}/tv/shows: + * get: + * summary: List a channel's title shows + * description: The channel's title shows (removed ones included, marked retired) with their patterns (each with its compiled Python regex), exclude terms, season names and counts (episodes, downloaded, duplicates, not supported yet); the channel's duplicate and classification-error conflicts; whether channel downloads are limited to its shows; the TV folders; and the TV folder a new show uses by default (null when one must be chosen). + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * responses: + * 200: { description: The channel's title shows } + * 404: { description: Channel not found } + * 500: { description: Failed to load the channel's shows } + */ + router.get('/api/channels/:channelId/tv/shows', verifyToken, withChannel('Failed to load the channel\'s shows', + async (channel, req, res) => res.json(await titleShowService.getChannelShows(channel)))); + + /** + * @swagger + * /api/channels/{channelId}/tv/shows: + * post: + * summary: Add a title show to a channel + * description: Adds a show after the channel's existing ones and classifies the channel's known videos. Episode numbers come from titles (title), upload order (order) or upload time (date, year seasons only). A change that moves downloaded videos is answered with a reorganizeRequired 409 naming the change (type titleShows) to preview through /api/tv/reorganize/preview. A folder another show uses is a 409 with a suggestion (and retiredShowId when a removed show of this channel can be restored instead). + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, patterns] + * properties: + * name: { type: string } + * folderName: { type: string, description: 'Defaults to the sanitized name' } + * libraryFolder: { type: string, description: 'A TV folder ("" = main folder); defaults to the channel''s TV folder, the default subfolder, or the only TV folder' } + * excludeTerms: { type: array, items: { type: string } } + * seasonNames: { type: object, additionalProperties: { type: string } } + * patterns: + * type: array + * items: + * type: object + * properties: + * text: { type: string, description: 'Simple syntax ({season}, {episode}, {episode_end}, {part}, {title}, *, leading ^) or a Python regex' } + * kind: { type: string, enum: [simple, regex] } + * seasonSource: { type: string, enum: [title, fixed, year] } + * seasonFixed: { type: integer, minimum: 0, maximum: 199 } + * episodeSource: { type: string, enum: [title, date, order] } + * responses: + * 200: { description: The channel's title shows after the change } + * 400: { description: Invalid show or pattern } + * 404: { description: Channel not found } + * 409: { description: "Downloaded videos move (reorganizeRequired), the folder is taken, or a reorganize is moving the channel's files" } + * 500: { description: Failed to add the show } + */ + router.post('/api/channels/:channelId/tv/shows', verifyToken, withChannel('Failed to add the show', async (channel, req, res) => { + if (!isObject(req.body)) return res.status(400).json({ error: 'The body must be a show' }); + return res.json(await titleShowService.createShow(channel, req.body)); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/shows/order: + * put: + * summary: Reorder a channel's title shows + * description: The first show whose pattern matches a title takes the video, so the order decides between shows. Reclassifies the channel's videos; refusals as for adding a show. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [showIds] + * properties: + * showIds: { type: array, items: { type: integer }, description: 'Every active show of the channel, once' } + * responses: + * 200: { description: The channel's title shows after the change } + * 400: { description: The list doesn't name every show once } + * 404: { description: Channel not found } + * 409: { description: Downloaded videos move (reorganizeRequired) or a reorganize is running } + * 500: { description: Failed to reorder the shows } + */ + router.put('/api/channels/:channelId/tv/shows/order', verifyToken, withChannel('Failed to reorder the shows', async (channel, req, res) => { + const showIds = req.body && req.body.showIds; + if (!Array.isArray(showIds)) return res.status(400).json({ error: 'showIds must be a list' }); + return res.json(await titleShowService.reorderShows(channel, showIds)); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/shows/{showId}: + * put: + * summary: Edit a title show + * description: Replaces the show's definition (same body as adding a show) and reclassifies the channel's videos. A video that still matches with the same number keeps it; changed numbers, shows and folders of downloaded videos go through the reorganize (reorganizeRequired 409). + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * - in: path + * name: showId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: The channel's title shows after the change } + * 400: { description: Invalid show or pattern } + * 404: { description: Channel or show not found } + * 409: { description: Downloaded videos move (reorganizeRequired), the folder is taken, or a reorganize is running } + * 500: { description: Failed to save the show } + * delete: + * summary: Remove (retire) a title show + * description: The show's videos fall back to the channel's layout (through the reorganize when downloaded); its order and manual numbers are kept for a restore. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * - in: path + * name: showId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: The channel's title shows after the change } + * 404: { description: Channel or show not found } + * 409: { description: Downloaded videos move (reorganizeRequired) or a reorganize is running } + * 500: { description: Failed to remove the show } + */ + router.put('/api/channels/:channelId/tv/shows/:showId', verifyToken, withChannel('Failed to save the show', async (channel, req, res) => { + const showId = parseShowId(req, res); + if (showId === null) return undefined; + if (!isObject(req.body)) return res.status(400).json({ error: 'The body must be a show' }); + return res.json(await titleShowService.updateShow(channel, showId, req.body)); + })); + + router.delete('/api/channels/:channelId/tv/shows/:showId', verifyToken, withChannel('Failed to remove the show', async (channel, req, res) => { + const showId = parseShowId(req, res); + if (showId === null) return undefined; + return res.json(await titleShowService.retireShow(channel, showId)); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/shows/{showId}/restore: + * post: + * summary: Restore a removed title show + * description: Adds the show back after the channel's active shows, with its folder and kept numbers. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * - in: path + * name: showId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: The channel's title shows after the change } + * 400: { description: The show isn't removed } + * 404: { description: Channel or show not found } + * 409: { description: Downloaded videos move (reorganizeRequired) or a reorganize is running } + * 500: { description: Failed to restore the show } + */ + router.post('/api/channels/:channelId/tv/shows/:showId/restore', verifyToken, withChannel('Failed to restore the show', async (channel, req, res) => { + const showId = parseShowId(req, res); + if (showId === null) return undefined; + return res.json(await titleShowService.restoreShow(channel, showId)); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/shows/{showId}/missing: + * get: + * summary: A title show's missing episodes + * description: Per season, the numbered episodes not downloaded yet and the numbers missing between 1 and the highest one (title- and order-numbered seasons only). + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * - in: path + * name: showId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: The show's seasons with missing episodes } + * 404: { description: Channel or show not found } + * 500: { description: Failed to list the missing episodes } + */ + router.get('/api/channels/:channelId/tv/shows/:showId/missing', verifyToken, withChannel('Failed to list the missing episodes', async (channel, req, res) => { + const showId = parseShowId(req, res); + if (showId === null) return undefined; + return res.json(await titleShowService.missingEpisodes(channel, showId)); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/preview: + * post: + * summary: Preview title shows + * description: Classifies every known video of the channel with draft shows (the channel's whole set, in order) and optional episode assignments, without saving. Returns per show its episodes with download state, plus duplicates with the upload that keeps the number, gaps per season, compilations and parts not supported yet, unmatched videos, stored episodes that would change, how many downloaded videos would move, and each draft's compiled patterns. Lists are capped; counts are complete. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [shows] + * properties: + * shows: { type: array, items: { type: object }, description: 'Draft shows as for adding one; existing shows carry their id' } + * overrides: { type: array, items: { type: object } } + * responses: + * 200: { description: The preview } + * 400: { description: Invalid show or pattern } + * 404: { description: Channel not found } + * 409: { description: A show's folder is taken } + * 500: { description: Failed to preview the shows } + */ + router.post('/api/channels/:channelId/tv/preview', verifyToken, withChannel('Failed to preview the shows', async (channel, req, res) => { + const { shows, overrides = [] } = req.body || {}; + if (!Array.isArray(shows) || !Array.isArray(overrides)) { + return res.status(400).json({ error: 'shows and overrides must be lists' }); + } + return res.json(await titleShowService.preview(channel, { shows, overrides })); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/show-only: + * put: + * summary: Limit channel downloads to the channel's title shows + * description: When on, channel downloads fetch only videos whose title matches one of the channel's title shows (one yt-dlp match filter per show), and Download All queues only videos classified into a show. Ignored while the channel has no shows. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [enabled] + * properties: + * enabled: { type: boolean } + * responses: + * 200: { description: The saved switch } + * 400: { description: enabled is not a boolean } + * 404: { description: Channel not found } + * 500: { description: Failed to save the switch } + */ + router.put('/api/channels/:channelId/tv/show-only', verifyToken, withChannel('Failed to save the switch', async (channel, req, res) => { + const enabled = req.body && req.body.enabled; + if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be true or false' }); + return res.json(await titleShowService.setShowOnly(channel, enabled)); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/conflicts/{youtubeId}/use-copy: + * post: + * summary: Use a duplicate's copy of an episode instead + * description: Gives the duplicate the episode number its holder has (a manual assignment); the holder becomes the duplicate. Through the reorganize (reorganizeRequired 409) when either is downloaded. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * - in: path + * name: youtubeId + * required: true + * schema: { type: string } + * responses: + * 200: { description: The channel's title shows after the change } + * 400: { description: Invalid video id } + * 404: { description: Channel not found, or the video is not a duplicate of the channel } + * 409: { description: Downloaded videos move (reorganizeRequired) or a reorganize is running } + * 500: { description: Failed to use the copy } + */ + router.post('/api/channels/:channelId/tv/conflicts/:youtubeId/use-copy', verifyToken, withChannel('Failed to use the copy', async (channel, req, res) => { + if (!YOUTUBE_ID.test(req.params.youtubeId)) return res.status(400).json({ error: 'Invalid video id' }); + return res.json(await titleShowService.useDuplicateCopy(channel, req.params.youtubeId)); + })); + + /** + * @swagger + * /api/channels/{channelId}/tv/recheck: + * post: + * summary: Classify a channel's titles again + * description: Runs the channel's title shows over its videos again, which clears classification errors (a title that could not be checked when it downloaded). Refusals as for editing a show. + * tags: [TV Shows] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: { type: string } + * responses: + * 200: { description: The channel's title shows after the check } + * 404: { description: Channel not found } + * 409: { description: Downloaded videos move (reorganizeRequired) or a reorganize is running } + * 500: { description: Failed to check the titles } + */ + router.post('/api/channels/:channelId/tv/recheck', verifyToken, withChannel('Failed to check the titles', + async (channel, req, res) => res.json(await titleShowService.recheck(channel)))); + + /** + * @swagger + * /api/videos/{youtubeId}/episode: + * get: + * summary: A video's episode + * description: The video's classification (show, status, season and episode, whether it is marked "Not an episode") and the title shows of its channel it can be assigned to. + * tags: [TV Shows] + * parameters: + * - in: path + * name: youtubeId + * required: true + * schema: { type: string } + * responses: + * 200: { description: The video's episode } + * 400: { description: Invalid video id } + * 500: { description: Failed to load the episode } + * put: + * summary: Assign a video's episode by hand + * description: Puts the video into one of its channel's title shows with the given season and episode (a manual number classification never changes; a title-numbered holder of that number becomes a duplicate), marks it "Not an episode" (it never joins a title show again), or returns it to automatic classification. Through the reorganize (reorganizeRequired 409) when the video is downloaded and its file moves. + * tags: [TV Shows] + * parameters: + * - in: path + * name: youtubeId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * showId: { type: integer } + * season: { type: integer, minimum: 0, maximum: 199 } + * episode: { type: integer, minimum: 1 } + * notAnEpisode: { type: boolean } + * automatic: { type: boolean } + * responses: + * 200: { description: The video's episode after the change } + * 400: { description: Invalid assignment, or the video's channel has no shows } + * 409: { description: The file moves (reorganizeRequired) or a reorganize is running } + * 500: { description: Failed to assign the episode } + */ + router.get('/api/videos/:youtubeId/episode', verifyToken, async (req, res) => { + const { youtubeId } = req.params; + if (!YOUTUBE_ID.test(youtubeId)) return res.status(400).json({ error: 'Invalid video id' }); + try { + return res.json(await titleShowService.getVideoEpisode(youtubeId)); + } catch (error) { + return sendError(res, error, 'Failed to load the episode', { youtubeId }); + } + }); + + router.put('/api/videos/:youtubeId/episode', verifyToken, async (req, res) => { + const { youtubeId } = req.params; + if (!YOUTUBE_ID.test(youtubeId)) return res.status(400).json({ error: 'Invalid video id' }); + if (!isObject(req.body)) return res.status(400).json({ error: 'The body must be an assignment' }); + try { + return res.json(await titleShowService.assignEpisode(youtubeId, req.body)); + } catch (error) { + return sendError(res, error, 'Failed to assign the episode', { youtubeId }); + } + }); + + return router; +} + +module.exports = createTvTitleShowRoutes; diff --git a/server/server.js b/server/server.js index 0eb6ed60a..e27fe7f42 100644 --- a/server/server.js +++ b/server/server.js @@ -264,6 +264,7 @@ const initialize = async () => { const scheduledTaskManager = require('./modules/scheduledTaskManager'); const storageGuard = require('./modules/storageGuard'); const reorganize = require('./modules/reorganize'); + const archiveSuppressor = require('./modules/tvShows/archiveSuppressor'); const mediaServerSync = require('./modules/mediaServers/mediaServerSync'); const watchStatusSync = require('./modules/mediaServers/watchStatusSync'); @@ -358,6 +359,13 @@ const initialize = async () => { logger.error({ err }, 'Could not resume an interrupted reorganize'); }); } + // Title shows suppress duplicate episodes in complete.list, which must not + // change under a running download job: writes wait for the queue to idle. + archiveSuppressor.initialize({ isDownloadRunning: () => Boolean(jobModule.getInProgressJobId()) }); + jobModule.onBeforeNextJob(() => archiveSuppressor.flush()); + archiveSuppressor.flush().catch((err) => { + logger.error({ err }, 'Could not apply pending complete.list changes for duplicate episodes'); + }); subscriptionImportModule.init({ channelModule, jobModule, diff --git a/server/utils/title-filter-regex.py b/server/utils/title-filter-regex.py index bcb899d13..e7a8bae2d 100644 --- a/server/utils/title-filter-regex.py +++ b/server/utils/title-filter-regex.py @@ -1,35 +1,89 @@ #!/usr/bin/env python3 """ -Match titles against a title filter regex with Python's re module, the same -engine yt-dlp uses for `--match-filter "title ~= '...'"` (re.search, case -sensitive unless the pattern sets (?i)). +Match titles against regexes with Python's re module, the same engine yt-dlp +uses for `--match-filter "title ~= '...'"` (re.search, case sensitive unless +the pattern sets (?i)). -Reads one JSON object from stdin: {"pattern": str, "titles": [str, ...]}. -Writes one JSON object to stdout: {"matches": [bool, ...]} or {"error": str}. -An empty titles list just checks that the pattern compiles. +Reads one JSON object from stdin and writes one JSON object to stdout. + +Title filter (no mode): + {"pattern": str, "titles": [str, ...]} -> {"matches": [bool, ...]} + An empty titles list just checks that the pattern compiles. + +Compile check (mode "check"): + {"mode": "check", "patterns": [str, ...]} -> {"errors": [null | str, ...]} + +Title-show classification (mode "classify"): + {"mode": "classify", + "patterns": [{"regex": str, "excludes": [str, ...]}, ...], + "titles": [str, ...]} + -> {"results": [null | {"index": int, "groups": {name: str | null}}, ...]} + Each title gets the first pattern that matches it and none of whose + excludes match it. + +Any failure: {"error": str}. """ import json import re import sys -def main(): +def title_filter(request): + pattern = request["pattern"] + titles = request.get("titles") or [] try: - request = json.loads(sys.stdin.buffer.read().decode("utf-8")) - pattern = request["pattern"] - titles = request.get("titles") or [] - except (ValueError, KeyError, TypeError) as e: - print(json.dumps({"error": f"Invalid request: {e}"})) - return + regex = re.compile(pattern) + except re.error as e: + return {"error": f"Invalid regex pattern: {e}"} + return {"matches": [regex.search(title) is not None for title in titles]} + + +def check(request): + errors = [] + for pattern in request["patterns"]: + try: + re.compile(pattern) + errors.append(None) + except re.error as e: + errors.append(str(e)) + return {"errors": errors} + +def classify(request): + compiled = [] try: - regex = re.compile(pattern) + for entry in request["patterns"]: + compiled.append(( + re.compile(entry["regex"]), + [re.compile(exclude) for exclude in entry.get("excludes") or []], + )) except re.error as e: - print(json.dumps({"error": f"Invalid regex pattern: {e}"})) - return + return {"error": f"Invalid regex pattern: {e}"} + + results = [] + for title in request.get("titles") or []: + result = None + for index, (regex, excludes) in enumerate(compiled): + match = regex.search(title) + if match is None or any(exclude.search(title) for exclude in excludes): + continue + result = {"index": index, "groups": match.groupdict()} + break + results.append(result) + return {"results": results} + + +MODES = {"check": check, "classify": classify} - matches = [regex.search(title) is not None for title in titles] - print(json.dumps({"matches": matches})) + +def main(): + try: + request = json.loads(sys.stdin.buffer.read().decode("utf-8")) + handler = MODES[request["mode"]] if "mode" in request else title_filter + response = handler(request) + except (ValueError, KeyError, TypeError) as e: + response = {"error": f"Invalid request: {e}"} + print(json.dumps(response)) if __name__ == "__main__": From 580cfa2c91dc7fe116cd81b16a1f67fb47a29779 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Mon, 5 Oct 2026 10:02:51 -0700 Subject: [PATCH 07/12] fix(tv): handle renamed files and year-season gaps Follow-up fixes from the local checks of title shows and the reorganize: - The per-page file check showed videos as missing when their file had been renamed in place (for example to a TV episode name) but kept its [id]. It now also looks in the stored folder for a media file carrying the video's id, skips macOS AppleDouble copies, and lists each folder once per batch. - Gaps and Missing episodes listed long runs of missing numbers for seasons numbered by upload year. Those seasons are skipped, as date-numbered seasons already were. - A show's duplicate count comes from its recorded conflicts, so a TV channel's episode that lost a title claim, and kept its channel-show row, is counted. - The reorganize preview notes MP3 files moving from a Videos folder into a TV folder, where TV libraries don't show them. - After a move applies a default subfolder change, Settings asks to save again only when the form still has other unsaved changes. Refs: #175 --- CLAUDE.md | 2 +- client/src/components/Settings/Settings.tsx | 10 +- .../__tests__/defaultSubfolderMove.test.ts | 18 ++++ .../Settings/defaultSubfolderMove.ts | 15 +++ .../Reorganize/ReorganizePreviewBody.tsx | 6 +- .../__tests__/ReorganizeDialog.test.tsx | 2 +- .../__tests__/ReorganizePreviewBody.test.tsx | 8 +- client/src/types/reorganize.ts | 2 + docs/USAGE_GUIDE.md | 4 +- .../modules/__tests__/fileCheckModule.test.js | 98 ++++++++++++++++++- server/modules/__tests__/videosModule.test.js | 4 +- server/modules/fileCheckModule.js | 45 ++++++++- .../__tests__/destinationPlanner.test.js | 25 +++++ .../reorganize/__tests__/planner.test.js | 8 ++ server/modules/reorganize/constants.js | 2 + .../modules/reorganize/destinationPlanner.js | 3 + server/modules/reorganize/planner.js | 1 + .../tvShows/__tests__/titleNumbering.test.js | 11 +++ .../__tests__/titleShowQueries.test.js | 11 +++ server/modules/tvShows/constants.js | 6 +- server/modules/tvShows/episodeConflicts.js | 2 +- server/modules/tvShows/titleNumbering.js | 6 +- server/modules/tvShows/titleShowQueries.js | 15 ++- 23 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts create mode 100644 client/src/components/Settings/defaultSubfolderMove.ts diff --git a/CLAUDE.md b/CLAUDE.md index f4bb75637..f297d8637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`<library folder>/<show>/Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. -- `modules/tvShows/` (TV show support: channel shows, title shows, and the reorganize): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. **Title shows** (a series inside one channel, `tv_shows.kind = 'title'`, numbered from its titles; classification decides, the match filter only screens downloads): `showFolderNames` (dependency-free folder-name rules, shared with `showStore`), `patternCompiler` (simple syntax (`{season}`/`{episode}` as `[0-9]+`, `{title}`, `*`, `^`; `{episode_end}`/`{part}` recognized as unsupported) or regex mode -> a Python regex with named groups plus its filter form (groups unnamed, leading flags scoped); seasons 0..199), `titleMatcher` (one Python batch per channel through `titleFilterRegex.classifyTitles`: first show by position, then pattern, without an exclude term; a number the pattern takes from the title but the title skipped (an optional regex group) is `unsupported`, reason `missing-number`; a pending match keeps its title episode for the reorganize as `titleEpisode`), `titleNumbering` (pure: stored rows + matches -> rows with statuses `assigned`/`pending_number`/`duplicate`/`unsupported`/`opted_out`/`error`; the stored holder of a title number keeps it while it still claims it and is available or downloaded, else the earliest available upload; a manual override takes its number from any holder, which is then classified as if it held nothing; a TV channel's episode that loses a title claim keeps its channel-show row; `order` numbers past `tv_show_seasons.order_high_water`, never reissued; year seasons and date episodes wait for the download (`pending_number`), except that a downloaded video's upload year is known (`Videos.originalDate`), so a year season with a title episode is numbered at plan time and a downloaded duplicate the post-processor recorded keeps that verdict, since the planner never has the upload time to redo it; manual and opted-out rows are never touched; gaps), `titleShowDrafts` (validates and compiles a channel's whole set of shows; keys `title:<id>`/`new:<n>`), `titleShowStore` (rows, patterns, seasons, retire/restore, folder availability, show filters for downloads), `titlePlanner` (a channel's plan after a change, for every listed and downloaded video and every row classified for the channel; the preview, direct saves and the reorganize all plan through `planChannel`; a download outside the downloads folder never moves (`staysOutside`, counted in the preview)), `titlePreview` (the editor's tabs), `titleRowWriter` (writes a plan in one transaction, numbers released before they are retaken, each row only while it is as planned: `ROW_CHANGED` otherwise, and the saver plans again once; conflicts are released by the channel's recorded duplicates), `titleShowSaver` (saves directly when no downloaded video's show, number or stem changes, else answers `reorganizeRequired` with a `titleShows` change; listing refreshes classify only new rows through `classifyNew`), `titleShowService` (the API's operations as drafts plus overrides: create, edit, retire, restore, reorder, manual episode, "Not an episode" (title show episodes only), back to automatic, "Use this copy instead", recheck; after each save it rewrites the channel's title show NFO files, which name and season-name edits change without moving a file; the reorganize follow-up does the same for a `titleShows` change), `titleShowQueries` (counts, `/getchannels` `titleShows`, missing episodes, the channel page's show filter, the planned episode chip of not-downloaded videos), `episodeConflicts` (duplicates and classification errors; a not-downloaded duplicate is ignored and suppressed in `complete.list`, and only what Youtarr did is undone on release: the ignore it set (`youtarr_ignored`) and the archive line it wrote (`archive_suppressed`, false when the line was already there, e.g. a deleted download's); a downloaded duplicate is never ignored or deleted; duplicates a listing refresh finds are ignored too), `archiveSuppressor` (the deferred `complete.list` writer: `archive_pending` on the conflict row, applied when no download job is In Progress, through `jobModule.onBeforeNextJob` and at startup, because channel jobs count archive lines), `titleEpisodeAssigner` (the post-processor's step: a stored episode is reused without Python; otherwise the full title is classified at finalize; duplicates, unsupported, opted-out and errors fall back to the channel layout and are recorded), and `tvNfo`'s title show output (`<uniqueid type="youtarr">` + custom UUID, `<namedseason>`, `season.nfo` per named season). The show-only switch (`channels.tv_show_only_downloads`) adds one match filter per show to channel downloads and limits Download All to classified videos (`assigned`/`pending_number`); it does nothing while the channel has no active show. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`) and `routes/tvTitleShows.js` (`/api/channels/:channelId/tv/shows` list/create/edit/retire/restore/order and `/:showId/missing`, `POST .../tv/preview`, `PUT .../tv/show-only`, `POST .../tv/conflicts/:youtubeId/use-copy`, `POST .../tv/recheck`, `GET`/`PUT /api/videos/:youtubeId/episode`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json` (`titleCorpusClassification.test.js` runs them through the real Python matcher). +- `modules/tvShows/` (TV show support: channel shows, title shows, and the reorganize): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. **Title shows** (a series inside one channel, `tv_shows.kind = 'title'`, numbered from its titles; classification decides, the match filter only screens downloads): `showFolderNames` (dependency-free folder-name rules, shared with `showStore`), `patternCompiler` (simple syntax (`{season}`/`{episode}` as `[0-9]+`, `{title}`, `*`, `^`; `{episode_end}`/`{part}` recognized as unsupported) or regex mode -> a Python regex with named groups plus its filter form (groups unnamed, leading flags scoped); seasons 0..199), `titleMatcher` (one Python batch per channel through `titleFilterRegex.classifyTitles`: first show by position, then pattern, without an exclude term; a number the pattern takes from the title but the title skipped (an optional regex group) is `unsupported`, reason `missing-number`; a pending match keeps its title episode for the reorganize as `titleEpisode`), `titleNumbering` (pure: stored rows + matches -> rows with statuses `assigned`/`pending_number`/`duplicate`/`unsupported`/`opted_out`/`error`; the stored holder of a title number keeps it while it still claims it and is available or downloaded, else the earliest available upload; a manual override takes its number from any holder, which is then classified as if it held nothing; a TV channel's episode that loses a title claim keeps its channel-show row; `order` numbers past `tv_show_seasons.order_high_water`, never reissued; year seasons and date episodes wait for the download (`pending_number`), except that a downloaded video's upload year is known (`Videos.originalDate`), so a year season with a title episode is numbered at plan time and a downloaded duplicate the post-processor recorded keeps that verdict, since the planner never has the upload time to redo it; manual and opted-out rows are never touched; gaps, none for date or upload-year seasons), `titleShowDrafts` (validates and compiles a channel's whole set of shows; keys `title:<id>`/`new:<n>`), `titleShowStore` (rows, patterns, seasons, retire/restore, folder availability, show filters for downloads), `titlePlanner` (a channel's plan after a change, for every listed and downloaded video and every row classified for the channel; the preview, direct saves and the reorganize all plan through `planChannel`; a download outside the downloads folder never moves (`staysOutside`, counted in the preview)), `titlePreview` (the editor's tabs), `titleRowWriter` (writes a plan in one transaction, numbers released before they are retaken, each row only while it is as planned: `ROW_CHANGED` otherwise, and the saver plans again once; conflicts are released by the channel's recorded duplicates), `titleShowSaver` (saves directly when no downloaded video's show, number or stem changes, else answers `reorganizeRequired` with a `titleShows` change; listing refreshes classify only new rows through `classifyNew`), `titleShowService` (the API's operations as drafts plus overrides: create, edit, retire, restore, reorder, manual episode, "Not an episode" (title show episodes only), back to automatic, "Use this copy instead", recheck; after each save it rewrites the channel's title show NFO files, which name and season-name edits change without moving a file; the reorganize follow-up does the same for a `titleShows` change), `titleShowQueries` (counts, `/getchannels` `titleShows`, missing episodes, the channel page's show filter, the planned episode chip of not-downloaded videos), `episodeConflicts` (duplicates and classification errors; a not-downloaded duplicate is ignored and suppressed in `complete.list`, and only what Youtarr did is undone on release: the ignore it set (`youtarr_ignored`) and the archive line it wrote (`archive_suppressed`, false when the line was already there, e.g. a deleted download's); a downloaded duplicate is never ignored or deleted; duplicates a listing refresh finds are ignored too), `archiveSuppressor` (the deferred `complete.list` writer: `archive_pending` on the conflict row, applied when no download job is In Progress, through `jobModule.onBeforeNextJob` and at startup, because channel jobs count archive lines), `titleEpisodeAssigner` (the post-processor's step: a stored episode is reused without Python; otherwise the full title is classified at finalize; duplicates, unsupported, opted-out and errors fall back to the channel layout and are recorded), and `tvNfo`'s title show output (`<uniqueid type="youtarr">` + custom UUID, `<namedseason>`, `season.nfo` per named season). The show-only switch (`channels.tv_show_only_downloads`) adds one match filter per show to channel downloads and limits Download All to classified videos (`assigned`/`pending_number`); it does nothing while the channel has no active show. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`) and `routes/tvTitleShows.js` (`/api/channels/:channelId/tv/shows` list/create/edit/retire/restore/order and `/:showId/missing`, `POST .../tv/preview`, `PUT .../tv/show-only`, `POST .../tv/conflicts/:youtubeId/use-copy`, `POST .../tv/recheck`, `GET`/`PUT /api/videos/:youtubeId/episode`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json` (`titleCorpusClassification.test.js` runs them through the real Python matcher). - `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder), or when a change to a channel's title shows moves downloaded videos (`titleShows` change: the show definitions and every classification row of the channel are written at the start, with a `titleSnapshot` of the previous ones in `settings_change`, restored when no video moved (deleting the shows the change created); title shows are planned under show keys `title:<id>`/`new:<n>`, channel shows under their owner's channel id; `titleTargets` keeps episodes of active title shows in place for every other change type and a folder holding title shows can't switch to Videos (400); `titleAssignments` numbers videos moving into a title show, upload-time numbers from the stored info.json as the post-processor would). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings; items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. - `modules/mediaServers/watchStatusHolds.js` + `watchStatusPushBack.js`: watch state a reorganize protects. Before files move, each watched or in-progress `video_watch_status` row (Plex: the owner only; other accounts come from play history, which a move never resets) is snapshotted into `watch_status_holds`; `watchStatusSync` passes every server's matches through `applyHolds`, which drops downgrades of held rows (the moved item looks new and unwatched, and until a rescan the server lists the old path, which the current-copy rule rejects) and ends a hold when the item at the video's current path (matching it with more trailing segments than the path the video came from, kept in the snapshot as `fromPath`: between TV folders the stale item shares the show, season and file names) shows at least the held state or a newer one. A push stamps the server item with the push time: a last-watched time within 10 minutes of the hold's last actual write (`last_pushed_at`; an attempt that found the state already there writes nothing and leaves no echo) is that echo, not a watch, so the row keeps its historical play count and last-watched time (restored holds are consulted for this too), while a later real watch is written as is. Re-holding a row for a later reorganize resets its push record (the earlier push went to the earlier location). Push-back first reads the user's state of the item (`getWatchState`: Plex `/library/metadata/{key}` as the owner, Jellyfin and Emby `/Users/{id}/Items/{id}`; `watchStateCompare` holds the pure `isAtLeast`/`isLaterWatch` the sync uses too) and leaves a state at least as watched, or watched since the snapshot, alone; otherwise it writes the state through the adapters' `setWatchState` (Plex `/:/scrobble` and `/:/progress` as the owner; Jellyfin `/UserPlayedItems` and `/UserItems/{id}/UserData`, falling back to the legacy `/Users/{id}/...` routes; Emby `/Users/{id}/PlayedItems` and `/Users/{id}/Items/{id}/UserData`), finding items with `resolveItemMatchesByPaths` (an item is skipped only when the same item answers for both the new and the old path and fits the old one at least as well, i.e. the stale item before the server rescans; two different items mean the new file is indexed; an explicit retry takes whatever matches); a hold is pushed once, and again only when a sync finds the item at the new path below the held state (`applyHolds` records that as the hold's error), since a blind re-push could undo a watch made in between. It runs 1, 5 and 15 minutes after a reorganize and at the end of every sync, an hour apart per hold. Holds not restored within 14 days become failed (still protecting the row) until retried or dismissed (`GET /api/tv/holds`, `POST /api/tv/holds/:id/retry|dismiss`). - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 725cb627a..5581be104 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -40,6 +40,7 @@ import { useYouTubeApiKey } from '../Configuration/hooks/useYouTubeApiKey'; import { useStorageStatus } from '../../hooks/useStorageStatus'; import { useConfig } from '../../hooks/useConfig'; import { TRACKABLE_CONFIG_KEYS } from '../../config/configSchema'; +import { defaultSubfolderMoveNotice } from './defaultSubfolderMove'; import { ConfigState, SnackbarState } from '../Configuration/types'; import { validateConfig } from '../Configuration/utils/configValidation'; import { FILENAME_PRESETS } from '../../utils/filenameTemplate/presets'; @@ -196,15 +197,12 @@ export function Settings({ token }: SettingsProps) { const handleDefaultSubfolderMoved = useCallback((result: ReorganizeStartResult) => { if (!reorganizeChange || reorganizeChange.type !== 'defaultSubfolder') return; const value = reorganizeChange.value; + const notice = initialConfig ? defaultSubfolderMoveNotice(config, initialConfig, value) : null; setInitialConfig((current) => (current ? { ...current, defaultSubfolder: value } : current)); formDefaultSetByMove.current = value; if (result.operationId) setTrackedMove({ operationId: result.operationId, requested: value, attempt: 0 }); - setSnackbar({ - open: true, - message: 'The default subfolder changed. Save again to apply your other changes.', - severity: 'info', - }); - }, [reorganizeChange, setInitialConfig]); + if (notice) setSnackbar({ open: true, message: notice, severity: 'info' }); + }, [reorganizeChange, setInitialConfig, config, initialConfig]); // The server undoes the change when no video could be moved, and applies // it again when a retry moves some: the form follows the value read back, diff --git a/client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts b/client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts new file mode 100644 index 000000000..68b06e9ba --- /dev/null +++ b/client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts @@ -0,0 +1,18 @@ +import { DEFAULT_CONFIG } from '../../../config/configSchema'; +import { defaultSubfolderMoveNotice } from '../defaultSubfolderMove'; + +describe('defaultSubfolderMoveNotice', () => { + const saved = { ...DEFAULT_CONFIG, defaultSubfolder: 'Videos' }; + + test('asks to save again when another setting is unsaved', () => { + const form = { ...saved, defaultSubfolder: 'TV', defaultSkipVideoFolder: !saved.defaultSkipVideoFolder }; + expect(defaultSubfolderMoveNotice(form, saved, 'TV')).toBe( + 'The default subfolder changed. Save again to apply your other changes.' + ); + }); + + test('says nothing when the default subfolder was the only change', () => { + const form = { ...saved, defaultSubfolder: 'TV' }; + expect(defaultSubfolderMoveNotice(form, saved, 'TV')).toBeNull(); + }); +}); diff --git a/client/src/components/Settings/defaultSubfolderMove.ts b/client/src/components/Settings/defaultSubfolderMove.ts new file mode 100644 index 000000000..d0d83f433 --- /dev/null +++ b/client/src/components/Settings/defaultSubfolderMove.ts @@ -0,0 +1,15 @@ +import isEqual from 'lodash/isEqual'; +import { ConfigState, TRACKABLE_CONFIG_KEYS } from '../../config/configSchema'; + +const SAVE_AGAIN_NOTICE = 'The default subfolder changed. Save again to apply your other changes.'; + +/** + * What to tell the user once a move applied the default subfolder change: + * the rest of the form is saved by saving again, so ask only when it holds + * other unsaved changes. + */ +export function defaultSubfolderMoveNotice(form: ConfigState, saved: ConfigState, movedTo: string): string | null { + const savedAfterMove = { ...saved, defaultSubfolder: movedTo }; + const otherChanges = TRACKABLE_CONFIG_KEYS.some((key) => !isEqual(form[key], savedAfterMove[key])); + return otherChanges ? SAVE_AGAIN_NOTICE : null; +} diff --git a/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx index 629e6b30d..2992700fd 100644 --- a/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx +++ b/client/src/components/shared/Reorganize/ReorganizePreviewBody.tsx @@ -59,7 +59,7 @@ function summaryLines(preview: ReorganizePreview): string[] { function noteLines(preview: ReorganizePreview): string[] { const { totals } = preview; const notes: string[] = []; - const { overridePlaced, adopted, uploadDateOnly, downloadTime, movieTags } = totals; + const { overridePlaced, adopted, uploadDateOnly, downloadTime, movieTags, audioToTv } = totals; if (overridePlaced > 0) { notes.push(`${countOf(overridePlaced, 'video')} downloaded to another folder with a download override ` + `${agree(overridePlaced, 'moves', 'move')} with the channel.`); @@ -77,6 +77,10 @@ function noteLines(preview: ReorganizePreview): string[] { + `${agree(downloadTime, 'it is', 'they are')} numbered by download time.`); } if (movieTags > 0) notes.push(`${countOf(movieTags, 'video')} ${agree(movieTags, 'keeps', 'keep')} ${MOVIE_TAGS_NOTE}`); + if (audioToTv > 0) { + notes.push(`${countOf(audioToTv, 'MP3 file')} ${agree(audioToTv, 'moves', 'move')} into a TV folder, ` + + `where TV libraries don't show ${agree(audioToTv, 'it', 'them')}.`); + } return notes; } diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx index 2fe49654c..0cf8f093d 100644 --- a/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizeDialog.test.tsx @@ -19,7 +19,7 @@ const PREVIEW = { change: { type: 'channel', channelId: 'UC1', subFolder: 'TV', label: 'Chan' }, totals: { videos: 2, toTv: 2, toVideos: 0, betweenFolders: 0, unchanged: 0, missing: 0, collisions: 0, noName: 0, noDate: 0, - overridePlaced: 0, adopted: 0, uploadDateOnly: 0, downloadTime: 0, movieTags: 0, + overridePlaced: 0, adopted: 0, uploadDateOnly: 0, downloadTime: 0, movieTags: 0, audioToTv: 0, }, shows: [], items: [], diff --git a/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx index 610d0d7ef..b670b83fa 100644 --- a/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx +++ b/client/src/components/shared/Reorganize/__tests__/ReorganizePreviewBody.test.tsx @@ -6,7 +6,7 @@ import { ReorganizePreview } from '../../../../types/reorganize'; const TOTALS = { videos: 2, toTv: 2, toVideos: 0, betweenFolders: 0, unchanged: 1, missing: 0, collisions: 0, noName: 0, noDate: 0, - unsafeName: 0, overridePlaced: 0, adopted: 0, uploadDateOnly: 0, downloadTime: 0, movieTags: 0, + unsafeName: 0, overridePlaced: 0, adopted: 0, uploadDateOnly: 0, downloadTime: 0, movieTags: 0, audioToTv: 0, }; const preview = (overrides: Partial<ReorganizePreview> = {}): ReorganizePreview => ({ @@ -122,6 +122,12 @@ describe('ReorganizePreviewBody', () => { expect(screen.getByText(/^1 video keeps movie tags inside the video file\./)).toBeInTheDocument(); }); + test('says how many MP3 files move into a TV folder', () => { + render(<ReorganizePreviewBody preview={preview({ totals: { ...TOTALS, audioToTv: 2 } })} />); + + expect(screen.getByText(/2 MP3 files move into a TV folder, where TV libraries don't show them\./)).toBeInTheDocument(); + }); + describe('media server libraries', () => { const libraryCheck = (status: 'ok' | 'missing') => ({ servers: [{ serverType: 'jellyfin' as const, name: 'Jellyfin', reachable: true, error: null }], diff --git a/client/src/types/reorganize.ts b/client/src/types/reorganize.ts index 159419b58..6e04835cd 100644 --- a/client/src/types/reorganize.ts +++ b/client/src/types/reorganize.ts @@ -62,6 +62,8 @@ export interface ReorganizeTotals { uploadDateOnly: number; downloadTime: number; movieTags: number; + /** Videos whose MP3 file goes into a TV folder */ + audioToTv: number; } export interface ReorganizeWatchState { diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index bc089c71a..e2c789258 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -504,7 +504,7 @@ Open the channel page, click the settings icon (gear), open **TV Show**, and und - **Exclude titles containing**: a title with any of these words never joins the show, for example `Official Clip`. - **Season names**: written to the show's NFO files, so the media server shows "Season 2: V-Force" instead of "Season 2". Upload-year seasons (such as 2024) can be named too. -The preview updates as you type: **Episodes** (with the episode each video would get, and whether it's downloaded), **Duplicates**, **Gaps** (numbers no video has), **Unmatched videos**, and **Not supported** (compilations such as `Ep.19 ... Ep.20` and parts such as `Episode 1 Part 2`, which Youtarr can't place yet, and titles missing a number the pattern needs). Downloads whose files are outside the downloads folder join a show but their files stay where they are. When several shows match a title, the first in the list takes the video; the arrows in the list reorder the shows. +The preview updates as you type: **Episodes** (with the episode each video would get, and whether it's downloaded), **Duplicates**, **Gaps** (numbers no video has; none for seasons numbered by upload year or date), **Unmatched videos**, and **Not supported** (compilations such as `Ep.19 ... Ep.20` and parts such as `Episode 1 Part 2`, which Youtarr can't place yet, and titles missing a number the pattern needs). Downloads whose files are outside the downloads folder join a show but their files stay where they are. When several shows match a title, the first in the list takes the video; the arrows in the list reorder the shows. When saving would move downloaded videos (into the show, between shows, or out of one), the same **Review the move** dialog opens first. Changing a show that only affects videos you haven't downloaded saves right away, and a new show name or season name is written to the show's NFO files on save. **Remove** retires a show: its downloaded videos move back to the channel's layout. **Restore** (under **Removed shows**) brings it back. @@ -514,7 +514,7 @@ When saving would move downloaded videos (into the show, between shows, or out o **Only download videos that belong to a show**: with this switch on, the channel's automatic downloads and **Download All** fetch only videos that match one of its shows. It does nothing while the channel has no shows. -On the channel page, a **N shows** chip appears in the header and on Subscriptions, and the video list gets a show filter (**All videos** or one show) with **Missing episodes**: per season, the episodes not downloaded yet and the numbers no video has. +On the channel page, a **N shows** chip appears in the header and on Subscriptions, and the video list gets a show filter (**All videos** or one show) with **Missing episodes**: per season, the episodes not downloaded yet and the numbers no video has (none for seasons numbered by upload year or date). ## Browse and Filter Channel Videos diff --git a/server/modules/__tests__/fileCheckModule.test.js b/server/modules/__tests__/fileCheckModule.test.js index 75991d7f9..aa5165360 100644 --- a/server/modules/__tests__/fileCheckModule.test.js +++ b/server/modules/__tests__/fileCheckModule.test.js @@ -2,7 +2,8 @@ jest.mock('fs', () => ({ promises: { - stat: jest.fn() + stat: jest.fn(), + readdir: jest.fn() } })); @@ -16,6 +17,7 @@ describe('FileCheckModule', () => { jest.clearAllMocks(); mockFs = require('fs').promises; + mockFs.readdir.mockResolvedValue([]); // Mock the Video model mockVideo = { @@ -465,6 +467,100 @@ describe('FileCheckModule', () => { expect(result.updates[0].fileSize).toBeUndefined(); }); + test('finds a renamed file by the video id in its name in the same folder', async () => { + const renamed = '/videos/channel/S2024E03161200 The Farm Life [An3UrW9P58Y].mp4'; + const videos = [{ id: 1, youtubeId: 'An3UrW9P58Y', filePath: '/videos/channel/The Farm Life [An3UrW9P58Y].mp4', fileSize: '1000', removed: false }]; + mockFs.stat.mockImplementation(async (p) => { + if (p === renamed) return { size: 1000, isFile: () => true }; + throw { code: 'ENOENT' }; + }); + mockFs.readdir.mockResolvedValue([ + 'S2024E03161200 The Farm Life [An3UrW9P58Y].jpg', + 'Other [zzzzzzzzzzz].mp4', + 'S2024E03161200 The Farm Life [An3UrW9P58Y].mp4', + ]); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([{ id: 1, filePath: renamed, fileSize: 1000 }]); + }); + + test('takes the video file, not its macOS AppleDouble copy', async () => { + const renamed = '/videos/channel/S2024E03161200 The Farm Life [An3UrW9P58Y].mp4'; + const videos = [{ id: 1, youtubeId: 'An3UrW9P58Y', filePath: '/videos/channel/The Farm Life [An3UrW9P58Y].mp4', fileSize: '1000', removed: false }]; + mockFs.stat.mockImplementation(async (p) => { + if (p === renamed) return { size: 1000, isFile: () => true }; + if (p === '/videos/channel/._S2024E03161200 The Farm Life [An3UrW9P58Y].mp4') return { size: 4096, isFile: () => true }; + throw { code: 'ENOENT' }; + }); + mockFs.readdir.mockResolvedValue([ + '._S2024E03161200 The Farm Life [An3UrW9P58Y].mp4', + 'S2024E03161200 The Farm Life [An3UrW9P58Y].mp4', + ]); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([{ id: 1, filePath: renamed, fileSize: 1000 }]); + }); + + test('marks the video removed when only an AppleDouble copy carries its id', async () => { + const videos = [{ id: 1, youtubeId: 'abc123', filePath: '/videos/channel/video [abc123].mp4', removed: false }]; + mockFs.stat.mockImplementation(async (p) => { + if (p === '/videos/channel/._video [abc123].mp4') return { size: 4096, isFile: () => true }; + throw { code: 'ENOENT' }; + }); + mockFs.readdir.mockResolvedValue(['._video [abc123].mp4']); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([{ id: 1, removed: true }]); + }); + + test('does not take a folder named like the video', async () => { + const videos = [{ id: 1, youtubeId: 'abc123', filePath: '/videos/channel/video [abc123].mp4', removed: false }]; + mockFs.stat.mockImplementation(async (p) => { + if (p === '/videos/channel/Renamed [abc123].mp4') return { size: 4096, isFile: () => false }; + throw { code: 'ENOENT' }; + }); + mockFs.readdir.mockResolvedValue(['Renamed [abc123].mp4']); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([{ id: 1, removed: true }]); + }); + + test('lists a folder once for every missing file in it', async () => { + const videos = [ + { id: 1, youtubeId: 'aaaaaaaaaaa', filePath: '/videos/channel/A [aaaaaaaaaaa].mp4', audioFilePath: '/videos/channel/A [aaaaaaaaaaa].mp3', removed: false }, + { id: 2, youtubeId: 'bbbbbbbbbbb', filePath: '/videos/channel/B [bbbbbbbbbbb].mp4', removed: false }, + ]; + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + await fileCheckModule.checkVideoFiles(videos); + + expect(mockFs.readdir).toHaveBeenCalledTimes(1); + }); + + test('marks the video removed when its folder is gone', async () => { + const videos = [{ id: 1, youtubeId: 'abc123', filePath: '/videos/gone/video [abc123].mp4', removed: false }]; + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + mockFs.readdir.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([{ id: 1, removed: true }]); + }); + + test('leaves the removed status alone when its folder cannot be read', async () => { + const videos = [{ id: 1, youtubeId: 'abc123', filePath: '/videos/locked/video [abc123].mp4', removed: false }]; + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + mockFs.readdir.mockRejectedValue({ code: 'EACCES' }); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([]); + }); + test('should not flip removed status when stat fails with non-ENOENT error', async () => { const videos = [ { diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index 93b7b55f7..cec7f5bd4 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -656,8 +656,10 @@ describe('VideosModule', () => { mockVideo.aggregate.mockResolvedValue([]); // Mock file does not exist; fileCheckModule's same-dir fallback will - // also try .webm/.mkv/.m4v/.avi variants, all of which must ENOENT. + // also try .webm/.mkv/.m4v/.avi variants, all of which must ENOENT, + // and then look for the video's [id] in a folder that doesn't hold it. mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + mockFs.readdir.mockResolvedValue([]); const result = await VideosModule.getVideosPaginated(); diff --git a/server/modules/fileCheckModule.js b/server/modules/fileCheckModule.js index 51b31220b..67c38f1af 100644 --- a/server/modules/fileCheckModule.js +++ b/server/modules/fileCheckModule.js @@ -1,7 +1,8 @@ const fs = require('fs').promises; const path = require('path'); const { Video } = require('../models'); -const { VIDEO_EXTENSIONS, AUDIO_EXTENSIONS } = require('./filesystem/constants'); +const { VIDEO_EXTENSIONS, AUDIO_EXTENSIONS, APPLEDOUBLE_FILE_PATTERN } = require('./filesystem/constants'); +const { isFileForVideo } = require('./filesystem/pathBuilder'); const createLimiter = require('./subscriptionImport/concurrencyLimiter'); // Per-video checks run concurrently up to this bound: each stat costs a full @@ -12,7 +13,8 @@ const MAX_CONCURRENT_FILE_CHECKS = 16; /** * Check file existence and update video metadata. * Real-time per-page check: stats the stored path, falls back to same-dir - * same-basename files with any supported extension if the original is missing. + * same-basename files with any supported extension if the original is missing, + * then to a same-dir media file named with the video's [id]. */ class FileCheckModule { /** @@ -25,7 +27,7 @@ class FileCheckModule { * { exists: false, statusKnown: true } - definitively missing * { exists: false, statusKnown: false } - non-ENOENT error */ - async _findExistingMediaFile(originalPath, extensionList) { + async _findExistingMediaFile(originalPath, extensionList, youtubeId, listings = new Map()) { try { const stats = await fs.stat(originalPath); return { exists: true, replaced: false, path: originalPath, size: stats.size }; @@ -56,6 +58,37 @@ class FileCheckModule { } } + return this._findByVideoId(dir, youtubeId, extensionList, listings); + } + + /** + * A file renamed in place (e.g. to a TV naming scheme) keeps its [id]: + * look for a media file carrying it in the stored folder, as the rescan does. + * `listings` (folder -> listing promise) lists each folder once per batch. + */ + async _findByVideoId(dir, youtubeId, extensionList, listings) { + if (!youtubeId) return { exists: false, statusKnown: true }; + if (!listings.has(dir)) listings.set(dir, fs.readdir(dir)); + let names; + try { + names = await listings.get(dir); + } catch (err) { + return { exists: false, statusKnown: err.code === 'ENOENT' || err.code === 'ENOTDIR' }; + } + const extensions = new Set(extensionList.map((ext) => ext.toLowerCase())); + // isFileForVideo also matches macOS AppleDouble copies ("._<name>"), which + // keep the media extension and sort first. + const candidates = names.filter((entry) => !APPLEDOUBLE_FILE_PATTERN.test(entry) + && extensions.has(path.extname(entry).toLowerCase()) && isFileForVideo(entry, youtubeId)); + for (const name of candidates) { + const candidatePath = path.join(dir, name); + try { + const stats = await fs.stat(candidatePath); + if (stats.isFile()) return { exists: true, replaced: true, path: candidatePath, size: stats.size }; + } catch (err) { + if (err.code !== 'ENOENT') return { exists: false, statusKnown: false }; + } + } return { exists: false, statusKnown: true }; } @@ -65,6 +98,8 @@ class FileCheckModule { // One slot per video keeps `updates` in input order no matter which // check finishes first. const updateSlots = new Array(updatedVideos.length).fill(null); + // Missing files of one folder share its listing. + const listings = new Map(); await Promise.all(updatedVideos.map((video, i) => limit(async () => { const update = { id: video.id }; @@ -75,7 +110,7 @@ class FileCheckModule { let audioFileStatusKnown = !video.audioFilePath; if (video.filePath) { - const result = await this._findExistingMediaFile(video.filePath, VIDEO_EXTENSIONS); + const result = await this._findExistingMediaFile(video.filePath, VIDEO_EXTENSIONS, video.youtubeId, listings); if (result.exists) { videoFileExists = true; videoFileStatusKnown = true; @@ -95,7 +130,7 @@ class FileCheckModule { } if (video.audioFilePath) { - const result = await this._findExistingMediaFile(video.audioFilePath, AUDIO_EXTENSIONS); + const result = await this._findExistingMediaFile(video.audioFilePath, AUDIO_EXTENSIONS, video.youtubeId, listings); if (result.exists) { audioFileExists = true; audioFileStatusKnown = true; diff --git a/server/modules/reorganize/__tests__/destinationPlanner.test.js b/server/modules/reorganize/__tests__/destinationPlanner.test.js index 9a01a51d3..df19a4082 100644 --- a/server/modules/reorganize/__tests__/destinationPlanner.test.js +++ b/server/modules/reorganize/__tests__/destinationPlanner.test.js @@ -95,6 +95,22 @@ describe('reorganize destinationPlanner', () => { expect(items[0].flags).toContain('movie-tags'); }); + it('flags a video whose MP3 file moves into a TV folder', async () => { + const audioPath = touch(`__Kids/Chan/Chan - Big Build - ${ID}/Chan - Big Build [${ID}].mp3`); + + const { items } = await planTo(subjectFor(null, { video: { audioFilePath: audioPath } }), { libraryFolder: 'TV', layout: 'tv' }); + + expect(items[0].flags).toContain('audio-to-tv'); + }); + + it('does not flag a video without an MP3 file', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build - ${ID}/Chan - Big Build [${ID}].mp4`); + + const { items } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + + expect(items[0].flags).not.toContain('audio-to-tv'); + }); + it('keeps the code of a file named by the Plex TV Series preset', async () => { const videoPath = touch(`Chan/S2024E03151230 Big Build [${ID}].mp4`); @@ -369,6 +385,15 @@ describe('reorganize destinationPlanner', () => { expect(items[0].files).toHaveLength(1); }); + it('does not flag an MP3 that is already in a TV folder', async () => { + const audioPath = touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp3`); + const subject = subjectFor(null, { video: { audioFilePath: audioPath }, subject: { libraryFolder: 'TV', currentLayout: 'tv' } }); + + const { items } = await planTo(subject, { libraryFolder: 'TV2', layout: 'tv' }, new Map([['UC1', { ...show, libraryFolder: 'TV2' }]])); + + expect(items[0].flags).not.toContain('audio-to-tv'); + }); + it('counts a video already where it belongs as unchanged', async () => { const stem = `S2024E03151200 - Big Build [${ID}]`; const videoPath = touch(`__TV/Chan/Season 2024/${stem}.mp4`); diff --git a/server/modules/reorganize/__tests__/planner.test.js b/server/modules/reorganize/__tests__/planner.test.js index 205cc7be0..384cf6b0f 100644 --- a/server/modules/reorganize/__tests__/planner.test.js +++ b/server/modules/reorganize/__tests__/planner.test.js @@ -125,6 +125,14 @@ describe('reorganize planner', () => { }); }); + it('counts the MP3 files that move into a TV folder', async () => { + require('../destinationPlanner').planDestinations.mockResolvedValue({ + items: [item({ flags: ['audio-to-tv'] }), item({ youtubeId: 'ccccccccccc' })], problems: [], + }); + const preview = await planner.summarizePlan(await planner.buildPlan({ type: 'channel' })); + expect(preview.totals.audioToTv).toBe(1); + }); + it('names the TV folders videos move into', async () => { require('../destinationPlanner').planDestinations.mockResolvedValue({ items: [item({ libraryFolder: 'TV' }), item({ youtubeId: 'ccccccccccc', libraryFolder: '', layout: 'tv' }), item({ youtubeId: 'ddddddddddd', layout: 'videos', libraryFolder: 'Kids' })], diff --git a/server/modules/reorganize/constants.js b/server/modules/reorganize/constants.js index b75a35805..7b94b6a50 100644 --- a/server/modules/reorganize/constants.js +++ b/server/modules/reorganize/constants.js @@ -47,6 +47,8 @@ const FLAG = Object.freeze({ UPLOAD_DATE_ONLY: 'upload-date-only', DOWNLOAD_TIME: 'download-time', MOVIE_TAGS: 'movie-tags', + // An MP3 file comes from a Videos folder into a TV folder, where TV libraries don't show it. + AUDIO_TO_TV: 'audio-to-tv', }); // The preview lists at most this many moves and problems; totals are complete. diff --git a/server/modules/reorganize/destinationPlanner.js b/server/modules/reorganize/destinationPlanner.js index 943bde232..ebd720a7c 100644 --- a/server/modules/reorganize/destinationPlanner.js +++ b/server/modules/reorganize/destinationPlanner.js @@ -404,6 +404,9 @@ async function planDestinations({ subjects, context, targets, shows, titleTarget && planned.files.some((file) => file.from === video.filePath && MOVIE_TAG_EXTENSIONS.has(path.extname(file.from).toLowerCase()))) { itemFlags.push(FLAG.MOVIE_TAGS); } + if (target.layout === LAYOUT_TV && subject.currentLayout !== LAYOUT_TV && planned.newAudioPath) { + itemFlags.push(FLAG.AUDIO_TO_TV); + } for (const collision of planned.collisions) problems.push(problemOf(subject, PROBLEM.COLLISION, collision)); items.push({ diff --git a/server/modules/reorganize/planner.js b/server/modules/reorganize/planner.js index f16d09c45..71c028c82 100644 --- a/server/modules/reorganize/planner.js +++ b/server/modules/reorganize/planner.js @@ -161,6 +161,7 @@ async function summarizePlan(plan, { blocked = null } = {}) { uploadDateOnly: flagged(FLAG.UPLOAD_DATE_ONLY), downloadTime: flagged(FLAG.DOWNLOAD_TIME), movieTags: flagged(FLAG.MOVIE_TAGS), + audioToTv: flagged(FLAG.AUDIO_TO_TV), }, shows: plan.shows.map((show) => ({ name: show.name, libraryFolder: show.libraryFolder, folderName: show.folderName, action: show.action, diff --git a/server/modules/tvShows/__tests__/titleNumbering.test.js b/server/modules/tvShows/__tests__/titleNumbering.test.js index 1b1db92ff..38c8cf817 100644 --- a/server/modules/tvShows/__tests__/titleNumbering.test.js +++ b/server/modules/tvShows/__tests__/titleNumbering.test.js @@ -377,6 +377,17 @@ describe('titleNumbering.computeGaps', () => { expect(computeGaps([row(1010101, 'date', 2024), row(5050505, 'date', 2024)])).toEqual([]); }); + // Uploads of one year hold numbers a series spreads over several years. + it('skips upload-year seasons with title episodes', () => { + expect(computeGaps([row(11, 'title', 2021), row(44, 'title', 2021)])).toEqual([]); + }); + + it('still lists gaps in season 199, the highest fixed season', () => { + expect(computeGaps([row(1, 'title', 199), row(3, 'title', 199)])).toEqual([ + expect.objectContaining({ season: 199, missing: [2] }), + ]); + }); + it('caps the missing list', () => { const [gap] = computeGaps([row(1), row(400)], { limit: 3 }); expect([gap.missing, gap.truncated]).toEqual([[2, 3, 4], true]); diff --git a/server/modules/tvShows/__tests__/titleShowQueries.test.js b/server/modules/tvShows/__tests__/titleShowQueries.test.js index 7ec47e896..0ac2e9d36 100644 --- a/server/modules/tvShows/__tests__/titleShowQueries.test.js +++ b/server/modules/tvShows/__tests__/titleShowQueries.test.js @@ -3,6 +3,7 @@ jest.mock('../../../models', () => ({ VideoClassification: { findAll: jest.fn() }, Video: { findAll: jest.fn() }, TvShowSeason: { findAll: jest.fn() }, + EpisodeConflict: { findAll: jest.fn() }, })); jest.mock('../../../models/channelvideo', () => ({ findAll: jest.fn() })); @@ -26,6 +27,7 @@ describe('titleShowQueries', () => { models.Video.findAll.mockResolvedValue([]); ChannelVideo.findAll.mockResolvedValue([]); models.TvShowSeason.findAll.mockResolvedValue([]); + models.EpisodeConflict.findAll.mockResolvedValue([]); }); describe('countsByShow', () => { @@ -34,12 +36,21 @@ describe('titleShowQueries', () => { classification('a'), classification('b', { status: 'pending_number', season: null, episode: null }), classification('c', { status: 'duplicate' }), classification('d', { status: 'unsupported', show_id: 4 }), ]); + models.EpisodeConflict.findAll.mockResolvedValue([{ show_id: 3 }]); models.Video.findAll.mockResolvedValue([{ youtubeId: 'a' }]); const counts = await queries.countsByShow([3, 4]); expect(counts.get(3)).toEqual({ episodes: 2, downloaded: 1, duplicates: 1, unsupported: 0 }); expect(counts.get(4)).toEqual({ episodes: 0, downloaded: 0, duplicates: 0, unsupported: 1 }); }); + // The duplicates list reads conflicts too; a duplicate that kept its channel-show row has no duplicate row. + it('counts the duplicates recorded as conflicts', async () => { + models.VideoClassification.findAll.mockResolvedValue([classification('a')]); + models.EpisodeConflict.findAll.mockResolvedValue([{ show_id: 3 }]); + const counts = await queries.countsByShow([3]); + expect(counts.get(3).duplicates).toBe(1); + }); + it('asks only for downloaded files that are present', async () => { models.VideoClassification.findAll.mockResolvedValue([classification('a')]); await queries.countsByShow([3]); diff --git a/server/modules/tvShows/constants.js b/server/modules/tvShows/constants.js index 6b80156f3..1ad114ee7 100644 --- a/server/modules/tvShows/constants.js +++ b/server/modules/tvShows/constants.js @@ -11,6 +11,9 @@ const LAYOUT_TV = 'tv'; const KIND_TITLE_SHOW = 'title'; const KIND_CHANNEL_SHOW = 'channel'; +// episode_conflicts.kind of a video whose episode number another video holds. +const CONFLICT_KIND_DUPLICATE = 'duplicate'; + // Download types that produce MP3 output. TV folders are video-only. const MP3_AUDIO_FORMATS = new Set(['mp3_only', 'video_mp3']); @@ -33,5 +36,6 @@ module.exports = { LAYOUT_VIDEOS, LAYOUT_TV, KIND_TITLE_SHOW, - KIND_CHANNEL_SHOW + KIND_CHANNEL_SHOW, + CONFLICT_KIND_DUPLICATE }; diff --git a/server/modules/tvShows/episodeConflicts.js b/server/modules/tvShows/episodeConflicts.js index 755cbd3f6..8e3eb0778 100644 --- a/server/modules/tvShows/episodeConflicts.js +++ b/server/modules/tvShows/episodeConflicts.js @@ -15,8 +15,8 @@ const { EpisodeConflict } = require('../../models'); const ChannelVideo = require('../../models/channelvideo'); const { ARCHIVE_ADD, ARCHIVE_REMOVE, KIND_RELEASED } = require('./archiveSuppressor'); +const { CONFLICT_KIND_DUPLICATE: KIND_DUPLICATE } = require('./constants'); -const KIND_DUPLICATE = 'duplicate'; const KIND_ERROR = 'classification_error'; function parseDetails(text) { diff --git a/server/modules/tvShows/titleNumbering.js b/server/modules/tvShows/titleNumbering.js index e7e53db9c..8cf4bb872 100644 --- a/server/modules/tvShows/titleNumbering.js +++ b/server/modules/tvShows/titleNumbering.js @@ -282,7 +282,11 @@ function computeGaps(rows, { limit = DEFAULT_GAP_LIMIT } = {}) { } const gaps = []; for (const { showKey, season, episodes, dated } of seasons.values()) { - if (dated) continue; + // A year season holds a year's uploads, not a run of the series' numbers. + // Seasons from MIN_YEAR_SEASON up only come from the upload year or a + // hand assignment: a season read from a title stays within 0..199 + // (patternCompiler.MAX_SEASON; titleMatcher marks a larger one unsupported). + if (dated || season >= MIN_YEAR_SEASON) continue; const highest = Math.max(...episodes); const missing = []; let truncated = false; diff --git a/server/modules/tvShows/titleShowQueries.js b/server/modules/tvShows/titleShowQueries.js index 69e49e95f..e48c85564 100644 --- a/server/modules/tvShows/titleShowQueries.js +++ b/server/modules/tvShows/titleShowQueries.js @@ -5,9 +5,9 @@ * downloaded yet (their episode chip). */ -const { TvShow, VideoClassification, Video, TvShowSeason } = require('../../models'); +const { TvShow, VideoClassification, Video, TvShowSeason, EpisodeConflict } = require('../../models'); const ChannelVideo = require('../../models/channelvideo'); -const { KIND_TITLE_SHOW } = require('./constants'); +const { KIND_TITLE_SHOW, CONFLICT_KIND_DUPLICATE } = require('./constants'); const { episodeCode } = require('./episodeNaming'); const { computeGaps, ROW_STATUS, SOURCE } = require('./titleNumbering'); @@ -39,12 +39,19 @@ async function countsByShow(showIds) { if (EPISODE_STATUSES.includes(row.status)) { count.episodes += 1; if (downloaded.has(row.youtube_id)) count.downloaded += 1; - } else if (row.status === ROW_STATUS.DUPLICATE) { - count.duplicates += 1; } else if (row.status === ROW_STATUS.UNSUPPORTED) { count.unsupported += 1; } } + // Counted from the conflicts the duplicates list shows: a channel-show + // episode that lost a title claim keeps its channel-show row. + const duplicates = await EpisodeConflict.findAll({ + where: { show_id: showIds, kind: CONFLICT_KIND_DUPLICATE }, attributes: ['show_id'], raw: true, + }); + for (const duplicate of duplicates) { + const count = counts.get(duplicate.show_id); + if (count) count.duplicates += 1; + } return counts; } From 23fd4acaa0e4a995f670b317fc683205032e53e6 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Mon, 5 Oct 2026 16:36:10 -0700 Subject: [PATCH 08/12] fix(tv): harden title shows and reorganize moves Follow-up fixes from the review of title shows and the reorganize: - Renaming a show folder only in case or accents saved without moving its episodes, so later downloads could land in a second folder. Such renames now move the files. On case-insensitive filesystems the move no longer reports the source as a collision or deletes the episode NFO it just wrote, and a destination that differs only in case or accents is never taken for a finished copy of the source. - A restart between creating a title show change's shows and recording the reorganize as applied could leave recovery and Retry refused on every attempt. Both are now written in one transaction. - Regex patterns with numbered backreferences or conditional groups could match differently in channel downloads than in classification. They are refused; three-digit octal escapes such as \123 still work. - A job started from an idle queue could begin while complete.list was being updated for duplicate episodes. Every job start now waits for that work, and a failed read of the pending updates no longer fails a save that has already committed. - Listing refreshes could record a new video as a duplicate of a video they left out, spend order numbers on rows they did not write, and clear classification errors of videos they did not check. - The subfolder delete guard did not count title shows of channels that download to a Videos folder. - Undoing a title show change dropped the pattern link of rows in retired shows. Drafts naming another channel's show, the same show twice, or a folder another show gives up in the same change are refused. - Moved title episodes lost their stored time source and were missing from the review's download-time and upload-day notes, and videos without a date were numbered before dated ones. - The reorganize API docs now list the titleShows change. Refs: #175 --- CLAUDE.md | 6 +- docs/USAGE_GUIDE.md | 2 +- server/modules/__tests__/jobModule.test.js | 33 ++++++++++ .../modules/__tests__/subfolderModule.test.js | 8 +++ .../fileOperations.moveNoClobber.test.js | 22 +++++++ server/modules/filesystem/fileOperations.js | 15 ++++- server/modules/jobModule.js | 8 ++- .../__tests__/destinationPlanner.test.js | 40 ++++++++++++ .../reorganize/__tests__/itemExecutor.test.js | 31 +++++++++ .../__tests__/operationRunner.test.js | 10 ++- .../__tests__/operationStore.test.js | 4 +- .../__tests__/settingsApplier.test.js | 22 ++++++- .../__tests__/titleAssignments.test.js | 28 ++++++++ .../__tests__/titleSnapshot.test.js | 14 ++++ .../reorganize/__tests__/titleTargets.test.js | 27 ++++++++ .../modules/reorganize/destinationPlanner.js | 22 +++++-- server/modules/reorganize/itemExecutor.js | 5 ++ server/modules/reorganize/operationRunner.js | 8 ++- server/modules/reorganize/operationStore.js | 4 +- server/modules/reorganize/settingsApplier.js | 23 +++++-- server/modules/reorganize/titleAssignments.js | 21 ++++-- server/modules/reorganize/titleSnapshot.js | 17 ++++- server/modules/reorganize/titleTargets.js | 6 +- server/modules/subfolderModule.js | 13 ++-- .../__tests__/archiveSuppressor.test.js | 7 ++ .../__tests__/episodeConflicts.test.js | 14 ++++ .../tvShows/__tests__/patternCompiler.test.js | 31 +++++++++ .../tvShows/__tests__/titleNumbering.test.js | 34 ++++++++++ .../tvShows/__tests__/titlePlanner.test.js | 17 ++++- .../tvShows/__tests__/titleRowWriter.test.js | 9 ++- .../tvShows/__tests__/titleShowDrafts.test.js | 4 ++ .../tvShows/__tests__/titleShowSaver.test.js | 64 ++++++++++++++++++- .../__tests__/titleShowService.test.js | 5 ++ .../tvShows/__tests__/titleShowStore.test.js | 26 ++++++++ server/modules/tvShows/archiveSuppressor.js | 13 +++- server/modules/tvShows/episodeConflicts.js | 11 +++- server/modules/tvShows/patternCompiler.js | 14 +++- server/modules/tvShows/titleNumbering.js | 7 +- server/modules/tvShows/titlePlanner.js | 6 +- server/modules/tvShows/titleRowWriter.js | 6 +- server/modules/tvShows/titleShowDrafts.js | 3 + server/modules/tvShows/titleShowSaver.js | 40 ++++++++++-- server/modules/tvShows/titleShowService.js | 5 +- server/modules/tvShows/titleShowStore.js | 19 +++++- server/routes/tvReorganize.js | 8 ++- 45 files changed, 658 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f297d8637..e8d3183e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `logger.js` + `logging/`: two transport targets pinned to `trace`, so the root level is the only filter and `logger.applyLevelSetting` can change it live. Only the server process (`process.argv[1]` is `server.js`; the yt-dlp post-processor, scripts, and tests never write files, since post-processor output already reaches the server log through yt-dlp's stdout) also writes the console format, without colors or embedded ANSI codes, to `config/logs/youtarr.<n>.log` through `logging/logFileTransport.js` (pino-roll with `removeOtherLogFiles`, so files from earlier runs are pruned too; `LOG_FILE_MAX_SIZE` default 10MB, `LOG_FILE_MAX_COUNT` older files kept, default 5). A file error prints one stderr notice and stops file output only; an unwritable folder is detected at startup and reported through `logger.getLoggingStatus()` (served as `logging` on `/getconfig`). Never log a field named `level`: it overwrites pino's level and the record is dropped. `modules/logLevelSync.js` applies the `logLevel` setting ('' = `LOG_LEVEL`) at startup, on config change, and in the post-processor. `modules/logFilesModule.js` + `routes/logs.js` serve `GET /api/logs/download` (all files oldest first, numeric order), read line by line through `logging/logScrubber.js`, which replaces the configured keys/tokens and Apprise notification URLs by value plus `X-Plex-Token`/`X-Emby-Token`/`api_key` values and URL userinfo (proxy credentials) with `[REDACTED]`; pino's redaction only covers top-level keys, and tokens reach the log inside logged axios errors and the logged yt-dlp args. The files on disk are not scrubbed. - `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus, tvshow, videoclassification, tvshowpattern, tvshowseason, episodeconflict, tvreorganizeoperation, tvreorganizeitem, watchstatushold). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates, TvShow hasMany VideoClassifications, TvShowPatterns (`patterns`) and TvShowSeasons (`seasons`), TvReorganizeOperation hasMany TvReorganizeItems. - `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs, tvShows, tvTitleShows, tvReorganize). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. -- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to <channel>@latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `<channel folder>.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings; MP3 is downgraded to video for videos whose predicted folder has the TV layout), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths, active references, and TV shows with numbered episodes in the folder, not counting a show whose tracked channel has since moved back to a videos folder; reads layouts through `tvShows/libraryLayouts`, which therefore never imports it), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: <title>`; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation, downgrading MP3 to video for URLs whose predicted folder has the TV layout; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). +- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to <channel>@latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `<channel folder>.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings; MP3 is downgraded to video for videos whose predicted folder has the TV layout), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths, active references, and TV shows with numbered episodes in the folder, not counting a channel show whose tracked channel has since moved back to a videos folder (a title show always counts); reads layouts through `tvShows/libraryLayouts`, which therefore never imports it), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: <title>`; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation, downgrading MP3 to video for URLs whose predicted folder has the TV layout; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). - `modules/titleFilterRegex.js`: evaluates channel title filter regexes with Python's `re` (via `utils/title-filter-regex.py`, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's `--match-filter` exactly. Channel downloads apply the pattern through `ytdlpCommandBuilder.buildMatchFilters`, which escapes only `'` and `&` because yt-dlp's `match_str` unescapes nothing else. The same script has a `check` mode (compile-checks patterns; title show saves) and a `classify` mode (many patterns, first match and its named groups per title; `classifyTitles`), and `buildMatchFilterList` adds one `--match-filter` per title show for the show-only switch (`ChannelFilterConfig.showFilters`), never OR'd across shows into one regex. Playlist title filters are separate: `playlistModule.buildTitleFilterRegExp` evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile. - `modules/storageUsage.js`: total bytes of downloaded videos (`SUM(file_size + audio_file_size)` over non-removed rows, never a disk walk) and the shared `STORED_BYTES_SQL` fragment the oldest-first auto-removal candidate queries use, so audio-only (MP3) videos count their real size. - `modules/storageGuard.js`: the storage download pause (issues #196/#217). Optional `downloadPauseUsageLimit` (storageUsage total) and `downloadPauseMinFreeSpace` (df) triggers, both off by default; a failed measurement never pauses (fail open). `assertDownloadsAllowed()` throws `DownloadsPausedError` (code `DOWNLOADS_PAUSED`) from `downloadModule`'s fresh-request entry points (`doChannelAndPlaylistDownloads`, `doChannelDownloads`, `doSpecificDownloads`, `doPlaylistDownloads`; queued jobs started with isNextJob skip it), which routes map to 409 via the injected `storageGuard.isPausedError` (Download New reaches `doChannelAndPlaylistDownloads` through the scheduler's `channelDownloadFrequency` task rather than calling it directly, so its own pause check runs first, inside `autoDownloadScheduler.getRunBlocker`, and a paused Download New request gets the `downloads-paused` reason instead of a bare 409); the scheduled sweep records a skipped run first, and a pause hit mid-sweep stops the playlist loop and is reported as `pausedReason` (not a failure); `jobModule.startNextJob` refreshes the guard (so every finished job re-checks) and holds Pending jobs while paused, restarting the queue on the guard's `resumed` event. `startNextJob` coalesces overlapping calls into one in-flight scan (the `resumed` event fires inside the refresh a scan awaits, and a channel job stays Pending while it builds groups, so two scans would start it twice) and catches the unawaited job action's rejection; a job whose action rejects while it is still Pending is marked `Error` by `failUnstartedJob`, which waits for the launching scan to settle and then scans again, so the queue never stalls behind it (the single-group channel job's pre-spawn failure likewise reports a `jobIssue` to its run and calls `startNextJob`). `subscriptionImport` calls `startNextJob` when an import finishes (the import occupies the queue but completes outside the download path, so jobs queued or held meanwhile would otherwise stay Pending). The downloaded-size SUM runs only when a usage limit is set; `GET /api/jobs/download-pause` opts in with `refresh({ includeUsage: true })` for the settings page. `/updateconfig` rejects size values other than blank or `<positive int>MB|GB|TB`; `configModule.normalizeStorageSizeFields` corrects or clears hand-edited ones on load (startup and file-watch reload), because the client saves the full config and one bad value would otherwise block every Settings save. On a paused-state flip it broadcasts `downloadPauseChanged`, sends `notificationModule.sendDownloadPauseNotification` (paused and resumed), and re-checks every 5 min while paused; deletions re-check when paused. `GET /api/jobs/download-pause` serves the status; the client shows it through `hooks/useDownloadPauseStatus`, `layout/DownloadPauseBanner` (every page, dismissible per pause except on `/downloads`), and `shared/DownloadPauseAlert`, with settings at `/settings/storage-limits` (`Configuration/sections/StorageLimitsSection.tsx`). @@ -47,8 +47,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`<library folder>/<show>/Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. -- `modules/tvShows/` (TV show support: channel shows, title shows, and the reorganize): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. **Title shows** (a series inside one channel, `tv_shows.kind = 'title'`, numbered from its titles; classification decides, the match filter only screens downloads): `showFolderNames` (dependency-free folder-name rules, shared with `showStore`), `patternCompiler` (simple syntax (`{season}`/`{episode}` as `[0-9]+`, `{title}`, `*`, `^`; `{episode_end}`/`{part}` recognized as unsupported) or regex mode -> a Python regex with named groups plus its filter form (groups unnamed, leading flags scoped); seasons 0..199), `titleMatcher` (one Python batch per channel through `titleFilterRegex.classifyTitles`: first show by position, then pattern, without an exclude term; a number the pattern takes from the title but the title skipped (an optional regex group) is `unsupported`, reason `missing-number`; a pending match keeps its title episode for the reorganize as `titleEpisode`), `titleNumbering` (pure: stored rows + matches -> rows with statuses `assigned`/`pending_number`/`duplicate`/`unsupported`/`opted_out`/`error`; the stored holder of a title number keeps it while it still claims it and is available or downloaded, else the earliest available upload; a manual override takes its number from any holder, which is then classified as if it held nothing; a TV channel's episode that loses a title claim keeps its channel-show row; `order` numbers past `tv_show_seasons.order_high_water`, never reissued; year seasons and date episodes wait for the download (`pending_number`), except that a downloaded video's upload year is known (`Videos.originalDate`), so a year season with a title episode is numbered at plan time and a downloaded duplicate the post-processor recorded keeps that verdict, since the planner never has the upload time to redo it; manual and opted-out rows are never touched; gaps, none for date or upload-year seasons), `titleShowDrafts` (validates and compiles a channel's whole set of shows; keys `title:<id>`/`new:<n>`), `titleShowStore` (rows, patterns, seasons, retire/restore, folder availability, show filters for downloads), `titlePlanner` (a channel's plan after a change, for every listed and downloaded video and every row classified for the channel; the preview, direct saves and the reorganize all plan through `planChannel`; a download outside the downloads folder never moves (`staysOutside`, counted in the preview)), `titlePreview` (the editor's tabs), `titleRowWriter` (writes a plan in one transaction, numbers released before they are retaken, each row only while it is as planned: `ROW_CHANGED` otherwise, and the saver plans again once; conflicts are released by the channel's recorded duplicates), `titleShowSaver` (saves directly when no downloaded video's show, number or stem changes, else answers `reorganizeRequired` with a `titleShows` change; listing refreshes classify only new rows through `classifyNew`), `titleShowService` (the API's operations as drafts plus overrides: create, edit, retire, restore, reorder, manual episode, "Not an episode" (title show episodes only), back to automatic, "Use this copy instead", recheck; after each save it rewrites the channel's title show NFO files, which name and season-name edits change without moving a file; the reorganize follow-up does the same for a `titleShows` change), `titleShowQueries` (counts, `/getchannels` `titleShows`, missing episodes, the channel page's show filter, the planned episode chip of not-downloaded videos), `episodeConflicts` (duplicates and classification errors; a not-downloaded duplicate is ignored and suppressed in `complete.list`, and only what Youtarr did is undone on release: the ignore it set (`youtarr_ignored`) and the archive line it wrote (`archive_suppressed`, false when the line was already there, e.g. a deleted download's); a downloaded duplicate is never ignored or deleted; duplicates a listing refresh finds are ignored too), `archiveSuppressor` (the deferred `complete.list` writer: `archive_pending` on the conflict row, applied when no download job is In Progress, through `jobModule.onBeforeNextJob` and at startup, because channel jobs count archive lines), `titleEpisodeAssigner` (the post-processor's step: a stored episode is reused without Python; otherwise the full title is classified at finalize; duplicates, unsupported, opted-out and errors fall back to the channel layout and are recorded), and `tvNfo`'s title show output (`<uniqueid type="youtarr">` + custom UUID, `<namedseason>`, `season.nfo` per named season). The show-only switch (`channels.tv_show_only_downloads`) adds one match filter per show to channel downloads and limits Download All to classified videos (`assigned`/`pending_number`); it does nothing while the channel has no active show. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`) and `routes/tvTitleShows.js` (`/api/channels/:channelId/tv/shows` list/create/edit/retire/restore/order and `/:showId/missing`, `POST .../tv/preview`, `PUT .../tv/show-only`, `POST .../tv/conflicts/:youtubeId/use-copy`, `POST .../tv/recheck`, `GET`/`PUT /api/videos/:youtubeId/episode`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json` (`titleCorpusClassification.test.js` runs them through the real Python matcher). -- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder), or when a change to a channel's title shows moves downloaded videos (`titleShows` change: the show definitions and every classification row of the channel are written at the start, with a `titleSnapshot` of the previous ones in `settings_change`, restored when no video moved (deleting the shows the change created); title shows are planned under show keys `title:<id>`/`new:<n>`, channel shows under their owner's channel id; `titleTargets` keeps episodes of active title shows in place for every other change type and a folder holding title shows can't switch to Videos (400); `titleAssignments` numbers videos moving into a title show, upload-time numbers from the stored info.json as the post-processor would). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings; items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. +- `modules/tvShows/` (TV show support: channel shows, title shows, and the reorganize): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. **Title shows** (a series inside one channel, `tv_shows.kind = 'title'`, numbered from its titles; classification decides, the match filter only screens downloads): `showFolderNames` (dependency-free folder-name rules, shared with `showStore`), `patternCompiler` (simple syntax (`{season}`/`{episode}` as `[0-9]+`, `{title}`, `*`, `^`; `{episode_end}`/`{part}` recognized as unsupported) or regex mode -> a Python regex with named groups plus its filter form (groups unnamed, leading flags scoped; backreferences and conditional groups are refused, since unnaming and joining a show's patterns renumber groups); seasons 0..199), `titleMatcher` (one Python batch per channel through `titleFilterRegex.classifyTitles`: first show by position, then pattern, without an exclude term; a number the pattern takes from the title but the title skipped (an optional regex group) is `unsupported`, reason `missing-number`; a pending match keeps its title episode for the reorganize as `titleEpisode`), `titleNumbering` (pure: stored rows + matches -> rows with statuses `assigned`/`pending_number`/`duplicate`/`unsupported`/`opted_out`/`error`; the stored holder of a title number keeps it while it still claims it and is available or downloaded, else the earliest available upload (undated videos last); a manual override takes its number from any holder, which is then classified as if it held nothing; a TV channel's episode that loses a title claim keeps its channel-show row; `order` numbers past `tv_show_seasons.order_high_water`, never reissued; year seasons and date episodes wait for the download (`pending_number`), except that a downloaded video's upload year is known (`Videos.originalDate`), so a year season with a title episode is numbered at plan time and a downloaded duplicate the post-processor recorded keeps that verdict, since the planner never has the upload time to redo it; manual and opted-out rows are never touched; gaps, none for date or upload-year seasons), `titleShowDrafts` (validates and compiles a channel's whole set of shows; keys `title:<id>`/`new:<n>`), `titleShowStore` (rows, patterns, seasons, retire/restore, folder availability, show filters for downloads), `titlePlanner` (a channel's plan after a change, for every listed and downloaded video and every row classified for the channel; the preview, direct saves and the reorganize all plan through `planChannel`; a download outside the downloads folder never moves (`staysOutside`, counted in the preview); a show folder renamed only in case or accents moves its files, since the database compares folder names ignoring both and most filesystems don't), `titlePreview` (the editor's tabs), `titleRowWriter` (writes a plan in one transaction, numbers released before they are retaken, each row only while it is as planned: `ROW_CHANGED` otherwise, and the saver plans again once; conflicts are released by the channel's recorded duplicates), `titleShowSaver` (saves directly when no downloaded video's show, number or stem changes, else answers `reorganizeRequired` with a `titleShows` change; listing refreshes classify only new rows through `classifyNew`), `titleShowService` (the API's operations as drafts plus overrides: create, edit, retire, restore, reorder, manual episode, "Not an episode" (title show episodes only), back to automatic, "Use this copy instead", recheck; after each save it rewrites the channel's title show NFO files, which name and season-name edits change without moving a file; the reorganize follow-up does the same for a `titleShows` change), `titleShowQueries` (counts, `/getchannels` `titleShows`, missing episodes, the channel page's show filter, the planned episode chip of not-downloaded videos), `episodeConflicts` (duplicates and classification errors; a not-downloaded duplicate is ignored and suppressed in `complete.list`, and only what Youtarr did is undone on release: the ignore it set (`youtarr_ignored`) and the archive line it wrote (`archive_suppressed`, false when the line was already there, e.g. a deleted download's); a downloaded duplicate is never ignored or deleted; duplicates a listing refresh finds are ignored too), `archiveSuppressor` (the deferred `complete.list` writer: `archive_pending` on the conflict row, applied when no download job is In Progress, through `jobModule.onBeforeNextJob` (awaited before any job starts, queued or new) and at startup, because channel jobs count archive lines), `titleEpisodeAssigner` (the post-processor's step: a stored episode is reused without Python; otherwise the full title is classified at finalize; duplicates, unsupported, opted-out and errors fall back to the channel layout and are recorded), and `tvNfo`'s title show output (`<uniqueid type="youtarr">` + custom UUID, `<namedseason>`, `season.nfo` per named season). The show-only switch (`channels.tv_show_only_downloads`) adds one match filter per show to channel downloads and limits Download All to classified videos (`assigned`/`pending_number`); it does nothing while the channel has no active show. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`) and `routes/tvTitleShows.js` (`/api/channels/:channelId/tv/shows` list/create/edit/retire/restore/order and `/:showId/missing`, `POST .../tv/preview`, `PUT .../tv/show-only`, `POST .../tv/conflicts/:youtubeId/use-copy`, `POST .../tv/recheck`, `GET`/`PUT /api/videos/:youtubeId/episode`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json` (`titleCorpusClassification.test.js` runs them through the real Python matcher). +- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder), or when a change to a channel's title shows moves downloaded videos (`titleShows` change: the show definitions and every classification row of the channel are written at the start, with a `titleSnapshot` of the previous ones in `settings_change`, restored when no video moved (deleting the shows the change created); title shows are planned under show keys `title:<id>`/`new:<n>`, channel shows under their owner's channel id; `titleTargets` keeps episodes of active title shows in place for every other change type and a folder holding title shows can't switch to Videos (400); `titleAssignments` numbers videos moving into a title show, upload-time numbers from the stored info.json as the post-processor would). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned, but never for two paths that differ only in case or accents, which may be one file on a filesystem whose inode numbers vary by spelling), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings (a title show change marks the operation applied inside its own write transaction, so a restart never replays shows it created); items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. - `modules/mediaServers/watchStatusHolds.js` + `watchStatusPushBack.js`: watch state a reorganize protects. Before files move, each watched or in-progress `video_watch_status` row (Plex: the owner only; other accounts come from play history, which a move never resets) is snapshotted into `watch_status_holds`; `watchStatusSync` passes every server's matches through `applyHolds`, which drops downgrades of held rows (the moved item looks new and unwatched, and until a rescan the server lists the old path, which the current-copy rule rejects) and ends a hold when the item at the video's current path (matching it with more trailing segments than the path the video came from, kept in the snapshot as `fromPath`: between TV folders the stale item shares the show, season and file names) shows at least the held state or a newer one. A push stamps the server item with the push time: a last-watched time within 10 minutes of the hold's last actual write (`last_pushed_at`; an attempt that found the state already there writes nothing and leaves no echo) is that echo, not a watch, so the row keeps its historical play count and last-watched time (restored holds are consulted for this too), while a later real watch is written as is. Re-holding a row for a later reorganize resets its push record (the earlier push went to the earlier location). Push-back first reads the user's state of the item (`getWatchState`: Plex `/library/metadata/{key}` as the owner, Jellyfin and Emby `/Users/{id}/Items/{id}`; `watchStateCompare` holds the pure `isAtLeast`/`isLaterWatch` the sync uses too) and leaves a state at least as watched, or watched since the snapshot, alone; otherwise it writes the state through the adapters' `setWatchState` (Plex `/:/scrobble` and `/:/progress` as the owner; Jellyfin `/UserPlayedItems` and `/UserItems/{id}/UserData`, falling back to the legacy `/Users/{id}/...` routes; Emby `/Users/{id}/PlayedItems` and `/Users/{id}/Items/{id}/UserData`), finding items with `resolveItemMatchesByPaths` (an item is skipped only when the same item answers for both the new and the old path and fits the old one at least as well, i.e. the stale item before the server rescans; two different items mean the new file is indexed; an explicit retry takes whatever matches); a hold is pushed once, and again only when a sync finds the item at the new path below the held state (`applyHolds` records that as the hold's error), since a blind re-push could undo a watch made in between. It runs 1, 5 and 15 minutes after a reorganize and at the end of every sync, an hour apart per hold. Holds not restored within 14 days become failed (still protecting the row) until retried or dismissed (`GET /api/tv/holds`, `POST /api/tv/holds/:id/retry|dismiss`). - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index e2c789258..f33c001d1 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -499,7 +499,7 @@ Some channels upload real series: "Hermitcraft 10: Episode 5 - ...", "BEYBLADE E Open the channel page, click the settings icon (gear), open **TV Show**, and under **Shows in this channel** click **Add show**: - **Name** and **Folder name** (the show folder; the name by default). A title show needs a TV folder: Youtarr uses the channel's TV folder, else the default subfolder if it is a TV folder, else your only TV folder, and asks when there is more than one. Two shows can't share a folder name in one TV folder; Youtarr suggests `<Name> (<Channel>)`, or offers to restore a removed show that used the name. -- **Title patterns**, tried in order. Text matches ignoring case and spaces match any spacing; `*` matches any text; a pattern matches anywhere in the title unless it starts with `^`. Placeholders capture the numbers and the episode title: `{season}`, `{episode}` and `{title}`. For example `Hermitcraft {season}: Episode {episode} - {title}`. **Edit as regular expression** shows the pattern as a Python regular expression with the named groups `season`, `episode` and `title`. +- **Title patterns**, tried in order. Text matches ignoring case and spaces match any spacing; `*` matches any text; a pattern matches anywhere in the title unless it starts with `^`. Placeholders capture the numbers and the episode title: `{season}`, `{episode}` and `{title}`. For example `Hermitcraft {season}: Episode {episode} - {title}`. **Edit as regular expression** shows the pattern as a Python regular expression with the named groups `season`, `episode` and `title`; backreferences and conditional groups aren't supported there (channel downloads use the same regular expression with its groups unnamed). - **Season** and **Episode** per pattern: the season from the title, a fixed season (0 is specials), or the upload year; the episode from the title, the next number in the season (given once, oldest upload first, and never reused), or the upload time (with upload-year seasons only, like channel shows). A video not downloaded yet gets its upload-year season when it downloads; a downloaded video's year is already known, so the preview shows its number. - **Exclude titles containing**: a title with any of these words never joins the show, for example `Official Clip`. - **Season names**: written to the show's NFO files, so the media server shows "Season 2: V-Force" instead of "Season 2". Upload-year seasons (such as 2024) can be named too. diff --git a/server/modules/__tests__/jobModule.test.js b/server/modules/__tests__/jobModule.test.js index 2efb9bded..fe5f1e827 100644 --- a/server/modules/__tests__/jobModule.test.js +++ b/server/modules/__tests__/jobModule.test.js @@ -1272,6 +1272,39 @@ describe('JobModule', () => { ); }); + // Title shows write complete.list between jobs, and a channel job counts + // archive lines from its start: pending writes land first. + test('finishes the before-next-job work before a new job starts In Progress', async () => { + JobModule.jobs = {}; + const order = []; + JobModule.onBeforeNextJob(async () => { order.push('listener'); }); + JobModule.addJob.mockImplementation(async (job) => { order.push(job.status); return 'new-job-id'; }); + + await JobModule.addOrUpdateJob({ jobType: 'download' }); + + expect(order).toEqual(['listener', 'In Progress']); + }); + + test('finishes the before-next-job work before a queued job flips to In Progress', async () => { + JobModule.jobs = {}; + const order = []; + JobModule.onBeforeNextJob(async () => { order.push('listener'); }); + JobModule.updateJob.mockImplementation(async (id, values) => { order.push(values.status); }); + + await JobModule.addOrUpdateJob({ id: 'next-job', jobType: 'download' }, true); + + expect(order).toEqual(['listener', 'In Progress']); + }); + + test('queues a new job when another started while the before-next-job work ran', async () => { + JobModule.jobs = {}; + JobModule.onBeforeNextJob(async () => { JobModule.jobs = { other: { status: 'In Progress' } }; }); + + await JobModule.addOrUpdateJob({ jobType: 'download' }); + + expect(JobModule.addJob).toHaveBeenCalledWith(expect.objectContaining({ status: 'Pending' })); + }); + test('should not emit jobsUpdated when next job cannot start', async () => { JobModule.jobs = { 'existing-job': { status: 'In Progress' } diff --git a/server/modules/__tests__/subfolderModule.test.js b/server/modules/__tests__/subfolderModule.test.js index 80a0f117b..31ddd44f3 100644 --- a/server/modules/__tests__/subfolderModule.test.js +++ b/server/modules/__tests__/subfolderModule.test.js @@ -220,6 +220,14 @@ describe('delete', () => { await expect(subfolderModule.delete('TV')).resolves.toBeUndefined(); }); + // A title show lives in a TV folder whatever folder its channel uses. + test('counts a title show of a channel that downloads to a videos folder', async () => { + VideoClassification.findAll.mockResolvedValue([{ show_id: 4 }]); + TvShow.findAll.mockResolvedValue([{ library_folder: 'TV', channel_id: 'UC1', kind: 'title' }]); + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', sub_folder: 'Kids' }]); + await expect(subfolderModule.delete('TV')).rejects.toThrow('holds 1 TV show(s)'); + }); + test('counts the show of an untracked channel, which has no folder of its own', async () => { VideoClassification.findAll.mockResolvedValue([{ show_id: 4 }]); TvShow.findAll.mockResolvedValue([{ library_folder: 'TV', channel_id: 'UCuntracked' }]); diff --git a/server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js b/server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js index e192c321a..1f9336338 100644 --- a/server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js +++ b/server/modules/filesystem/__tests__/fileOperations.moveNoClobber.test.js @@ -67,6 +67,28 @@ describe('filesystem/fileOperations moveFileNoClobber', () => { expect(fs.readFileSync(dest, 'utf8')).toBe('video'); }); + // A case-insensitive filesystem that reports another inode for each + // spelling (SMB without server inode numbers) shows the same file at both + // paths; two real files stand in for it. Taking it for a finished copy + // would delete the only copy. + it.each([ + ['case', 'cafe'], + ['accents', 'Café'], + ])('refuses, keeping the source, when the destination differs only in %s and looks like a finished copy', async (_label, destFolder) => { + const source = path.join(root, 'Cafe', 'Title [abcdefghijk].mp4'); + const target = path.join(root, destFolder, 'Title [abcdefghijk].mp4'); + fs.mkdirSync(path.dirname(source), { recursive: true }); + fs.writeFileSync(source, 'video'); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + const { atime, mtime } = fs.statSync(source); + fs.utimesSync(target, atime, mtime); + + await expect(moveFileNoClobber(source, target)).rejects.toMatchObject({ code: 'EEXIST' }); + + expect(fs.readFileSync(source, 'utf8')).toBe('video'); + }); + it('removes a staging copy left by an interrupted move before moving', async () => { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(`${dest}${NO_CLOBBER_STAGING_SUFFIX}`, 'partial'); diff --git a/server/modules/filesystem/fileOperations.js b/server/modules/filesystem/fileOperations.js index 86041ff3d..6e2003d98 100644 --- a/server/modules/filesystem/fileOperations.js +++ b/server/modules/filesystem/fileOperations.js @@ -271,6 +271,12 @@ function destinationExistsError(dest) { return Object.assign(new Error(`A file already exists at ${dest}`), { code: 'EEXIST', path: dest }); } +// Two paths a filesystem that ignores case or accents reads as one. +function sameIgnoringCaseAndAccents(a, b) { + const fold = (value) => value.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase(); + return fold(a) === fold(b); +} + // A path is absent when it or one of its parent folders is missing, and // when a parent turns out to be a file (ENOTDIR). async function statOrNull(filePath) { @@ -298,7 +304,7 @@ async function copyWithFallback(src, dest) { * removed, a source already moved is reported as such, and a destination * that is a finished copy of the source (same size and modification time, * left when a cross-filesystem move stopped before removing the source) - * completes the move. + * completes the move, unless the two paths differ only in case or accents. * * On one filesystem the move is a rename. Across filesystems (a __subfolder * can be another mount) the file is copied to `<dest>.reorganize.part`, @@ -329,7 +335,12 @@ async function moveFileNoClobber(src, dest, { retries = 3, delayMs = 200 } = {}) await fsPromises.rename(src, dest); return 'moved'; } - if (srcStat.size === destStat.size && Math.abs(srcStat.mtimeMs - destStat.mtimeMs) < COPY_MTIME_TOLERANCE_MS) { + // A spelling that differs only in case or accents may be the source + // itself on a filesystem whose inode numbers vary by spelling (SMB + // without server inode numbers): never taken for a finished copy, which + // would delete the only one. + if (!sameIgnoringCaseAndAccents(src, dest) + && srcStat.size === destStat.size && Math.abs(srcStat.mtimeMs - destStat.mtimeMs) < COPY_MTIME_TOLERANCE_MS) { await fsPromises.unlink(src); return 'already-moved'; } diff --git a/server/modules/jobModule.js b/server/modules/jobModule.js index 0424f03f9..a26fa5254 100644 --- a/server/modules/jobModule.js +++ b/server/modules/jobModule.js @@ -581,8 +581,8 @@ class JobModule { /** * Run work that must happen between download jobs (title shows write - * complete.list only while no job runs). Awaited before the next queued - * job starts; a failing listener never holds the queue. + * complete.list only while no job runs). Awaited before any job starts, + * queued or new; a failing listener never holds the queue. * @param {Function} listener - async, called with no arguments * @returns {Function} unsubscribe */ @@ -622,6 +622,10 @@ class JobModule { } async addOrUpdateJob(jobData, isNextJob = false) { + // Work that must happen between jobs (title shows' complete.list writes: + // a channel job counts archive lines from its start) finishes before a + // job starts here too, and the queue state is read after it. + if (!this.getInProgressJobId()) await this.runBeforeNextJob(); let jobId; const inProgressJobId = this.getInProgressJobId(); // A reorganize moves files the download would write next to, so download diff --git a/server/modules/reorganize/__tests__/destinationPlanner.test.js b/server/modules/reorganize/__tests__/destinationPlanner.test.js index df19a4082..6a0cd6877 100644 --- a/server/modules/reorganize/__tests__/destinationPlanner.test.js +++ b/server/modules/reorganize/__tests__/destinationPlanner.test.js @@ -185,6 +185,16 @@ describe('reorganize destinationPlanner', () => { expect(items[0].classification).toMatchObject({ season: 2024, episode: 3151200, source: 'date' }); }); + it('flags a title episode numbered by its download time for the review', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + videoInfoStore.readInfoOrFallback.mockResolvedValue({ id: ID, title: 'Big Build' }); + const subject = subjectFor(videoPath, { video: { last_downloaded_at: '2024-03-15T12:00:00.000Z' } }); + const { items } = await planTitle(subject, { + showKey: 'title:3', after: { ...assigned, status: 'pending_number', season: null, episode: null }, pattern: { seasonSource: 'year', episodeSource: 'date' }, stored: null, + }); + expect(items[0].flags).toContain('download-time'); + }); + it('keeps clear of numbers the title plan gives other videos', async () => { const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); const context = { @@ -365,6 +375,36 @@ describe('reorganize destinationPlanner', () => { expect(problems).toEqual([expect.objectContaining({ problem: 'collision' })]); }); + // A case-only rename on a case-insensitive filesystem: the destination + // spelling opens the source itself (a hard link stands in for it here). + it('does not report a destination that is the source file under another spelling', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + const destination = path.join(root, `__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.linkSync(videoPath, destination); + + const { items, problems } = await planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + + expect([items.length, problems]).toEqual([1, []]); + }); + + it('checks a destination against the source as first read, not a second look', async () => { + const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); + touch(`__TV/Chan/Season 2024/S2024E03151200 - Big Build [${ID}].mp4`, 'other'); + const realStat = fs.promises.stat; + let sourceStats = 0; + // The source vanishes right after planning read it. + const stat = jest.spyOn(fs.promises, 'stat').mockImplementation(async (target, ...rest) => { + if (target === videoPath && ++sourceStats > 1) throw Object.assign(new Error('gone'), { code: 'ENOENT' }); + return realStat(target, ...rest); + }); + + const planned = planTo(subjectFor(videoPath), { libraryFolder: 'TV', layout: 'tv' }); + const { problems } = await planned.finally(() => stat.mockRestore()); + + expect(problems).toEqual([expect.objectContaining({ problem: 'collision' })]); + }); + it('reports a file where the destination folder belongs as a collision', async () => { const videoPath = touch(`__Kids/Chan/Chan - Big Build [${ID}].mp4`); touch('__TV/Chan/Season 2024', 'not a folder'); diff --git a/server/modules/reorganize/__tests__/itemExecutor.test.js b/server/modules/reorganize/__tests__/itemExecutor.test.js index 3b12dc28a..e3c21fd7e 100644 --- a/server/modules/reorganize/__tests__/itemExecutor.test.js +++ b/server/modules/reorganize/__tests__/itemExecutor.test.js @@ -325,4 +325,35 @@ describe('reorganize itemExecutor', () => { expect(VideoClassification.create).not.toHaveBeenCalled(); expect(sidecarWriter.writeVideoSidecars).toHaveBeenCalledWith(expect.objectContaining({ episode: null })); }); + + // A show folder renamed only in case on a case-insensitive filesystem: both + // spellings reach the same files (a folder symlink stands in for that here). + it('keeps the episode NFO it wrote when the old NFO path spells the same file', async () => { + const newSeasonDir = at('__TV', 'chan', 'Season 2024'); + fs.mkdirSync(newSeasonDir, { recursive: true }); + fs.symlinkSync(at('__TV', 'chan'), at('__TV', 'Chan')); + const oldVideo = touch(path.join(seasonDir, `${STEM}.mp4`), 'video'); + touch(path.join(seasonDir, `${STEM}.nfo`), '<episodedetails/>'); + const newVideo = path.join(newSeasonDir, `${STEM}.mp4`); + const plan = { + files: [{ from: oldVideo, to: newVideo }], + nfoSources: [path.join(seasonDir, `${STEM}.nfo`)], + sourceDirs: [seasonDir], + oldVideoPath: oldVideo, + newVideoPath: newVideo, + oldAudioPath: null, + newAudioPath: null, + layout: 'tv', + fromLayout: 'tv', + }; + Video.findByPk.mockResolvedValue(videoRow(oldVideo)); + sidecarWriter.writeVideoSidecars.mockImplementationOnce(async ({ videoPath }) => { + fs.writeFileSync(videoPath.replace(/\.mp4$/, '.nfo'), '<episodedetails>new</episodedetails>'); + return []; + }); + + await executor.executeItem({ youtube_id: ID, video_id: 1, files: JSON.stringify(plan), classification: null }, { showIdFor: () => null }); + + expect(fs.readFileSync(path.join(newSeasonDir, `${STEM}.nfo`), 'utf8')).toBe('<episodedetails>new</episodedetails>'); + }); }); diff --git a/server/modules/reorganize/__tests__/operationRunner.test.js b/server/modules/reorganize/__tests__/operationRunner.test.js index b3df04703..88d68ae93 100644 --- a/server/modules/reorganize/__tests__/operationRunner.test.js +++ b/server/modules/reorganize/__tests__/operationRunner.test.js @@ -70,7 +70,13 @@ describe('reorganize operationRunner', () => { store.createOperation.mockResolvedValue(operation); store.itemsWithStatus.mockResolvedValue([item(1), item(2)]); store.refreshCounts.mockResolvedValue({ done: 2, failed: 0, pending: 0 }); - applier.applySettings.mockImplementation(async ({ shows }) => shows.map((show) => ({ ...show, showId: 9 }))); + // The applier records the change as applied itself (a title show change + // inside the transaction that writes it). + applier.applySettings.mockImplementation(async ({ shows, markApplied }) => { + const pinned = shows.map((show) => ({ ...show, showId: 9 })); + if (markApplied) await markApplied(pinned, 'tx'); + return pinned; + }); planner.buildPlan.mockResolvedValue(plan()); deps = { jobModule: { getInProgressJobId: jest.fn(() => null), isArchiveRepairRunning: jest.fn(() => false) }, @@ -159,7 +165,7 @@ describe('reorganize operationRunner', () => { await flush(() => !lock.isActive()); expect(applier.applySettings).toHaveBeenCalledTimes(1); - expect(store.markSettingsApplied).toHaveBeenCalledWith(operation, [{ ownerChannelId: 'UC1', showId: 9 }]); + expect(store.markSettingsApplied.mock.calls).toEqual([[operation, [{ ownerChannelId: 'UC1', showId: 9 }], { transaction: 'tx' }]]); expect(executor.executeItem).toHaveBeenCalledTimes(2); expect(executor.executeItem.mock.calls[0][1].showIdFor('UC1')).toBe(9); expect(store.finishOperation).toHaveBeenCalledWith(operation, 'completed', null); diff --git a/server/modules/reorganize/__tests__/operationStore.test.js b/server/modules/reorganize/__tests__/operationStore.test.js index 54b7550ff..668ce7899 100644 --- a/server/modules/reorganize/__tests__/operationStore.test.js +++ b/server/modules/reorganize/__tests__/operationStore.test.js @@ -58,12 +58,12 @@ describe('reorganize operationStore', () => { it('stores the pinned shows when the settings are applied', async () => { const operation = { settings_change: JSON.stringify({ change: {}, label: 'Chan', shows: [] }), update: jest.fn() }; - await store.markSettingsApplied(operation, [{ ownerChannelId: 'UC1', showId: 9 }]); + await store.markSettingsApplied(operation, [{ ownerChannelId: 'UC1', showId: 9 }], { transaction: 'tx' }); expect(operation.update).toHaveBeenCalledWith({ settings_change: JSON.stringify({ change: {}, label: 'Chan', shows: [{ ownerChannelId: 'UC1', showId: 9 }] }), settings_applied: true, - }); + }, { transaction: 'tx' }); }); it('recounts item statuses into the operation, with the items whose files moved', async () => { diff --git a/server/modules/reorganize/__tests__/settingsApplier.test.js b/server/modules/reorganize/__tests__/settingsApplier.test.js index d29dbe2f6..8b80d75b4 100644 --- a/server/modules/reorganize/__tests__/settingsApplier.test.js +++ b/server/modules/reorganize/__tests__/settingsApplier.test.js @@ -52,6 +52,13 @@ describe('reorganize settingsApplier', () => { previousVideosFolder: 'Kids', }; + it('records the change as applied once it is applied', async () => { + Channel.findOne.mockResolvedValue({ update: jest.fn() }); + const markApplied = jest.fn(); + await applier.applySettings({ change: channelChange, shows: [plannedShow], layoutBefore: layoutOf, markApplied }); + expect(markApplied).toHaveBeenCalledWith([expect.objectContaining({ ownerChannelId: 'UC1', showId: 11 })]); + }); + it('creates a planned show and moves the channel to its new folder', async () => { const channel = { update: jest.fn() }; Channel.findOne.mockResolvedValue(channel); @@ -164,7 +171,20 @@ describe('reorganize settingsApplier', () => { it('saves the shows and every episode row of the channel', async () => { await applier.applySettings({ change, shows: planned, layoutBefore: layoutOf }); expect(titleShowSaver.prepare).toHaveBeenCalledWith({ channel: { channel_id: 'UC1' }, rawShows: change.shows, rawOverrides: [] }); - expect(titleShowSaver.applyPrepared).toHaveBeenCalledWith({ channel: { channel_id: 'UC1' }, drafts: ['drafts'], plan: { entries: [] } }); + expect(titleShowSaver.applyPrepared).toHaveBeenCalledWith(expect.objectContaining({ channel: { channel_id: 'UC1' }, drafts: ['drafts'], plan: { entries: [] } })); + }); + + // A restart between the write and the record would replay the change, + // whose new shows would then hold their own folders. + it('records the change as applied inside the transaction that writes it', async () => { + titleShowSaver.applyPrepared.mockImplementation(async ({ onWritten }) => { + const saved = { showIds: new Map([['new:0', 9], ['title:3', 3]]), patternIds: new Map() }; + await onWritten(saved, 'tx'); + return saved; + }); + const markApplied = jest.fn(); + await applier.applySettings({ change, shows: planned, layoutBefore: layoutOf, markApplied }); + expect(markApplied.mock.calls).toEqual([[[expect.objectContaining({ key: 'new:0', showId: 9 }), expect.objectContaining({ key: 'title:3', showId: 3 })], 'tx']]); }); it('gives new title shows their saved ids', async () => { diff --git a/server/modules/reorganize/__tests__/titleAssignments.test.js b/server/modules/reorganize/__tests__/titleAssignments.test.js index b89e7bb89..1767792ff 100644 --- a/server/modules/reorganize/__tests__/titleAssignments.test.js +++ b/server/modules/reorganize/__tests__/titleAssignments.test.js @@ -35,6 +35,12 @@ describe('reorganize titleAssignments', () => { expect(assignments.get('aaaaaaaaaaa').fileStem).toBe('S01E20 - Old [aaaaaaaaaaa]'); }); + it('keeps the stored time source of an episode whose number stays', () => { + const stored = { showKey: 'title:3', season: 1, episode: 20, fileStem: 'S01E20 - Old [aaaaaaaaaaa]', timestampSource: 'timestamp' }; + const { assignments } = assign([entry('aaaaaaaaaaa', {}, { stored })]); + expect(assignments.get('aaaaaaaaaaa').timestampSource).toBe('timestamp'); + }); + it('numbers a waiting date episode from its upload time', () => { const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null, source: null }, { pattern: { seasonSource: 'year', episodeSource: 'date' }, @@ -99,6 +105,28 @@ describe('reorganize titleAssignments', () => { expect(assign([pending]).assignments.get('aaaaaaaaaaa')).toMatchObject({ season: 2021, episode: 1020304, timestampSource: null }); }); + // The move review counts these, as it does for channel shows. + it('flags a waiting episode numbered by its download time', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null }, { + info: {}, downloadedAt: '2021-01-02T03:04:00.000Z', pattern: { seasonSource: 'year', episodeSource: 'order' }, + }); + expect(assign([pending]).flags.get('aaaaaaaaaaa')).toEqual(['download-time']); + }); + + it('flags a waiting date episode numbered by its upload day', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null }, { + info: { upload_date: '20200809' }, pattern: { seasonSource: 'year', episodeSource: 'date' }, + }); + expect(assign([pending]).flags.get('aaaaaaaaaaa')).toEqual(['upload-date-only']); + }); + + it('does not flag a year-season episode whose upload day gives its year', () => { + const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: 4 }, { + info: { upload_date: '20200809' }, pattern: { seasonSource: 'year', episodeSource: 'title' }, + }); + expect(assign([pending]).flags.has('aaaaaaaaaaa')).toBe(false); + }); + it('leaves out a waiting episode with no time at all', () => { const pending = entry('aaaaaaaaaaa', { status: 'pending_number', season: null, episode: null }, { info: {}, pattern: { seasonSource: 'year', episodeSource: 'date' }, diff --git a/server/modules/reorganize/__tests__/titleSnapshot.test.js b/server/modules/reorganize/__tests__/titleSnapshot.test.js index d02debca5..784610dfd 100644 --- a/server/modules/reorganize/__tests__/titleSnapshot.test.js +++ b/server/modules/reorganize/__tests__/titleSnapshot.test.js @@ -3,6 +3,7 @@ jest.mock('../../../models', () => ({ VideoClassification: { findAll: jest.fn(), update: jest.fn(), destroy: jest.fn(), findByPk: jest.fn(), create: jest.fn() }, EpisodeConflict: { findAll: jest.fn(), update: jest.fn() }, Video: { findAll: jest.fn() }, + TvShowPattern: { findAll: jest.fn() }, })); jest.mock('../../tvShows/titleShowStore', () => ({ listTitleShows: jest.fn(), saveDefinitions: jest.fn(), titleShowIds: jest.fn(), deleteShows: jest.fn(), @@ -61,6 +62,7 @@ describe('reorganize titleSnapshot', () => { models.VideoClassification.findAll.mockResolvedValue([{ youtube_id: 'aaaaaaaaaaa' }, { youtube_id: 'new00000000' }]); models.EpisodeConflict.findAll.mockResolvedValue([]); store.titleShowIds.mockResolvedValue([3]); + models.TvShowPattern.findAll.mockResolvedValue([]); }); it('saves the shows that were active', async () => { @@ -78,6 +80,18 @@ describe('reorganize titleSnapshot', () => { expect(stored.update).toHaveBeenCalledWith(expect.objectContaining({ season: 1, episode: 20, pattern_id: 31 }), { transaction: 't' }); }); + it('keeps the pattern of a row whose show stays removed', async () => { + const stored = { update: jest.fn() }; + models.VideoClassification.findByPk.mockResolvedValue(stored); + models.TvShowPattern.findAll.mockResolvedValue([{ id: 50, show_id: 5, position: 0 }]); + await titleSnapshot.restoreTitleSnapshot(channel, { + shows: [show(3), show(5, { retired: true })], + rows: [row('bbbbbbbbbbb', { show_id: 5, source: 'order', pattern_id: 50 })], + conflicts: [], + }); + expect(stored.update).toHaveBeenCalledWith(expect.objectContaining({ show_id: 5, pattern_id: 50 }), { transaction: 't' }); + }); + it('deletes rows the change created', async () => { models.VideoClassification.findByPk.mockResolvedValue({ update: jest.fn() }); await titleSnapshot.restoreTitleSnapshot(channel, { shows: [show(3)], rows: [row('aaaaaaaaaaa')], conflicts: [] }); diff --git a/server/modules/reorganize/__tests__/titleTargets.test.js b/server/modules/reorganize/__tests__/titleTargets.test.js index 898ab91b6..72dd3470b 100644 --- a/server/modules/reorganize/__tests__/titleTargets.test.js +++ b/server/modules/reorganize/__tests__/titleTargets.test.js @@ -63,6 +63,26 @@ describe('reorganize titleTargets', () => { }); }); + it('plans a show whose folder name changes only in case to move', async () => { + const storedShows = new Map([['title:3', { key: 'title:3', libraryFolder: 'TV', folderName: 'beyblade' }]]); + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([draft('title:3')], [{ youtubeId: 'aaaaaaaaaaa', after: after('title:3') }], storedShows) + ); + expect(result.shows.get('title:3')).toMatchObject({ + action: 'move', previousLocation: { libraryFolder: 'TV', folderName: 'beyblade' }, + }); + }); + + it('keeps a show whose folder stays the same', async () => { + const storedShows = new Map([['title:3', { key: 'title:3', libraryFolder: 'TV', folderName: 'Beyblade' }]]); + const result = await titleTargets.resolveTitleTargets( + [subject(1, 'aaaaaaaaaaa')], + context([draft('title:3')], [{ youtubeId: 'aaaaaaaaaaa', after: after('title:3') }], storedShows) + ); + expect(result.shows.get('title:3').action).toBe('keep'); + }); + it('leaves a video that leaves its show to the channel layout', async () => { const result = await titleTargets.resolveTitleTargets( [subject(1, 'aaaaaaaaaaa')], @@ -105,6 +125,13 @@ describe('reorganize titleTargets', () => { expect(result.shows.get('title:3')).toMatchObject({ action: 'keep', showId: 3, libraryFolder: 'TV', folderName: 'Beyblade' }); }); + it('passes the stored row\'s time source along, so a kept number keeps it', async () => { + models.VideoClassification.findAll.mockResolvedValue([row({ source: 'date', timestamp_source: 'upload_date' })]); + models.TvShow.findAll.mockResolvedValue([titleShow]); + const result = await titleTargets.resolveTitleTargets([subject(1, 'aaaaaaaaaaa')], { type: 'channel' }, channels); + expect(result.targets.get(1).stored.timestampSource).toBe('upload_date'); + }); + it('asks only for active title shows', async () => { models.VideoClassification.findAll.mockResolvedValue([row()]); models.TvShow.findAll.mockResolvedValue([]); diff --git a/server/modules/reorganize/destinationPlanner.js b/server/modules/reorganize/destinationPlanner.js index ebd720a7c..00a777478 100644 --- a/server/modules/reorganize/destinationPlanner.js +++ b/server/modules/reorganize/destinationPlanner.js @@ -62,25 +62,29 @@ class DirectoryIndex { async function statFile(filePath) { try { - const stat = await fs.promises.stat(filePath); - return { size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) }; + return await fs.promises.stat(filePath); } catch (err) { if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null; throw err; } } -async function existsOtherThan(target, source) { +// sourceStat: the source as planning read it (a second look could find it +// gone and fail the whole plan). +async function existsOtherThan(target, source, sourceStat) { if (target === source) return false; + let targetStat; try { - await fs.promises.stat(target); - return true; + targetStat = await fs.promises.stat(target); } catch (err) { if (err.code === 'ENOENT') return false; // A file where a folder of the path belongs blocks the move as surely as a file at the path. if (err.code === 'ENOTDIR') return true; throw err; } + // The source itself under another spelling (a case-only rename on a + // case-insensitive filesystem), which moveFileNoClobber renames in place. + return sourceStat.ino !== targetStat.ino || sourceStat.dev !== targetStat.dev; } /** @@ -94,6 +98,7 @@ async function existsOtherThan(target, source) { async function planFiles(video, destDir, stem, index = new DirectoryIndex()) { const sourceDirs = [...new Set([video.filePath, video.audioFilePath].filter(Boolean).map((p) => path.dirname(p)))]; const files = []; + const sourceStats = new Map(); const nfoSources = []; for (const dir of sourceDirs) { let names; @@ -113,7 +118,10 @@ async function planFiles(video, destDir, stem, index = new DirectoryIndex()) { if (files.some((file) => file.from === from)) continue; const stat = await statFile(from); if (!stat) continue; - files.push({ from, to: path.join(destDir, episodeFileName(name, video.youtubeId, stem)), ...stat }); + sourceStats.set(from, stat); + files.push({ + from, to: path.join(destDir, episodeFileName(name, video.youtubeId, stem)), size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs), + }); } } const mediaPath = video.filePath || video.audioFilePath; @@ -122,7 +130,7 @@ async function planFiles(video, destDir, stem, index = new DirectoryIndex()) { const destinationOf = (source) => (source ? (files.find((file) => file.from === source) || {}).to || null : null); const collisions = []; for (const file of files) { - if (await existsOtherThan(file.to, file.from)) collisions.push(file.to); + if (await existsOtherThan(file.to, file.from, sourceStats.get(file.from))) collisions.push(file.to); } return { files, diff --git a/server/modules/reorganize/itemExecutor.js b/server/modules/reorganize/itemExecutor.js index 6e7708860..dc28e4996 100644 --- a/server/modules/reorganize/itemExecutor.js +++ b/server/modules/reorganize/itemExecutor.js @@ -145,8 +145,13 @@ async function moveFiles(files, mediaPaths) { } async function removeOldNfos(nfoSources, keep) { + const kept = await statOrNull(keep); for (const nfo of nfoSources) { if (nfo === keep) continue; + // The NFO just written under another spelling (a case-only rename on a + // case-insensitive filesystem). + const stat = kept ? await statOrNull(nfo) : null; + if (stat && stat.ino === kept.ino && stat.dev === kept.dev) continue; await fs.promises.rm(nfo, { force: true }); } } diff --git a/server/modules/reorganize/operationRunner.js b/server/modules/reorganize/operationRunner.js index 90d79e3f3..850b01c5b 100644 --- a/server/modules/reorganize/operationRunner.js +++ b/server/modules/reorganize/operationRunner.js @@ -328,8 +328,12 @@ class OperationRunner { if (!operation.settings_applied) { const layoutBefore = plan ? plan.context.layoutBefore : await getLayoutResolver(); - shows = await applySettings({ change: settings.change, shows, layoutBefore }); - await operationStore.markSettingsApplied(operation, shows); + shows = await applySettings({ + change: settings.change, + shows, + layoutBefore, + markApplied: (pinned, transaction) => operationStore.markSettingsApplied(operation, pinned, { transaction }), + }); } // A channel show by its owner channel, a title show by its key. const showIds = new Map(shows.map((show) => [show.key || show.ownerChannelId, show.showId])); diff --git a/server/modules/reorganize/operationStore.js b/server/modules/reorganize/operationStore.js index bee313f97..b48c96f35 100644 --- a/server/modules/reorganize/operationStore.js +++ b/server/modules/reorganize/operationStore.js @@ -79,12 +79,12 @@ function settingsOf(operation) { return JSON.parse(operation.settings_change); } -async function markSettingsApplied(operation, shows) { +async function markSettingsApplied(operation, shows, { transaction = null } = {}) { const settings = settingsOf(operation); await operation.update({ settings_change: JSON.stringify({ ...settings, shows }), settings_applied: true, - }); + }, { transaction }); } async function itemsWithStatus(operationId, statuses) { diff --git a/server/modules/reorganize/settingsApplier.js b/server/modules/reorganize/settingsApplier.js index e48b05976..1f48e9133 100644 --- a/server/modules/reorganize/settingsApplier.js +++ b/server/modules/reorganize/settingsApplier.js @@ -89,11 +89,21 @@ async function findChannelOrFail(channelId) { // A title show change: the channel's shows and every episode row as planned // (the plan is recomputed: the lock is held, so it is the one previewed). -async function applyTitleShows(change, pinned) { +// It is recorded as applied in the same transaction: a restart between the +// two would replay it, and its new shows would then hold their own folders. +async function applyTitleShows(change, pinned, markApplied) { const channel = await findChannelOrFail(change.channelId); const { drafts, plan } = await titleShowSaver.prepare({ channel, rawShows: change.shows, rawOverrides: change.overrides || [] }); - const { showIds } = await titleShowSaver.applyPrepared({ channel, drafts, plan }); - return pinned.map((show) => (show.kind === KIND_TITLE_SHOW ? { ...show, showId: showIds.get(show.key) || show.showId } : show)); + const withIds = ({ showIds }) => pinned.map((show) => ( + show.kind === KIND_TITLE_SHOW ? { ...show, showId: showIds.get(show.key) || show.showId } : show + )); + const saved = await titleShowSaver.applyPrepared({ + channel, + drafts, + plan, + onWritten: markApplied ? (result, transaction) => markApplied(withIds(result), transaction) : null, + }); + return withIds(saved); } /** @@ -103,9 +113,11 @@ async function applyTitleShows(change, pinned) { * @param {Object} params.change - The stored (normalized) change * @param {Array<Object>} params.shows - Planned shows * @param {(libraryFolder: string) => string} params.layoutBefore - Layouts before the change + * @param {(shows: Array<Object>, transaction?: Object) => Promise<void>} [params.markApplied] - Records + * the change as applied (an operation's); a title show change calls it inside its write transaction * @returns {Promise<Array<Object>>} The shows with their ids */ -async function applySettings({ change, shows, layoutBefore }) { +async function applySettings({ change, shows, layoutBefore, markApplied = null }) { const pinned = []; for (const planned of shows) pinned.push(await pinShow(planned)); @@ -139,8 +151,9 @@ async function applySettings({ change, shows, layoutBefore }) { await subfolderModule.register(change.value); setDefaultSubfolder(change.value); } else if (change.type === CHANGE_TITLE_SHOWS) { - return applyTitleShows(change, pinned); + return applyTitleShows(change, pinned, markApplied); } + if (markApplied) await markApplied(pinned); return pinned; } diff --git a/server/modules/reorganize/titleAssignments.js b/server/modules/reorganize/titleAssignments.js index 9b207ae8f..5957c211d 100644 --- a/server/modules/reorganize/titleAssignments.js +++ b/server/modules/reorganize/titleAssignments.js @@ -5,11 +5,12 @@ * numbered from its stored info.json the way the post-processor would. */ -const { releaseTime, dateEpisodeFor, allocateEpisode } = require('../tvShows/dateNumbering'); +const { releaseTime, dateEpisodeFor, allocateEpisode, SOURCE_UPLOAD_DATE } = require('../tvShows/dateNumbering'); const { buildEpisodeStem } = require('../tvShows/episodeNaming'); const { SEASON_SOURCE, EPISODE_SOURCE } = require('../tvShows/patternCompiler'); const { ROW_STATUS, SOURCE } = require('../tvShows/titleNumbering'); const { KIND_TITLE_SHOW } = require('../tvShows/constants'); +const { FLAG } = require('./constants'); function setOf(map, key) { if (!map.has(key)) map.set(key, new Set()); @@ -21,7 +22,7 @@ function timeOf(entry) { const release = releaseTime(entry.info || {}); if (release) return release; const downloaded = entry.downloadedAt ? Math.floor(new Date(entry.downloadedAt).getTime() / 1000) : null; - return Number.isFinite(downloaded) ? { epochSeconds: downloaded, source: null } : null; + return Number.isFinite(downloaded) ? { epochSeconds: downloaded, source: null, downloadTime: true } : null; } /** @@ -31,11 +32,13 @@ function timeOf(entry) { * after: the title plan's row; pattern: its { seasonSource, episodeSource }; stored: the video's stored row * @param {Map<number, Set<number>>} params.taken - Numbers other videos hold in the show after the change, by season * @param {Map<number, number>} params.highWater - Order high-water marks by season - * @returns {{assignments: Map<string, Object>, noDate: Set<string>, taken: Set<string>}} + * @returns {{assignments: Map<string, Object>, flags: Map<string, string[]>, noDate: Set<string>, taken: Set<string>}} + * flags: the review's notes on how a waiting video was numbered (download time, upload day); * taken: videos whose title number another video holds */ function assignTitleEpisodes({ show, entries, taken, highWater }) { const assignments = new Map(); + const flags = new Map(); const noDate = new Set(); const numberTaken = new Set(); const held = new Map([...taken].map(([season, numbers]) => [season, new Set(numbers)])); @@ -43,7 +46,8 @@ function assignTitleEpisodes({ show, entries, taken, highWater }) { const classification = (entry, { season, episode, source, timestampSource = null }) => { const { stored } = entry; - const keepsStem = stored && stored.showKey === show.key && stored.season === season && stored.episode === episode && stored.fileStem; + // A number that stays keeps its time source and its stem, as the row writer does. + const keepsNumber = Boolean(stored) && stored.showKey === show.key && stored.season === season && stored.episode === episode; return { showKey: show.key, kind: KIND_TITLE_SHOW, @@ -52,9 +56,9 @@ function assignTitleEpisodes({ show, entries, taken, highWater }) { season, episode, source, - timestampSource, + timestampSource: keepsNumber ? stored.timestampSource ?? null : timestampSource, episodeTitle: entry.after.episodeTitle || entry.title || null, - fileStem: keepsStem ? stored.fileStem : buildEpisodeStem({ + fileStem: keepsNumber && stored.fileStem ? stored.fileStem : buildEpisodeStem({ season, episode, dateNumbered: source === SOURCE.DATE, episodeTitle: entry.after.episodeTitle, videoTitle: entry.title, youtubeId: entry.youtubeId, }), }; @@ -102,8 +106,11 @@ function assignTitleEpisodes({ show, entries, taken, highWater }) { assignments.set(entry.youtubeId, classification(entry, { season, episode, source, timestampSource: source === SOURCE.DATE ? time.source : null, })); + // An upload day settles a year season but not a date episode's time. + if (time.downloadTime) flags.set(entry.youtubeId, [FLAG.DOWNLOAD_TIME]); + else if (source === SOURCE.DATE && time.source === SOURCE_UPLOAD_DATE) flags.set(entry.youtubeId, [FLAG.UPLOAD_DATE_ONLY]); } - return { assignments, noDate, taken: numberTaken }; + return { assignments, flags, noDate, taken: numberTaken }; } module.exports = { diff --git a/server/modules/reorganize/titleSnapshot.js b/server/modules/reorganize/titleSnapshot.js index 4f4fe984d..56be44212 100644 --- a/server/modules/reorganize/titleSnapshot.js +++ b/server/modules/reorganize/titleSnapshot.js @@ -5,7 +5,7 @@ */ const { sequelize } = require('../../db'); -const { VideoClassification, EpisodeConflict, Video } = require('../../models'); +const { VideoClassification, EpisodeConflict, Video, TvShowPattern } = require('../../models'); const titleShowStore = require('../tvShows/titleShowStore'); const episodeConflicts = require('../tvShows/episodeConflicts'); const archiveSuppressor = require('../tvShows/archiveSuppressor'); @@ -113,6 +113,18 @@ async function restoreConflicts(channelId, snapshot, transaction) { } } +// The patterns of shows that stay removed (saveDefinitions leaves them as +// they are), by pattern key, so the rows of those shows keep pointing at them. +async function removedShowPatternIds(snapshot, drafts, transaction) { + const saved = new Set(drafts.map((show) => show.id)); + const showIds = snapshot.shows.filter((show) => !saved.has(show.id)).map((show) => show.id); + if (showIds.length === 0) return new Map(); + const patterns = await TvShowPattern.findAll({ + where: { show_id: showIds }, attributes: ['id', 'show_id', 'position'], raw: true, transaction, + }); + return new Map(patterns.map((pattern) => [`title:${pattern.show_id}#${pattern.position}`, pattern.id])); +} + // Shows the change added are deleted, not retired: a retired show would hold // its folder name, and a retry of the same change would be refused for it. async function deleteAddedShows(channelId, snapshot, transaction) { @@ -135,7 +147,8 @@ async function restoreTitleSnapshot(channel, snapshot) { await sequelize.transaction(async (transaction) => { const drafts = snapshot.shows.filter((show) => !show.retired); const { patternIds } = await titleShowStore.saveDefinitions({ channelId, drafts, transaction }); - await restoreRows(channelId, snapshot, patternIds, transaction); + const removedPatternIds = await removedShowPatternIds(snapshot, drafts, transaction); + await restoreRows(channelId, snapshot, new Map([...removedPatternIds, ...patternIds]), transaction); await restoreConflicts(channelId, snapshot, transaction); await deleteAddedShows(channelId, snapshot, transaction); }); diff --git a/server/modules/reorganize/titleTargets.js b/server/modules/reorganize/titleTargets.js index 5e0d832c7..b1d8fbb0c 100644 --- a/server/modules/reorganize/titleTargets.js +++ b/server/modules/reorganize/titleTargets.js @@ -11,7 +11,6 @@ const { VideoClassification, TvShow } = require('../../models'); const { KIND_TITLE_SHOW } = require('../tvShows/constants'); -const { folderNameKey } = require('../tvShows/showFolderNames'); const { ROW_STATUS } = require('../tvShows/titleNumbering'); const { CHANGE_TITLE_SHOWS, SHOW_ACTION } = require('./constants'); @@ -44,7 +43,8 @@ function fromTitlePlan(subjects, context) { }); if (shows.has(draft.key)) continue; const before = titlePlan.storedShows.get(draft.key); - const moved = before && folderNameKey(before.libraryFolder, before.folderName) !== folderNameKey(draft.libraryFolder, draft.folderName); + // Spelled exactly: a rename that changes only case or accents moves the files too. + const moved = before && ((before.libraryFolder || '') !== (draft.libraryFolder || '') || before.folderName !== draft.folderName); shows.set(draft.key, plannedShow({ key: draft.key, ownerChannelId: channelId, @@ -86,7 +86,7 @@ async function fromStoredRows(subjects, channels) { episodeTitle: row.episode_title, }, pattern: null, - stored: { showKey: key, season: row.season, episode: row.episode, fileStem: row.file_stem }, + stored: { showKey: key, season: row.season, episode: row.episode, fileStem: row.file_stem, timestampSource: row.timestamp_source }, }); if (!shows.has(key)) { shows.set(key, plannedShow({ diff --git a/server/modules/subfolderModule.js b/server/modules/subfolderModule.js index 2db338c35..e2b6597d7 100644 --- a/server/modules/subfolderModule.js +++ b/server/modules/subfolderModule.js @@ -9,7 +9,7 @@ const configModule = require('./configModule'); const { buildSubfolderSegment, directoryHasFiles, removeIfEmpty, resolveEffectiveSubfolder } = require('./filesystem'); const { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } = require('./filesystem/constants'); const { getLayoutResolver } = require('./tvShows/libraryLayouts'); -const { LAYOUT_TV } = require('./tvShows/constants'); +const { LAYOUT_TV, KIND_TITLE_SHOW } = require('./tvShows/constants'); const SENTINELS = new Set([GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL]); @@ -74,9 +74,10 @@ async function tvChannelLookup(channelIds) { /** * TV shows with numbered episodes, per library folder (lowercased). Their * numbers are kept even when files are deleted, so a re-download returns to - * the same episode; a show without any only pins a location. A show whose - * tracked channel has since moved to a videos folder is not counted; an - * untracked channel's show always is, since the folder is all it has. + * the same episode; a show without any only pins a location. A channel show + * whose tracked channel has since moved to a videos folder is not counted; an + * untracked channel's show always is, since the folder is all it has, and so + * is a title show, which lives in its TV folder whatever folder its channel uses. * @returns {Promise<Map<string, number>>} */ async function tallyNumberedShows() { @@ -90,11 +91,11 @@ async function tallyNumberedShows() { if (showIds.length === 0) return counts; const shows = await TvShow.findAll({ where: { id: showIds, retired_at: null }, - attributes: ['library_folder', 'channel_id'], + attributes: ['library_folder', 'channel_id', 'kind'], }); const channels = await tvChannelLookup([...new Set(shows.map((show) => show.channel_id).filter(Boolean))]); for (const show of shows) { - if (channels.isTracked(show.channel_id) && !channels.isTv(show.channel_id)) continue; + if (show.kind !== KIND_TITLE_SHOW && channels.isTracked(show.channel_id) && !channels.isTv(show.channel_id)) continue; const key = String(show.library_folder || '').trim().toLowerCase(); if (key) counts.set(key, (counts.get(key) || 0) + 1); } diff --git a/server/modules/tvShows/__tests__/archiveSuppressor.test.js b/server/modules/tvShows/__tests__/archiveSuppressor.test.js index dd3c40140..76819385b 100644 --- a/server/modules/tvShows/__tests__/archiveSuppressor.test.js +++ b/server/modules/tvShows/__tests__/archiveSuppressor.test.js @@ -47,6 +47,13 @@ describe('archiveSuppressor', () => { archiveModule.isVideoInArchive.mockImplementation(async (id) => archiveModule.addVideoToArchive.mock.calls.some(([added]) => added === id)); }); + // Callers flush after their own write committed; a failed read must not + // turn that into a failure (the rows stay pending for the next flush). + it('resolves when the pending rows cannot be read', async () => { + models.EpisodeConflict.findAll.mockRejectedValue(new Error('connection lost')); + await expect(suppressor.flush()).resolves.toBeUndefined(); + }); + it('adds a pending line and records that Youtarr wrote it', async () => { const row = conflict('a', { archive_pending: 'add' }); models.EpisodeConflict.findAll.mockResolvedValue([row]); diff --git a/server/modules/tvShows/__tests__/episodeConflicts.test.js b/server/modules/tvShows/__tests__/episodeConflicts.test.js index 6d2d64795..9084b562c 100644 --- a/server/modules/tvShows/__tests__/episodeConflicts.test.js +++ b/server/modules/tvShows/__tests__/episodeConflicts.test.js @@ -187,6 +187,20 @@ describe('episodeConflicts', () => { await conflicts.clearErrorsForChannel(CHANNEL_ID, { transaction: 't' }); expect(models.EpisodeConflict.destroy).toHaveBeenCalledWith({ where: { channel_id: CHANNEL_ID, kind: 'classification_error' }, transaction: 't' }); }); + + it('removes only the errors of the videos given', async () => { + models.EpisodeConflict.destroy = jest.fn(); + await conflicts.clearErrorsForChannel(CHANNEL_ID, { transaction: 't', youtubeIds: ['a'] }); + expect(models.EpisodeConflict.destroy).toHaveBeenCalledWith({ + where: { channel_id: CHANNEL_ID, kind: 'classification_error', youtube_id: ['a'] }, transaction: 't', + }); + }); + + it('removes nothing when no video is given', async () => { + models.EpisodeConflict.destroy = jest.fn(); + await conflicts.clearErrorsForChannel(CHANNEL_ID, { youtubeIds: [] }); + expect(models.EpisodeConflict.destroy).not.toHaveBeenCalled(); + }); }); describe('duplicateIdsForChannel', () => { diff --git a/server/modules/tvShows/__tests__/patternCompiler.test.js b/server/modules/tvShows/__tests__/patternCompiler.test.js index e2a1ccb2a..e136db210 100644 --- a/server/modules/tvShows/__tests__/patternCompiler.test.js +++ b/server/modules/tvShows/__tests__/patternCompiler.test.js @@ -120,6 +120,37 @@ describe('patternCompiler', () => { expect(() => regex('(?P<episode>[0-9]+) (?P=episode)')).toThrow('backreference'); }); + // Unnaming groups, and joining a show's patterns into one alternation, + // renumbers groups: a \1 would point at another group in the filter. + it('refuses numbered backreferences', () => { + expect(() => regex('^(?P<episode>[0-9]+)-(x)-\\1$')).toThrow('backreference'); + }); + + it('refuses numbered backreferences in a pattern without named groups', () => { + expect(() => regex('(a)\\12')).toThrow('backreference'); + }); + + // Python reads a backslash and three octal digits as a character. + it('allows a three-digit octal escape', () => { + expect(regex('^\\123(?P<episode>[0-9]+)$').filterRegex).toBe('(?:^\\123(?:[0-9]+)$)'); + }); + + it('allows a three-digit octal escape followed by another digit', () => { + expect(regex('\\1234(?P<episode>[0-9]+)').compiledRegex).toBe('\\1234(?P<episode>[0-9]+)'); + }); + + it.each(['(a)\\12', '(a)\\18', '(a)\\8', '(a)\\1x'])('refuses %s as a backreference', (pattern) => { + expect(() => regex(pattern)).toThrow('backreference'); + }); + + it('refuses conditional groups', () => { + expect(() => regex('(a)?(?(1)b|c)(?P<episode>[0-9]+)')).toThrow('Conditional'); + }); + + it('allows escaped backslashes before digits, octal escapes and digits in a character class', () => { + expect(regex('a\\\\1\\0[\\1](?P<episode>[0-9]+)').filterRegex).toBe('(?:a\\\\1\\0[\\1](?:[0-9]+))'); + }); + it('refuses group names other than the placeholders', () => { expect(() => regex('(?P<number>[0-9]+)')).toThrow('number'); }); diff --git a/server/modules/tvShows/__tests__/titleNumbering.test.js b/server/modules/tvShows/__tests__/titleNumbering.test.js index 38c8cf817..0127c6179 100644 --- a/server/modules/tvShows/__tests__/titleNumbering.test.js +++ b/server/modules/tvShows/__tests__/titleNumbering.test.js @@ -70,6 +70,24 @@ describe('titleNumbering.planNumbers', () => { expect([result.rows.get('a').episode, result.rows.get('b').episode]).toEqual([8, 9]); }); + // A video without a date (publishedAtMs 0) is not the oldest upload. + it('allocates order numbers to videos without a date after the dated ones', () => { + const order = { kind: MATCH_KIND.ORDER, showKey: 'title:2', patternKey: 'p2', season: 0, episode: null, episodeTitle: null, reason: null }; + const result = plan({ + videos: [{ ...video('dateless'), publishedAtMs: 0 }, video('dated', { daysAgo: 5 })], + matches: new Map([['dateless', order], ['dated', order]]), + }); + expect([result.rows.get('dated').episode, result.rows.get('dateless').episode]).toEqual([1, 2]); + }); + + it('gives a shared number to a dated upload over one without a date', () => { + const result = plan({ + videos: [{ ...video('dateless'), publishedAtMs: 0 }, video('dated', { daysAgo: 5 })], + matches: new Map([['dateless', numbered(20)], ['dated', numbered(20)]]), + }); + expect(result.rows.get('dateless').status).toBe(ROW_STATUS.DUPLICATE); + }); + it('raises the high-water mark past the numbers it allocates', () => { const order = { kind: MATCH_KIND.ORDER, showKey: 'title:2', patternKey: 'p2', season: 0, episode: null, episodeTitle: null, reason: null }; const result = plan({ videos: [video('a')], matches: new Map([['a', order]]), highWater: new Map([['title:2|0', 7]]) }); @@ -362,6 +380,22 @@ describe('titleNumbering.planNumbers frozen videos', () => { }); expect(result.rows.has('a')).toBe(false); }); + + // A listing refresh leaves out a video that would have to move; the video + // it would have beaten takes the number instead of becoming its duplicate. + it('lets a frozen video without a row claim no number', () => { + const result = plan({ + videos: [video('left-out', { daysAgo: 700 }), video('later', { daysAgo: 10 })], + matches: new Map([['left-out', numbered(20)], ['later', numbered(20)]]), + }); + const frozenResult = planNumbers({ + videos: [video('left-out', { daysAgo: 700 }), video('later', { daysAgo: 10 })], + matches: new Map([['left-out', numbered(20)], ['later', numbered(20)]]), + stored: new Map(), highWater: new Map(), frozen: new Set(['left-out']), + }); + expect([result.rows.get('later').status, frozenResult.rows.get('later').status, frozenResult.duplicates]) + .toEqual([ROW_STATUS.DUPLICATE, ROW_STATUS.ASSIGNED, []]); + }); }); describe('titleNumbering.computeGaps', () => { diff --git a/server/modules/tvShows/__tests__/titlePlanner.test.js b/server/modules/tvShows/__tests__/titlePlanner.test.js index 08b5ce01d..de39d85ca 100644 --- a/server/modules/tvShows/__tests__/titlePlanner.test.js +++ b/server/modules/tvShows/__tests__/titlePlanner.test.js @@ -117,6 +117,21 @@ describe('titlePlanner', () => { expect([plan.entries[0].moves, plan.relocated]).toEqual([true, ['title:3']]); }); + // The database compares folder names ignoring case and accents; most + // filesystems don't, so a new download would land in a second folder. + it.each([ + ['case', 'beyblade'], + ['accents', 'Beybladé'], + ])('moves the downloaded episodes of a show whose folder name changes only in %s', (_label, folderName) => { + const plan = build({ + drafts: [draftShow('title:3', { folderName })], + videos: [video('a', { downloaded: true, filePath: '/d/__TV Shows/Beyblade/Season 01/S01E20 - E20 [a].mp4' })], + stored: new Map([['a', storedRow()]]), + matches: new Map([['a', numbered('title:3', 20)]]), + }); + expect([plan.entries[0].moves, plan.relocated]).toEqual([true, ['title:3']]); + }); + it('moves a downloaded episode out of a show it leaves', () => { const plan = build({ drafts: [], @@ -137,7 +152,7 @@ describe('titlePlanner', () => { it('moves a downloaded duplicate nowhere', () => { const plan = build({ - videos: [video('old'), video('new', { downloaded: true, publishedAtMs: 5, filePath: '/d/__Kids/Ch/new.mp4' })], + videos: [video('old', { publishedAtMs: 1 }), video('new', { downloaded: true, publishedAtMs: 5, filePath: '/d/__Kids/Ch/new.mp4' })], matches: new Map([['old', numbered('title:3', 20)], ['new', numbered('title:3', 20)]]), }); expect(plan.entries.find((entry) => entry.youtubeId === 'new')).toMatchObject({ moves: false, after: expect.objectContaining({ status: 'duplicate' }) }); diff --git a/server/modules/tvShows/__tests__/titleRowWriter.test.js b/server/modules/tvShows/__tests__/titleRowWriter.test.js index 6d9f254ca..6c574e3ef 100644 --- a/server/modules/tvShows/__tests__/titleRowWriter.test.js +++ b/server/modules/tvShows/__tests__/titleRowWriter.test.js @@ -230,7 +230,14 @@ describe('titleRowWriter.applyPlan', () => { it('clears the channel\'s classification errors once its titles classified', async () => { await apply(plan([])); - expect(conflicts.clearErrorsForChannel).toHaveBeenCalledWith(CHANNEL_ID, { transaction: 't' }); + expect(conflicts.clearErrorsForChannel).toHaveBeenCalledWith(CHANNEL_ID, { transaction: 't', youtubeIds: null }); + }); + + // A listing refresh classifies only its new videos: an error recorded at + // download for another video stays until that title is checked again. + it('clears only the errors of the videos it is told were classified', async () => { + await writer.applyPlan({ channel, drafts: [], plan: plan([]), highWaterBefore: new Map(), transaction: 't', clearErrorsOf: ['abcdefghijk'] }); + expect(conflicts.clearErrorsForChannel).toHaveBeenCalledWith(CHANNEL_ID, { transaction: 't', youtubeIds: ['abcdefghijk'] }); }); it('raises the high-water marks of seasons that allocated order numbers', async () => { diff --git a/server/modules/tvShows/__tests__/titleShowDrafts.test.js b/server/modules/tvShows/__tests__/titleShowDrafts.test.js index e437b7e38..b989b8182 100644 --- a/server/modules/tvShows/__tests__/titleShowDrafts.test.js +++ b/server/modules/tvShows/__tests__/titleShowDrafts.test.js @@ -52,6 +52,10 @@ describe('titleShowDrafts', () => { expect(() => normalizeDrafts([draft(), draft({ name: 'beyblade' })], context)).toThrow('same folder'); }); + it('refuses the same show twice', () => { + expect(() => normalizeDrafts([draft({ id: 4 }), draft({ id: 4, name: 'Other' })], context)).toThrow('more than once'); + }); + it('compiles each pattern', () => { const [show] = normalizeDrafts([draft()], context); expect(show.patterns[0]).toMatchObject({ diff --git a/server/modules/tvShows/__tests__/titleShowSaver.test.js b/server/modules/tvShows/__tests__/titleShowSaver.test.js index 06b9bc014..8f3dc0bfb 100644 --- a/server/modules/tvShows/__tests__/titleShowSaver.test.js +++ b/server/modules/tvShows/__tests__/titleShowSaver.test.js @@ -10,7 +10,7 @@ jest.mock('../titleShowDrafts', () => ({ assertDraftsCompile: jest.fn(), })); jest.mock('../titleShowStore', () => ({ - assertFolderNamesFree: jest.fn(), highWaterMarks: jest.fn(async () => new Map()), listTitleShows: jest.fn(async () => []), + assertOwnShows: jest.fn(), assertFolderNamesFree: jest.fn(), highWaterMarks: jest.fn(async () => new Map()), listTitleShows: jest.fn(async () => []), })); jest.mock('../titlePlanner', () => ({ planChannel: jest.fn() })); jest.mock('../titleRowWriter', () => ({ @@ -112,6 +112,13 @@ describe('titleShowSaver', () => { })).rejects.toMatchObject({ status: 400 }); }); + // The preview would otherwise let such a draft keep that show's folder. + it('refuses a show the channel doesn\'t have before planning', async () => { + require('../titleShowStore').assertOwnShows.mockRejectedValue(Object.assign(new Error('Show not found'), { status: 404 })); + await expect(saver.prepare({ channel, rawShows: [{ ...rawShows[0], id: 9 }] })).rejects.toMatchObject({ status: 404 }); + expect(planner.planChannel).not.toHaveBeenCalled(); + }); + it('refuses an override for an invalid video id', async () => { await expect(saver.prepare({ channel, rawShows, rawOverrides: [{ youtubeId: '../x', notAnEpisode: true }] })) .rejects.toMatchObject({ status: 400 }); @@ -164,6 +171,21 @@ describe('titleShowSaver', () => { expect([result.showIds.get('new:0'), writer.applyPlan.mock.calls[0][0].transaction]).toEqual([9, 't']); expect(suppressor.flush).toHaveBeenCalled(); }); + + it('runs the caller\'s record inside the write transaction', async () => { + const saved = { showIds: new Map([['new:0', 9]]), patternIds: new Map() }; + writer.applyPlan.mockResolvedValue(saved); + const onWritten = jest.fn(); + await saver.applyPrepared({ channel, drafts: [], plan: plan(true), onWritten }); + expect(onWritten).toHaveBeenCalledWith(saved, 't'); + }); + + it('fails the write when the caller\'s record fails', async () => { + writer.applyPlan.mockResolvedValue({ showIds: new Map(), patternIds: new Map() }); + const onWritten = jest.fn().mockRejectedValue(new Error('record failed')); + await expect(saver.applyPrepared({ channel, drafts: [], plan: plan(true), onWritten })).rejects.toThrow('record failed'); + expect(suppressor.flush).not.toHaveBeenCalled(); + }); }); describe('classifyNew', () => { @@ -194,6 +216,46 @@ describe('titleShowSaver', () => { expect(writer.applyPlan.mock.calls[0][0].plan.entries).toEqual([{ youtubeId: 'b', moves: false }]); }); + it('plans again without the videos that would move, so they spend no episode numbers', async () => { + require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); + planner.planChannel + .mockResolvedValueOnce({ ...plan(true), entries: [{ youtubeId: 'a', moves: true }, { youtubeId: 'b', moves: false }], highWater: new Map([['title:3|1', 2]]) }) + .mockResolvedValueOnce({ ...plan(false), entries: [{ youtubeId: 'b', moves: false }], highWater: new Map([['title:3|1', 1]]) }); + await saver.classifyNew({ channel, youtubeIds: ['a', 'b'] }); + expect([planner.planChannel.mock.calls[1][0].onlyIds, writer.applyPlan.mock.calls[0][0].plan.highWater]) + .toEqual([new Set(['b']), new Map([['title:3|1', 1]])]); + }); + + // A and B are downloaded and claim the same episode as C, oldest first: + // each winner that has to move is left out in turn, until C wins. + it('plans again until no winner has to move, so no duplicate points at a video left out', async () => { + require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); + const duplicateOf = (youtubeId, winner) => ({ youtubeId, showKey: 'title:3', season: 1, episode: 20, duplicateOf: winner }); + planner.planChannel + .mockResolvedValueOnce({ + ...plan(true), + entries: [{ youtubeId: 'a', moves: true }, { youtubeId: 'b', moves: false }, { youtubeId: 'c', moves: false }], + duplicates: [duplicateOf('b', 'a'), duplicateOf('c', 'a')], + }) + .mockResolvedValueOnce({ + ...plan(true), + entries: [{ youtubeId: 'b', moves: true }, { youtubeId: 'c', moves: false }], + duplicates: [duplicateOf('c', 'b')], + }) + .mockResolvedValueOnce({ ...plan(false), entries: [{ youtubeId: 'c', moves: false }] }); + await saver.classifyNew({ channel, youtubeIds: ['a', 'b', 'c'] }); + const written = writer.applyPlan.mock.calls[0][0]; + expect([planner.planChannel.mock.calls[2][0].onlyIds, written.plan.entries, written.plan.duplicates, written.clearErrorsOf]) + .toEqual([new Set(['c']), [{ youtubeId: 'c', moves: false }], [], ['c']]); + }); + + it('clears only the classification errors of the videos it classified', async () => { + require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); + planner.planChannel.mockResolvedValue({ ...plan(true), entries: [{ youtubeId: 'a', moves: true }, { youtubeId: 'b', moves: false }] }); + await saver.classifyNew({ channel, youtubeIds: ['a', 'b'] }); + expect(writer.applyPlan.mock.calls[0][0].clearErrorsOf).toEqual(['b']); + }); + it('classifies again once when a download changed an episode row meanwhile', async () => { require('../titleShowStore').listTitleShows.mockResolvedValue([storedShow]); planner.planChannel.mockResolvedValue(plan(false)); diff --git a/server/modules/tvShows/__tests__/titleShowService.test.js b/server/modules/tvShows/__tests__/titleShowService.test.js index 29380cdd5..92a8dd1a6 100644 --- a/server/modules/tvShows/__tests__/titleShowService.test.js +++ b/server/modules/tvShows/__tests__/titleShowService.test.js @@ -110,6 +110,11 @@ describe('titleShowService', () => { expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(3), draftOf(4), { name: 'New' }]); }); + it('adds a new show even when the request names an id', async () => { + await service.createShow(channel, { name: 'New', id: 4 }); + expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(3), draftOf(4), { name: 'New' }]); + }); + it('replaces a show\'s definition, keeping its id', async () => { await service.updateShow(channel, 4, { name: 'Renamed', id: 99 }); expect(saver.save.mock.calls[0][0].rawShows).toEqual([draftOf(3), { name: 'Renamed', id: 4 }]); diff --git a/server/modules/tvShows/__tests__/titleShowStore.test.js b/server/modules/tvShows/__tests__/titleShowStore.test.js index 5d2edacd7..18422f2f3 100644 --- a/server/modules/tvShows/__tests__/titleShowStore.test.js +++ b/server/modules/tvShows/__tests__/titleShowStore.test.js @@ -150,12 +150,38 @@ describe('titleShowStore', () => { .rejects.toMatchObject({ details: { retiredShowId: 2 } }); }); + // The location index is unique, so a hand-off within one change would fail + // partway through the save or its undo, depending on the order of the writes. + it('refuses a folder another show of the channel gives up in the same change', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 3, name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', channel_id: CHANNEL_ID, kind: 'title', retired_at: null }]); + const drafts = [draft('title:3', { folderName: 'Beyblade (2001)' }), draft('new:1')]; + await expect(store.assertFolderNamesFree({ channelTitle: 'x', channelId: CHANNEL_ID, drafts })).rejects.toMatchObject({ status: 409 }); + }); + it('lets a show keep its own folder', async () => { models.TvShow.findAll.mockResolvedValue([{ id: 3, name: 'Beyblade', folder_name: 'Beyblade', library_folder: 'TV Shows', channel_id: CHANNEL_ID, kind: 'title', retired_at: null }]); await expect(store.assertFolderNamesFree({ channelTitle: 'x', channelId: CHANNEL_ID, drafts: [draft('title:3')] })).resolves.toBeUndefined(); }); }); + describe('assertOwnShows', () => { + it('refuses a draft naming a show of another channel', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 3 }]); + await expect(store.assertOwnShows({ channelId: CHANNEL_ID, drafts: [draft('title:3'), draft('title:9')] })) + .rejects.toMatchObject({ status: 404 }); + }); + + it('accepts the channel\'s own shows and new ones', async () => { + models.TvShow.findAll.mockResolvedValue([{ id: 3 }]); + await expect(store.assertOwnShows({ channelId: CHANNEL_ID, drafts: [draft('title:3'), draft('new:0')] })).resolves.toBeUndefined(); + }); + + it('reads nothing when every draft is new', async () => { + await store.assertOwnShows({ channelId: CHANNEL_ID, drafts: [draft('new:0')] }); + expect(models.TvShow.findAll).not.toHaveBeenCalled(); + }); + }); + describe('highWaterMarks', () => { it('reads the order high-water mark of each show season', async () => { models.TvShowSeason.findAll.mockResolvedValue([{ show_id: 3, season: 1, order_high_water: 4 }]); diff --git a/server/modules/tvShows/archiveSuppressor.js b/server/modules/tvShows/archiveSuppressor.js index 2c1e1017c..6a36dc3e4 100644 --- a/server/modules/tvShows/archiveSuppressor.js +++ b/server/modules/tvShows/archiveSuppressor.js @@ -7,7 +7,8 @@ * from where it started, so the file must not change under a running job. * Requests are stored on their episode_conflicts row (archive_pending) and * applied when no download job is In Progress: right away when the queue is - * idle, else when jobModule is about to start the next job, and at startup. + * idle, else before jobModule starts the next job (which waits for a pass + * already running), and at startup. */ const { EpisodeConflict } = require('../../models'); @@ -48,7 +49,15 @@ class ArchiveSuppressor { async _flush() { if (this.isDownloadRunning()) return; - const rows = await EpisodeConflict.findAll({ where: { archive_pending: [ARCHIVE_ADD, ARCHIVE_REMOVE] } }); + // Callers flush after their own write committed, so a failed read is + // logged, not thrown: the rows stay pending for the next flush. + let rows; + try { + rows = await EpisodeConflict.findAll({ where: { archive_pending: [ARCHIVE_ADD, ARCHIVE_REMOVE] } }); + } catch (err) { + logger.error({ err }, 'Could not read the pending complete.list changes for duplicate episodes'); + return; + } for (const row of rows) { if (this.isDownloadRunning()) return; try { diff --git a/server/modules/tvShows/episodeConflicts.js b/server/modules/tvShows/episodeConflicts.js index 8e3eb0778..8c3f513be 100644 --- a/server/modules/tvShows/episodeConflicts.js +++ b/server/modules/tvShows/episodeConflicts.js @@ -151,9 +151,16 @@ async function clearError(youtubeId, { transaction = null } = {}) { /** * Forget a channel's classification errors: its titles just classified. + * @param {string} channelId + * @param {Object} [options] + * @param {Object} [options.transaction] + * @param {string[]|null} [options.youtubeIds] - Only these videos' errors (null: every one of the channel's) */ -async function clearErrorsForChannel(channelId, { transaction = null } = {}) { - await EpisodeConflict.destroy({ where: { channel_id: channelId, kind: KIND_ERROR }, transaction }); +async function clearErrorsForChannel(channelId, { transaction = null, youtubeIds = null } = {}) { + if (youtubeIds && youtubeIds.length === 0) return; + const where = { channel_id: channelId, kind: KIND_ERROR }; + if (youtubeIds) where.youtube_id = youtubeIds; + await EpisodeConflict.destroy({ where, transaction }); } /** diff --git a/server/modules/tvShows/patternCompiler.js b/server/modules/tvShows/patternCompiler.js index c4946a277..78ca2495e 100644 --- a/server/modules/tvShows/patternCompiler.js +++ b/server/modules/tvShows/patternCompiler.js @@ -116,13 +116,15 @@ function compileSimple(text) { // Walk a regex outside escapes and character classes, calling visit(i) at // each '(' that opens a group; visit returns how many characters it consumed -// (0 to copy the '(' as is). -function scanGroups(source, visit) { +// (0 to copy the '(' as is). onEscape, when given, sees each escape outside +// a character class (the backslash and up to three characters after it). +function scanGroups(source, visit, onEscape = null) { let out = ''; let inClass = false; for (let i = 0; i < source.length;) { const char = source[i]; if (char === '\\') { + if (!inClass && onEscape) onEscape(source.slice(i, i + 4)); out += source.slice(i, i + 2); i += 2; continue; @@ -182,6 +184,9 @@ function compileRegex(text) { if (tail.startsWith('(?P=')) { throw new PatternError('Named backreferences such as (?P=episode) are not supported: the download filter drops group names.'); } + if (tail.startsWith('(?(')) { + throw new PatternError('Conditional groups such as (?(1)...) are not supported: the download filter renumbers groups.'); + } if (FLAG_GROUP.test(tail)) throw new PatternError('Global flags such as (?i) must be at the start of the pattern.'); const named = /^\(\?P<([A-Za-z_][A-Za-z0-9_]*)>/.exec(tail); if (named) { @@ -193,6 +198,11 @@ function compileRegex(text) { groups.push(name); } return null; + }, (escape) => { + // \1 to \99; a backslash and three octal digits is a character, as \0 is. + if (/^\\[1-9]/.test(escape) && !/^\\[0-7]{3}/.test(escape)) { + throw new PatternError('Numbered backreferences such as \\1 are not supported: the download filter renumbers groups.'); + } }); return { diff --git a/server/modules/tvShows/titleNumbering.js b/server/modules/tvShows/titleNumbering.js index 8cf4bb872..2617addd1 100644 --- a/server/modules/tvShows/titleNumbering.js +++ b/server/modules/tvShows/titleNumbering.js @@ -62,11 +62,12 @@ function sameNumber(a, b) { return a.showKey === b.showKey && a.season === b.season && a.episode === b.episode; } -// The earliest available upload first; ids break ties so the result never -// depends on listing order. +// The earliest available upload first, videos without a date (0) after the +// dated ones; ids break ties so the result never depends on listing order. function byUploadOrder(a, b) { if (a.available !== b.available) return a.available ? -1 : 1; - if (a.publishedAtMs !== b.publishedAtMs) return a.publishedAtMs - b.publishedAtMs; + const timeOf = (video) => video.publishedAtMs || Infinity; + if (timeOf(a) !== timeOf(b)) return timeOf(a) < timeOf(b) ? -1 : 1; return a.youtubeId < b.youtubeId ? -1 : a.youtubeId > b.youtubeId ? 1 : 0; } diff --git a/server/modules/tvShows/titlePlanner.js b/server/modules/tvShows/titlePlanner.js index fcdb9fb28..88282ae2e 100644 --- a/server/modules/tvShows/titlePlanner.js +++ b/server/modules/tvShows/titlePlanner.js @@ -15,7 +15,6 @@ const ChannelVideo = require('../../models/channelvideo'); const titleShowStore = require('./titleShowStore'); const { matchVideos } = require('./titleMatcher'); const { planNumbers, ROW_STATUS } = require('./titleNumbering'); -const { folderNameKey } = require('./showFolderNames'); const { KIND_TITLE_SHOW } = require('./constants'); const MEMBERS_ONLY = 'subscriber_only'; @@ -41,8 +40,11 @@ function uploadYearOf(originalDate) { return /^\d{8}$/.test(String(originalDate || '')) ? Number(String(originalDate).slice(0, 4)) : null; } +// A show's folder as spelled: the database tells folder names apart ignoring +// case and accents (folderNameKey), but most filesystems don't, so a rename +// that changes only those still moves the files. function locationKey(show) { - return folderNameKey(show.libraryFolder, show.folderName); + return `${show.libraryFolder || ''}/${show.folderName}`; } // The reorganize moves only files under the downloads folder. diff --git a/server/modules/tvShows/titleRowWriter.js b/server/modules/tvShows/titleRowWriter.js index ecb62c295..e39461f0c 100644 --- a/server/modules/tvShows/titleRowWriter.js +++ b/server/modules/tvShows/titleRowWriter.js @@ -116,9 +116,11 @@ function unchangedSincePlan(youtubeId, before, numberFreed) { * @param {Object} params.transaction * @param {{showIds: Map<string, number>, patternIds: Map<string, number>}} [params.definitions] - The stored * shows' ids when the definitions are unchanged (a listing refresh); otherwise the drafts are saved + * @param {string[]|null} [params.clearErrorsOf] - The videos whose classification errors the plan settles + * (a listing refresh classifies only its new videos); null for every one of the channel's * @returns {Promise<{showIds: Map<string, number>, patternIds: Map<string, number>}>} */ -async function applyPlan({ channel, drafts, plan, highWaterBefore, transaction, definitions = null }) { +async function applyPlan({ channel, drafts, plan, highWaterBefore, transaction, definitions = null, clearErrorsOf = null }) { const channelId = channel.channel_id; const { showIds, patternIds } = definitions || await titleShowStore.saveDefinitions({ channelId, drafts, transaction }); const showIdOf = (key, fallback = null) => (key.startsWith('channel:') ? fallback : showIds.get(key) || null); @@ -210,7 +212,7 @@ async function applyPlan({ channel, drafts, plan, highWaterBefore, transaction, } } - await episodeConflicts.clearErrorsForChannel(channelId, { transaction }); + await episodeConflicts.clearErrorsForChannel(channelId, { transaction, youtubeIds: clearErrorsOf }); for (const [key, value] of plan.highWater) { if (value <= (highWaterBefore.get(key) || 0)) continue; diff --git a/server/modules/tvShows/titleShowDrafts.js b/server/modules/tvShows/titleShowDrafts.js index ffd8d9726..461c0bce0 100644 --- a/server/modules/tvShows/titleShowDrafts.js +++ b/server/modules/tvShows/titleShowDrafts.js @@ -149,9 +149,12 @@ function normalizeDrafts(rawDrafts, context) { if (!Array.isArray(rawDrafts)) throw invalid('shows must be a list.'); if (rawDrafts.length > MAX_SHOWS_PER_CHANNEL) throw invalid(`A channel can have at most ${MAX_SHOWS_PER_CHANNEL} shows.`); const locations = new Set(); + const keys = new Set(); return rawDrafts.map((raw, index) => { if (!raw || typeof raw !== 'object') throw invalid('Each show must be an object.'); const key = showKeyOf(raw, index); + if (keys.has(key)) throw invalid('A show appears more than once.'); + keys.add(key); const name = normalizeName(raw); const folderName = normalizeFolderName(raw, name); const libraryFolder = normalizeLibraryFolder(raw, context); diff --git a/server/modules/tvShows/titleShowSaver.js b/server/modules/tvShows/titleShowSaver.js index 526c06b0f..2d0a622f1 100644 --- a/server/modules/tvShows/titleShowSaver.js +++ b/server/modules/tvShows/titleShowSaver.js @@ -93,6 +93,7 @@ async function prepare({ channel, rawShows, rawOverrides = [] }) { }); const overrides = normalizeOverrides(rawOverrides, drafts); await assertDraftsCompile(drafts); + await titleShowStore.assertOwnShows({ channelId: channel.channel_id, drafts }); await titleShowStore.assertFolderNamesFree({ channelId: channel.channel_id, channelTitle: channel.title || channel.uploader || channel.channel_id, @@ -114,12 +115,21 @@ async function highWaterOf(plan) { * Write a prepared plan (definitions, rows, conflicts) in one transaction, * then apply the archive changes it queued. The reorganize calls this when * it starts a title show change. - * @param {{channel: Object, drafts: Array<Object>, plan: Object}} prepared + * @param {Object} prepared + * @param {Object} prepared.channel + * @param {Array<Object>} prepared.drafts + * @param {Object} prepared.plan + * @param {(saved: Object, transaction: Object) => Promise<void>} [prepared.onWritten] - Runs inside + * the write's transaction with what was saved (the reorganize records its operation as applied) * @returns {Promise<{showIds: Map<string, number>, patternIds: Map<string, number>}>} */ -async function applyPrepared({ channel, drafts, plan }) { +async function applyPrepared({ channel, drafts, plan, onWritten = null }) { const highWaterBefore = await highWaterOf(plan); - const saved = await sequelize.transaction((transaction) => applyPlan({ channel, drafts, plan, highWaterBefore, transaction })); + const saved = await sequelize.transaction(async (transaction) => { + const result = await applyPlan({ channel, drafts, plan, highWaterBefore, transaction }); + if (onWritten) await onWritten(result, transaction); + return result; + }); await archiveSuppressor.flush(); return saved; } @@ -173,9 +183,21 @@ async function classifyNew({ channel, youtubeIds }) { showIds: new Map(shows.map((show) => [show.key, show.id])), patternIds: new Map(shows.flatMap((show) => show.patterns.map((pattern) => [pattern.key, pattern.id]))), }; + const planFor = (ids) => planChannel({ channel, drafts: shows, onlyIds: ids, downloadsDir: configModule.directoryPath }); + const newMovers = (planned, moving) => planned.entries + .filter((entry) => entry.moves && !moving.has(entry.youtubeId)).map((entry) => entry.youtubeId); for (let attempt = 0; ; attempt += 1) { - const planned = await planChannel({ channel, drafts: shows, onlyIds: new Set(youtubeIds), downloadsDir: configModule.directoryPath }); - const moving = new Set(planned.entries.filter((entry) => entry.moves).map((entry) => entry.youtubeId)); + // Videos that would have to move are left out and the channel planned + // again without them, until no other one would (a duplicate of one can + // win next): otherwise their order numbers would be spent (high-water + // marks only go up) and the videos losing to them recorded as duplicates + // of a video that holds nothing. Each pass leaves out at least one video. + const moving = new Set(); + let planned = await planFor(new Set(youtubeIds)); + for (let found = newMovers(planned, moving); found.length > 0; found = newMovers(planned, moving)) { + for (const youtubeId of found) moving.add(youtubeId); + planned = await planFor(new Set(youtubeIds.filter((youtubeId) => !moving.has(youtubeId)))); + } const plan = { ...planned, entries: planned.entries.filter((entry) => !moving.has(entry.youtubeId)), @@ -184,7 +206,13 @@ async function classifyNew({ channel, youtubeIds }) { const highWaterBefore = await highWaterOf(plan); try { await sequelize.transaction((transaction) => applyPlan({ - channel, drafts: shows, plan, highWaterBefore, transaction, definitions, + channel, + drafts: shows, + plan, + highWaterBefore, + transaction, + definitions, + clearErrorsOf: youtubeIds.filter((youtubeId) => !moving.has(youtubeId)), })); } catch (err) { if (attempt === 0 && (isUniqueConstraintError(err) || isRowChangedError(err))) continue; diff --git a/server/modules/tvShows/titleShowService.js b/server/modules/tvShows/titleShowService.js index 8e90895e2..0eb548533 100644 --- a/server/modules/tvShows/titleShowService.js +++ b/server/modules/tvShows/titleShowService.js @@ -127,7 +127,10 @@ class TitleShowService { async createShow(channel, rawDraft) { const drafts = await this.currentDrafts(channel.channel_id); - return this.saveAndDescribe(channel, [...drafts, rawDraft]); + // Always a new show: an id would make it a second copy of an existing one. + const draft = { ...rawDraft }; + delete draft.id; + return this.saveAndDescribe(channel, [...drafts, draft]); } async updateShow(channel, showId, rawDraft) { diff --git a/server/modules/tvShows/titleShowStore.js b/server/modules/tvShows/titleShowStore.js index 7187a10a6..666e10e5c 100644 --- a/server/modules/tvShows/titleShowStore.js +++ b/server/modules/tvShows/titleShowStore.js @@ -202,6 +202,18 @@ async function saveDefinitions({ channelId, drafts, transaction = null }) { return { showIds, patternIds }; } +/** + * Refuse drafts naming a show that isn't one of the channel's title shows + * (saving them would fail; the preview would let them keep that show's folder). + * @throws {Error} status 404 + */ +async function assertOwnShows({ channelId, drafts }) { + const ids = drafts.filter((draft) => draft.id).map((draft) => draft.id); + if (ids.length === 0) return; + const own = new Set(await titleShowIds(channelId)); + if (ids.some((id) => !own.has(id))) throw storeError('Show not found', 404); +} + /** * Refuse drafts whose folder another show already uses in that library * folder (retired shows included: their folder is kept for a restore). @@ -214,10 +226,12 @@ async function assertFolderNamesFree({ channelId, channelTitle, drafts }) { where: { library_folder: folders }, attributes: ['id', 'name', 'folder_name', 'library_folder', 'channel_id', 'kind', 'retired_at'], }); - const ownIds = new Set(drafts.filter((draft) => draft.id).map((draft) => draft.id)); for (const draft of drafts) { const key = folderNameKey(draft.libraryFolder, draft.folderName); - const holder = rows.find((row) => !ownIds.has(row.id) && folderNameKey(row.library_folder, row.folder_name) === key); + // Only the show itself may hold its folder: a folder another of the + // channel's shows gives up in the same change is refused too, since the + // unique location index could fail the save (or its undo) partway through. + const holder = rows.find((row) => row.id !== draft.id && folderNameKey(row.library_folder, row.folder_name) === key); if (!holder) continue; const restorable = holder.kind === KIND_TITLE_SHOW && holder.retired_at && holder.channel_id === channelId; const details = { suggestion: `${draft.name} (${channelTitle})` }; @@ -302,6 +316,7 @@ module.exports = { listActiveByChannel, toDraft, saveDefinitions, + assertOwnShows, assertFolderNamesFree, highWaterMarks, raiseHighWater, diff --git a/server/routes/tvReorganize.js b/server/routes/tvReorganize.js index 9ce8840ae..586d8ad58 100644 --- a/server/routes/tvReorganize.js +++ b/server/routes/tvReorganize.js @@ -52,15 +52,19 @@ function createTvReorganizeRoutes({ verifyToken, reorganize, watchStatusHolds, w * { type: channelLayout, channelId, layout (videos|tv), folder? } (the Channel Settings toggle), * { type: channel, channelId, subFolder } (a channel's sub_folder value), * { type: folderLayout, folder ("" = main folder), layout }, - * { type: defaultSubfolder, value ("" = main folder)}. + * { type: defaultSubfolder, value ("" = main folder)}, + * { type: titleShows, channelId, shows, overrides? } (a channel's title shows after the change, as a + * reorganizeRequired refusal from a title show save names them). * required: [type] * properties: - * type: { type: string, enum: [channelLayout, channel, folderLayout, defaultSubfolder] } + * type: { type: string, enum: [channelLayout, channel, folderLayout, defaultSubfolder, titleShows] } * channelId: { type: string } * layout: { type: string, enum: [videos, tv] } * folder: { type: string } * subFolder: { type: string, nullable: true } * value: { type: string } + * shows: { type: array, items: { type: object }, description: 'titleShows - the channel''s whole set of draft shows, in order; existing shows carry their id' } + * overrides: { type: array, items: { type: object }, description: 'titleShows - episode assignments, "Not an episode" and back-to-automatic entries' } */ /** From a28eca7408fca6c3de2503c24fbd163b43c809b4 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Tue, 6 Oct 2026 13:16:56 -0700 Subject: [PATCH 09/12] feat(library): add library folder APIs Add the endpoints behind the Library folders page: folder usage and file counts on GET /api/library-folders, a folder detail endpoint, folder creation with a layout and its directory, a default folder endpoint, and Plex refresh mappings that can be replaced, set to the default library or removed without a Plex connection. The library check reports where each server sees the downloads folder and whether a folder's Plex refresh is the user's choice. Download-time Plex refreshes match folders ignoring case, a mapping no longer blocks deleting a folder, and the flat-structure count leaves out channels in TV folders. Refs: #175 --- .../server.additional-routes.test.js | 2 + server/__tests__/server.apikeys.test.js | 2 + server/__tests__/server.auth-sessions.test.js | 2 + server/__tests__/server.core.test.js | 2 + server/__tests__/server.plex-routes.test.js | 2 + server/__tests__/server.routes.test.js | 2 + .../__tests__/channelSettingsModule.test.js | 18 +- server/modules/__tests__/plexModule.test.js | 22 +++ .../__tests__/subfolderDeletion.test.js | 36 ++++ .../modules/__tests__/subfolderModule.test.js | 98 +++++++++- server/modules/channelSettingsModule.js | 35 ++-- .../__tests__/libraryCheck.test.js | 59 +++++- .../__tests__/libraryMatcher.test.js | 17 +- .../__tests__/plexMappingEntries.test.js | 53 +++++ .../__tests__/plexRefreshMappings.test.js | 80 ++++++++ server/modules/mediaServers/libraryCheck.js | 36 ++-- server/modules/mediaServers/libraryMatcher.js | 40 +++- .../mediaServers/plexMappingEntries.js | 74 +++++++ .../mediaServers/plexRefreshMappings.js | 77 ++++++++ server/modules/plexModule.js | 14 +- server/modules/subfolderDeletion.js | 48 +++++ server/modules/subfolderModule.js | 105 +++++----- .../tvShows/__tests__/folderDetail.test.js | 158 +++++++++++++++ .../tvShows/__tests__/folderUsage.test.js | 136 +++++++++++++ .../tvShows/__tests__/layoutGuards.test.js | 22 ++- .../tvShows/__tests__/libraryFolders.test.js | 113 ++++++++++- server/modules/tvShows/folderDetail.js | 182 ++++++++++++++++++ server/modules/tvShows/folderUsage.js | 128 ++++++++++++ server/modules/tvShows/layoutGuards.js | 25 +++ server/modules/tvShows/libraryFolders.js | 92 ++++++++- server/routes/__tests__/subfolders.test.js | 57 +++++- server/routes/__tests__/tvShows.test.js | 119 ++++++++++++ server/routes/channels.js | 2 +- server/routes/index.js | 6 +- server/routes/plex.js | 10 +- server/routes/subfolders.js | 58 ++++-- server/routes/tvShows.js | 174 ++++++++++++++++- 37 files changed, 1952 insertions(+), 154 deletions(-) create mode 100644 server/modules/__tests__/subfolderDeletion.test.js create mode 100644 server/modules/mediaServers/__tests__/plexMappingEntries.test.js create mode 100644 server/modules/mediaServers/__tests__/plexRefreshMappings.test.js create mode 100644 server/modules/mediaServers/plexMappingEntries.js create mode 100644 server/modules/mediaServers/plexRefreshMappings.js create mode 100644 server/modules/subfolderDeletion.js create mode 100644 server/modules/tvShows/__tests__/folderDetail.test.js create mode 100644 server/modules/tvShows/__tests__/folderUsage.test.js create mode 100644 server/modules/tvShows/folderDetail.js create mode 100644 server/modules/tvShows/folderUsage.js diff --git a/server/__tests__/server.additional-routes.test.js b/server/__tests__/server.additional-routes.test.js index f082115e8..a1f29ebcf 100644 --- a/server/__tests__/server.additional-routes.test.js +++ b/server/__tests__/server.additional-routes.test.js @@ -290,6 +290,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/mediaServers/plexRefreshMappings', () => ({ setMapping: jest.fn(), removeMapping: jest.fn() })); + jest.doMock('../modules/tvShows/folderDetail', () => ({ getFolderDetail: jest.fn() })); jest.doMock('../modules/tvShows/titleShowService', () => ({})); jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ diff --git a/server/__tests__/server.apikeys.test.js b/server/__tests__/server.apikeys.test.js index 03aba89e5..228eece2a 100644 --- a/server/__tests__/server.apikeys.test.js +++ b/server/__tests__/server.apikeys.test.js @@ -300,6 +300,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/mediaServers/plexRefreshMappings', () => ({ setMapping: jest.fn(), removeMapping: jest.fn() })); + jest.doMock('../modules/tvShows/folderDetail', () => ({ getFolderDetail: jest.fn() })); jest.doMock('../modules/tvShows/titleShowService', () => ({})); jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ diff --git a/server/__tests__/server.auth-sessions.test.js b/server/__tests__/server.auth-sessions.test.js index df20d5e3a..2beda38b0 100644 --- a/server/__tests__/server.auth-sessions.test.js +++ b/server/__tests__/server.auth-sessions.test.js @@ -258,6 +258,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/mediaServers/plexRefreshMappings', () => ({ setMapping: jest.fn(), removeMapping: jest.fn() })); + jest.doMock('../modules/tvShows/folderDetail', () => ({ getFolderDetail: jest.fn() })); jest.doMock('../modules/tvShows/titleShowService', () => ({})); jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ diff --git a/server/__tests__/server.core.test.js b/server/__tests__/server.core.test.js index 3b32b64ef..6d2c90d9a 100644 --- a/server/__tests__/server.core.test.js +++ b/server/__tests__/server.core.test.js @@ -280,6 +280,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/mediaServers/plexRefreshMappings', () => ({ setMapping: jest.fn(), removeMapping: jest.fn() })); + jest.doMock('../modules/tvShows/folderDetail', () => ({ getFolderDetail: jest.fn() })); jest.doMock('../modules/tvShows/titleShowService', () => ({})); jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ diff --git a/server/__tests__/server.plex-routes.test.js b/server/__tests__/server.plex-routes.test.js index d889556e9..85bb89200 100644 --- a/server/__tests__/server.plex-routes.test.js +++ b/server/__tests__/server.plex-routes.test.js @@ -176,6 +176,8 @@ const setupServer = async ({ authEnabled = 'false', passwordHash = null } = {}) })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/mediaServers/plexRefreshMappings', () => ({ setMapping: jest.fn(), removeMapping: jest.fn() })); + jest.doMock('../modules/tvShows/folderDetail', () => ({ getFolderDetail: jest.fn() })); jest.doMock('../modules/tvShows/titleShowService', () => ({})); jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 218bd4e70..045d22ded 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -442,6 +442,8 @@ const createServerModule = ({ })); jest.doMock('../modules/mediaServers/watchStatusPushBack', () => ({ pushPendingHolds: jest.fn(), scheduleFollowUps: jest.fn() })); jest.doMock('../modules/mediaServers/libraryCheck', () => ({ check: jest.fn(), applyPlexMapping: jest.fn() })); + jest.doMock('../modules/mediaServers/plexRefreshMappings', () => ({ setMapping: jest.fn(), removeMapping: jest.fn() })); + jest.doMock('../modules/tvShows/folderDetail', () => ({ getFolderDetail: jest.fn() })); jest.doMock('../modules/tvShows/titleShowService', () => ({})); jest.doMock('../modules/tvShows/archiveSuppressor', () => ({ initialize: jest.fn(), flush: jest.fn().mockResolvedValue() })); jest.doMock('../modules/tvShows/channelLayout', () => ({ diff --git a/server/modules/__tests__/channelSettingsModule.test.js b/server/modules/__tests__/channelSettingsModule.test.js index be1dd3503..2bb689d2f 100644 --- a/server/modules/__tests__/channelSettingsModule.test.js +++ b/server/modules/__tests__/channelSettingsModule.test.js @@ -62,6 +62,8 @@ jest.mock('../tvShows/channelLayout', () => ({ checkChannelSettingsChange: jest.fn(), applyChannelFolderChange: jest.fn(), })); +jest.mock('../tvShows/libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); +jest.mock('../tvShows/channelFolders', () => ({ effectiveLibraryFolder: jest.fn((value) => value || '') })); describe('ChannelSettingsModule', () => { let channelSettingsModule; @@ -105,6 +107,8 @@ describe('ChannelSettingsModule', () => { plexModule = require('../plexModule'); m3uGenerator = require('../m3uGenerator'); + require('../tvShows/libraryLayouts').getLayoutResolver.mockResolvedValue(() => 'videos'); + // Reset mock implementations Channel.findOne.mockResolvedValue(null); Channel.findAll.mockResolvedValue([]); @@ -527,7 +531,7 @@ describe('ChannelSettingsModule', () => { const result = await channelSettingsModule.getChannelsUsingGlobalFileStructure(); expect(Channel.findAll).toHaveBeenCalledWith({ - attributes: ['uploader'], + attributes: ['uploader', 'sub_folder'], where: { enabled: true, skip_video_folder: null @@ -549,6 +553,18 @@ describe('ChannelSettingsModule', () => { expect(result.channelNames).toHaveLength(10); expect(result.channelNames).toEqual(channels.slice(0, 10).map(ch => ch.uploader)); }); + + test('leaves out channels whose folder uses the TV shows layout', async () => { + require('../tvShows/libraryLayouts').getLayoutResolver.mockResolvedValue((folder) => (folder === 'TV' ? 'tv' : 'videos')); + Channel.findAll.mockResolvedValue([ + { uploader: 'Movies A', sub_folder: 'Kids' }, + { uploader: 'Show B', sub_folder: 'TV' }, + ]); + + const result = await channelSettingsModule.getChannelsUsingGlobalFileStructure(); + + expect(result).toEqual({ count: 1, channelNames: ['Movies A'] }); + }); }); describe('previewTitleFilter', () => { diff --git a/server/modules/__tests__/plexModule.test.js b/server/modules/__tests__/plexModule.test.js index 3c289aa7a..7d1b133db 100644 --- a/server/modules/__tests__/plexModule.test.js +++ b/server/modules/__tests__/plexModule.test.js @@ -319,6 +319,18 @@ describe('plexModule', () => { expect(plexModule.getLibraryIdForSubfolder('kids')).toBe('2'); expect(plexModule.getLibraryIdForSubfolder('unknown')).toBe('1'); }); + + test('matches a mapping ignoring case and surrounding spaces', () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'Kids', libraryId: '2' }]; + + expect(plexModule.getLibraryIdForSubfolder(' kids ')).toBe('2'); + }); + + test('falls back to the default library for an explicit default entry', () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'Kids', libraryId: null }]; + + expect(plexModule.getLibraryIdForSubfolder('Kids')).toBe('1'); + }); }); describe('refreshLibrariesForSubfolders', () => { @@ -656,6 +668,16 @@ describe('plexModule', () => { ); expect(result).toHaveLength(1); }); + + test('returns each library type', async () => { + axios.get.mockResolvedValue({ + data: { MediaContainer: { Directory: [{ key: '41', title: 'TV', type: 'show', Location: [] }] } }, + }); + + const result = await plexModule.getLibrariesWithParams('192.168.1.10', 'token', '32400'); + + expect(result).toEqual([{ id: '41', title: 'TV', type: 'show', locations: [] }]); + }); }); describe('getServerIdentityWithParams', () => { diff --git a/server/modules/__tests__/subfolderDeletion.test.js b/server/modules/__tests__/subfolderDeletion.test.js new file mode 100644 index 000000000..f185d6b50 --- /dev/null +++ b/server/modules/__tests__/subfolderDeletion.test.js @@ -0,0 +1,36 @@ +const { deletionBlockers, deletionBlockReason } = require('../subfolderDeletion'); + +const unused = { channels: 0, disabledChannels: 0, playlists: 0, shows: 0, isDefault: false, hasFiles: false }; + +describe('subfolderDeletion', () => { + test('lists every blocker in guard order', () => { + expect(deletionBlockers({ + channels: 2, disabledChannels: 1, playlists: 3, shows: 1, isDefault: true, hasFiles: true, + })).toEqual([ + { code: 'channels', count: 2 }, + { code: 'disabledChannels', count: 1 }, + { code: 'playlists', count: 3 }, + { code: 'shows', count: 1 }, + { code: 'default' }, + { code: 'files' }, + ]); + }); + + test('an unused empty folder has no blockers', () => { + expect(deletionBlockers(unused)).toEqual([]); + expect(deletionBlockReason(unused)).toBeNull(); + }); + + test('keeps today\'s message, counting enabled and disabled channels together', () => { + expect(deletionBlockReason({ ...unused, channels: 1, disabledChannels: 2 })) + .toBe('Subfolder is in use by 3 channel(s)'); + expect(deletionBlockReason({ ...unused, disabledChannels: 1 })).toBe('Subfolder is in use by 1 channel(s)'); + }); + + test('reports the first blocker only', () => { + expect(deletionBlockReason({ ...unused, isDefault: true, hasFiles: true })) + .toBe('Subfolder is the global default and cannot be deleted'); + expect(deletionBlockReason({ ...unused, hasFiles: true })) + .toBe('Subfolder still contains downloaded files and cannot be deleted'); + }); +}); diff --git a/server/modules/__tests__/subfolderModule.test.js b/server/modules/__tests__/subfolderModule.test.js index 31ddd44f3..9000b19cb 100644 --- a/server/modules/__tests__/subfolderModule.test.js +++ b/server/modules/__tests__/subfolderModule.test.js @@ -5,6 +5,7 @@ jest.mock('../../models/subfolder', () => ({ findOrCreate: jest.fn(), destroy: jest.fn(), count: jest.fn(), + findOne: jest.fn(), })); jest.mock('../../models/channel', () => ({ count: jest.fn(), findAll: jest.fn() })); jest.mock('../../models/playlist', () => ({ count: jest.fn(), findAll: jest.fn() })); @@ -13,6 +14,7 @@ jest.mock('../../models/videoclassification', () => ({ findAll: jest.fn() })); jest.mock('../configModule', () => ({ getDefaultSubfolder: jest.fn(), getConfig: jest.fn(), + updateConfig: jest.fn(), directoryPath: '/data', })); jest.mock('../filesystem', () => ({ @@ -81,7 +83,9 @@ describe('getUsage', () => { { name: 'Spare', displayName: '__Spare', - usage: { channels: 0, playlists: 0, shows: 0, isDefault: false, plexMapped: false, hasFiles: false }, + usage: { + channels: 0, disabledChannels: 0, playlists: 0, shows: 0, isDefault: false, plexMapped: false, hasFiles: false, + }, deletable: true, }, ]); @@ -132,12 +136,22 @@ describe('getUsage', () => { expect(item.usage.hasFiles).toBe(true); expect(item.deletable).toBe(false); }); + + test('a Plex mapping alone leaves an unused, empty folder deletable', async () => { + Subfolder.findAll.mockResolvedValue([{ name: 'Movies' }]); + configModule.getConfig.mockReturnValue({ plexSubfolderLibraryMappings: [{ subfolder: 'Movies', libraryId: '5' }] }); + + const [item] = await subfolderModule.getUsage(); + + expect(item.usage.plexMapped).toBe(true); + expect(item.deletable).toBe(true); + }); }); describe('register', () => { test('upserts a real name', async () => { Subfolder.findOrCreate.mockResolvedValue([{ name: 'Sports' }, true]); - await subfolderModule.register(' Sports '); + await expect(subfolderModule.register(' Sports ')).resolves.toEqual({ name: 'Sports', layout: 'videos', created: true }); expect(Subfolder.findOrCreate).toHaveBeenCalledWith({ where: { name: 'Sports' }, defaults: { name: 'Sports' } }); }); @@ -152,7 +166,26 @@ describe('register', () => { test('tolerates a unique-constraint race', async () => { const err = new Error('dup'); err.name = 'SequelizeUniqueConstraintError'; Subfolder.findOrCreate.mockRejectedValueOnce(err); - await expect(subfolderModule.register('Dup')).resolves.toBeUndefined(); + await expect(subfolderModule.register('Dup')).resolves.toBeNull(); + }); + + test('stores a layout and reports a new row', async () => { + Subfolder.findOrCreate.mockResolvedValue([{ name: 'TV', layout: 'tv' }, true]); + + await expect(subfolderModule.register('TV', { layout: 'tv' })).resolves.toEqual({ name: 'TV', layout: 'tv', created: true }); + expect(Subfolder.findOrCreate).toHaveBeenCalledWith({ where: { name: 'TV' }, defaults: { name: 'TV', layout: 'tv' } }); + }); + + test('swallows a database error by default, for download-time registration', async () => { + Subfolder.findOrCreate.mockRejectedValue(new Error('db down')); + + await expect(subfolderModule.register('TV')).resolves.toBeNull(); + }); + + test('throws a database error when asked to', async () => { + Subfolder.findOrCreate.mockRejectedValue(new Error('db down')); + + await expect(subfolderModule.register('TV', { throwOnError: true })).rejects.toThrow('db down'); }); }); @@ -179,7 +212,7 @@ describe('delete', () => { }); test('409 when a channel uses it', async () => { - Channel.count.mockResolvedValue(2); + Channel.count.mockImplementation(async ({ where }) => (where.enabled ? 2 : 0)); await expect(subfolderModule.delete('Used')).rejects.toMatchObject({ status: 409 }); expect(Subfolder.destroy).not.toHaveBeenCalled(); }); @@ -195,9 +228,9 @@ describe('delete', () => { await expect(subfolderModule.delete('used')).rejects.toMatchObject({ status: 409 }); }); - test('409 when a plex mapping references it', async () => { + test('deletes despite a plex mapping that references it', async () => { configModule.getConfig.mockReturnValue({ plexSubfolderLibraryMappings: [{ subfolder: 'Used', libraryId: '3' }] }); - await expect(subfolderModule.delete('used')).rejects.toMatchObject({ status: 409 }); + await expect(subfolderModule.delete('used')).resolves.toBeUndefined(); }); test('409 when it holds a TV show with numbered episodes', async () => { @@ -248,3 +281,56 @@ describe('delete', () => { await expect(subfolderModule.delete('Full')).rejects.toMatchObject({ status: 409 }); }); }); + +describe('delete with Plex mappings', () => { + beforeEach(() => { + Channel.count.mockResolvedValue(0); + Playlist.count.mockResolvedValue(0); + filesystem.directoryHasFiles.mockResolvedValue(false); + }); + + test('a mapping no longer blocks delete, and delete removes it', async () => { + Subfolder.count.mockResolvedValue(1); + configModule.getConfig.mockReturnValue({ + plexSubfolderLibraryMappings: [{ subfolder: 'kids', libraryId: '2' }, { subfolder: 'TV', libraryId: '41' }], + }); + + await subfolderModule.delete('Kids'); + + expect(Subfolder.destroy).toHaveBeenCalledWith({ where: { name: 'Kids' } }); + expect(configModule.updateConfig).toHaveBeenCalledWith({ + plexSubfolderLibraryMappings: [{ subfolder: 'TV', libraryId: '41' }], + }); + }); + + test('deletes a folder known only from a mapping (no registry row) and answers success', async () => { + Subfolder.count.mockResolvedValue(0); + configModule.getConfig.mockReturnValue({ plexSubfolderLibraryMappings: [{ subfolder: 'Old', libraryId: '9' }] }); + + await expect(subfolderModule.delete('Old')).resolves.toBeUndefined(); + expect(Subfolder.destroy).not.toHaveBeenCalled(); + expect(configModule.updateConfig).toHaveBeenCalledWith({ plexSubfolderLibraryMappings: [] }); + }); + + test('the config-only default folder still blocks with 409', async () => { + Subfolder.count.mockResolvedValue(0); + configModule.getDefaultSubfolder.mockReturnValue('Kids'); + + await expect(subfolderModule.delete('Kids')).rejects.toMatchObject({ status: 409 }); + }); + + test('a name known nowhere is 404', async () => { + Subfolder.count.mockResolvedValue(0); + + await expect(subfolderModule.delete('Nope')).rejects.toMatchObject({ status: 404 }); + }); + + test('counts disabled channels as blockers with the combined message', async () => { + Subfolder.count.mockResolvedValue(1); + Channel.count.mockImplementation(async ({ where }) => (where.enabled ? 0 : 2)); + + await expect(subfolderModule.delete('Kids')).rejects.toMatchObject({ + status: 409, message: 'Subfolder is in use by 2 channel(s)', + }); + }); +}); diff --git a/server/modules/channelSettingsModule.js b/server/modules/channelSettingsModule.js index 259e3d5c2..981e745b4 100644 --- a/server/modules/channelSettingsModule.js +++ b/server/modules/channelSettingsModule.js @@ -14,6 +14,9 @@ const subfolderModule = require('./subfolderModule'); const m3uGenerator = require('./m3uGenerator'); const titleRegex = require('./titleFilterRegex'); const channelLayout = require('./tvShows/channelLayout'); +const { getLayoutResolver } = require('./tvShows/libraryLayouts'); +const { effectiveLibraryFolder } = require('./tvShows/channelFolders'); +const { LAYOUT_TV } = require('./tvShows/constants'); const { GLOBAL_DEFAULT_SENTINEL, buildChannelPath, @@ -472,23 +475,27 @@ class ChannelSettingsModule { } /** - * Get channels that follow the global file-structure setting - * (skip_video_folder is NULL = inherit). Only enabled channels are counted: - * disabled channels include hidden auto-created playlist source channels. - * @returns {Promise<Object>} - { count, channelNames } + * Enabled channels that follow the global flat-structure default (no + * override) and download to a Videos folder: TV folder episodes always go + * flat into Season folders, so the setting doesn't apply to them. + * Disabled channels include hidden auto-created playlist source channels. + * @returns {Promise<{count: number, channelNames: string[]}>} */ async getChannelsUsingGlobalFileStructure() { - const channels = await Channel.findAll({ - attributes: ['uploader'], - where: { - enabled: true, - skip_video_folder: null - } - }); - + const [channels, layoutOf] = await Promise.all([ + Channel.findAll({ + attributes: ['uploader', 'sub_folder'], + where: { + enabled: true, + skip_video_folder: null + } + }), + getLayoutResolver(), + ]); + const affected = channels.filter((channel) => layoutOf(effectiveLibraryFolder(channel.sub_folder)) !== LAYOUT_TV); return { - count: channels.length, - channelNames: channels.map(ch => ch.uploader).slice(0, 10) // First 10 for display + count: affected.length, + channelNames: affected.map((channel) => channel.uploader).slice(0, 10) // First 10 for display }; } diff --git a/server/modules/mediaServers/__tests__/libraryCheck.test.js b/server/modules/mediaServers/__tests__/libraryCheck.test.js index 0e824f2b6..96b12c8f3 100644 --- a/server/modules/mediaServers/__tests__/libraryCheck.test.js +++ b/server/modules/mediaServers/__tests__/libraryCheck.test.js @@ -59,14 +59,14 @@ describe('libraryCheck', () => { const report = reportOf(result, 'TV', 'plex'); expect(report.status).toBe('ok'); - expect(report.plexMapping).toEqual({ mappedLibraryId: '41', suggestedLibraryId: '41' }); + expect(report.plexMapping).toEqual({ mappedLibraryId: '41', suggestedLibraryId: '41', choice: 'library' }); }); test('suggests the Plex refresh mapping when the folder has none', async () => { const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])]); const report = reportOf(result, 'TV', 'plex'); - expect(report.plexMapping).toEqual({ mappedLibraryId: null, suggestedLibraryId: '41' }); + expect(report.plexMapping).toEqual({ mappedLibraryId: null, suggestedLibraryId: '41', choice: 'none' }); expect(codes(report)).toEqual(['plexMappingMissing']); }); @@ -170,6 +170,57 @@ describe('libraryCheck', () => { expect(reportOf(result, 'Kids', 'plex').status).toBe('ok'); }); + + test('reports no missing mapping when the user chose the default library', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: null }]; + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])]); + + const report = reportOf(result, 'TV', 'plex'); + expect(report.plexMapping).toEqual({ mappedLibraryId: null, suggestedLibraryId: '41', choice: 'default' }); + expect(codes(report)).toEqual([]); + }); + + test('a mapping mismatch no longer points at Settings > Plex', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'tv', libraryId: '37' }]; + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])]); + + const issue = reportOf(result, 'TV', 'plex').issues[0]; + expect(issue).toMatchObject({ code: 'plexMappingMismatch' }); + expect(issue.message).toBe('New episodes in __TV refresh library 37, not YouTube TV.'); + }); + + test('applyPlexMapping refuses a folder that already has a setting', async () => { + config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: null }]; + libraryFolders.listLibraryFolders.mockResolvedValue([folder('', 'videos'), folder('TV', 'tv')]); + serverRegistry.getEnabledAdapters.mockReturnValue([server('plex', [plexTv()])]); + + await expect(libraryCheck.applyPlexMapping('TV', '41')).rejects.toMatchObject({ + status: 409, message: '__TV already refreshes another Plex library.', + }); + }); + }); + + describe('downloadsPath', () => { + test('gives the server\'s downloads folder when one candidate is known', async () => { + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', [plexTv()])]); + expect(result.servers[0].downloadsPath).toBe('Q:\\Y'); + }); + + test('is null when the main folder can\'t be found', async () => { + const result = await run([folder('', 'videos')], [server('jellyfin', [{ id: '1', name: 'Films', type: 'videos', locations: ['/films'] }])]); + expect(result.servers[0].downloadsPath).toBeNull(); + }); + + test('is null when two candidates disagree', async () => { + const libraries = [plexTv(), plexTv({ id: '42', name: 'Other', locations: ['R:\\Elsewhere\\__TV'] })]; + const result = await run([folder('', 'videos'), folder('TV', 'tv')], [server('plex', libraries)]); + expect(result.servers[0].downloadsPath).toBeNull(); + }); + + test('is null for an unreachable server', async () => { + const result = await run([folder('', 'videos')], [{ serverType: 'plex', unreachable: true }]); + expect(result.servers[0].downloadsPath).toBeNull(); + }); }); describe('Videos folders', () => { @@ -254,7 +305,7 @@ describe('libraryCheck', () => { const entry = result.folders[0]; expect(entry.layout).toBe('tv'); - expect(reportOf(result, 'TV', 'plex').plexMapping).toEqual({ mappedLibraryId: null, suggestedLibraryId: '41' }); + expect(reportOf(result, 'TV', 'plex').plexMapping).toEqual({ mappedLibraryId: null, suggestedLibraryId: '41', choice: 'none' }); expect(codes(reportOf(result, 'TV', 'plex'))).toEqual(['plexMappingMissing']); }); @@ -276,6 +327,7 @@ describe('libraryCheck', () => { expect(await libraryCheck.applyPlexMapping('TV', '41')).toEqual({ mappedLibraryId: '41', + choice: 'library', plexSubfolderLibraryMappings: [{ subfolder: 'Kids', libraryId: '12' }, { subfolder: 'TV', libraryId: '41' }], }); expect(configModule.updateConfig).toHaveBeenCalledWith(expect.objectContaining({ @@ -288,6 +340,7 @@ describe('libraryCheck', () => { expect(await libraryCheck.applyPlexMapping('TV', '41')).toEqual({ mappedLibraryId: '41', + choice: 'library', plexSubfolderLibraryMappings: [{ subfolder: 'TV', libraryId: '41' }], }); expect(configModule.updateConfig).not.toHaveBeenCalled(); diff --git a/server/modules/mediaServers/__tests__/libraryMatcher.test.js b/server/modules/mediaServers/__tests__/libraryMatcher.test.js index 8230cde9e..ae4ba7c41 100644 --- a/server/modules/mediaServers/__tests__/libraryMatcher.test.js +++ b/server/modules/mediaServers/__tests__/libraryMatcher.test.js @@ -132,6 +132,21 @@ describe('libraryMatcher', () => { expect(relationsFor(result, 'TV Shows').filter((relation) => relation.relation === RELATION_EXACT)).toEqual([]); }); + + test('folderPaths keeps a POSIX leading slash', () => { + const result = matchLibraries({ folders: FOLDERS, libraries: [library('41', '/data/yt/__TV Shows')], containerRoot: ROOT }); + + expect(result.folderPaths('')).toEqual([{ path: '/data/yt', source: 'name' }]); + expect(result.folderPaths('Kids')).toEqual([{ path: '/data/yt/__Kids', source: 'derived' }]); + }); + + test('folderPaths keeps Windows separators and UNC roots', () => { + const windows = matchLibraries({ folders: FOLDERS, libraries: [library('41', 'Q:\\Youtube_test\\__TV Shows')], containerRoot: ROOT }); + const unc = matchLibraries({ folders: FOLDERS, libraries: [library('41', '\\\\nas\\media\\yt\\__Kids')], containerRoot: ROOT }); + + expect(windows.folderPaths('')).toEqual([{ path: 'Q:\\Youtube_test', source: 'name' }]); + expect(unc.folderPaths('')).toEqual([{ path: '\\\\nas\\media\\yt', source: 'name' }]); + }); }); describe('mappingFromSample', () => { @@ -144,7 +159,7 @@ describe('libraryMatcher', () => { containerPath: `${ROOT}/Chan/Chan - T - abcDEF12345/Chan - T [abcDEF12345].mp4`, }, rootSegments, subfolderKeys); - expect(mapping).toEqual({ folder: '', segments: ['srv', 'data'] }); + expect(mapping).toEqual({ folder: '', segments: ['srv', 'data'], style: { separator: '/', root: '/' } }); }); test('needs at least the file name and its folder in common', () => { diff --git a/server/modules/mediaServers/__tests__/plexMappingEntries.test.js b/server/modules/mediaServers/__tests__/plexMappingEntries.test.js new file mode 100644 index 000000000..7dee7f95c --- /dev/null +++ b/server/modules/mediaServers/__tests__/plexMappingEntries.test.js @@ -0,0 +1,53 @@ +const { + readMappings, findEntry, mappingOf, withEntry, withoutEntry, +} = require('../plexMappingEntries'); + +describe('plexMappingEntries', () => { + const mappings = [ + { subfolder: 'Kids', libraryId: '38' }, + { subfolder: null, libraryId: '37' }, + { subfolder: 'Archive', libraryId: null }, + ]; + + test('readMappings skips entries that are not objects', () => { + expect(readMappings({ plexSubfolderLibraryMappings: [null, 'x', 3, { subfolder: 'A', libraryId: '1' }] })) + .toEqual([{ subfolder: 'A', libraryId: '1' }]); + expect(readMappings({})).toEqual([]); + }); + + test('finds a folder ignoring case, and the main folder by a null subfolder', () => { + expect(findEntry(mappings, 'KIDS')).toEqual({ subfolder: 'Kids', libraryId: '38' }); + expect(findEntry(mappings, '')).toEqual({ subfolder: null, libraryId: '37' }); + expect(findEntry(mappings, 'Music')).toBeNull(); + }); + + test('reports a library choice, an explicit default choice, or none', () => { + expect(mappingOf(mappings, 'kids')).toEqual({ choice: 'library', libraryId: '38' }); + expect(mappingOf(mappings, 'Archive')).toEqual({ choice: 'default', libraryId: null }); + expect(mappingOf(mappings, 'Music')).toEqual({ choice: 'none', libraryId: null }); + }); + + test('treats an empty library id as the default choice', () => { + expect(mappingOf([{ subfolder: 'A', libraryId: '' }], 'A')).toEqual({ choice: 'default', libraryId: null }); + }); + + test('withEntry replaces the folder entry in place and keeps the others', () => { + expect(withEntry(mappings, 'kids', '41')).toEqual([ + { subfolder: 'kids', libraryId: '41' }, + { subfolder: null, libraryId: '37' }, + { subfolder: 'Archive', libraryId: null }, + ]); + }); + + test('withEntry appends a new entry, storing the main folder as null', () => { + expect(withEntry([], '', null)).toEqual([{ subfolder: null, libraryId: null }]); + expect(withEntry([], 'TV', '41')).toEqual([{ subfolder: 'TV', libraryId: '41' }]); + }); + + test('withoutEntry removes every entry of the folder', () => { + expect(withoutEntry([...mappings, { subfolder: 'kids', libraryId: '2' }], 'Kids')).toEqual([ + { subfolder: null, libraryId: '37' }, + { subfolder: 'Archive', libraryId: null }, + ]); + }); +}); diff --git a/server/modules/mediaServers/__tests__/plexRefreshMappings.test.js b/server/modules/mediaServers/__tests__/plexRefreshMappings.test.js new file mode 100644 index 000000000..5b06208fe --- /dev/null +++ b/server/modules/mediaServers/__tests__/plexRefreshMappings.test.js @@ -0,0 +1,80 @@ +jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +describe('plexRefreshMappings', () => { + let plexRefreshMappings; + let plexModule; + let subfolderModule; + let config; + + beforeEach(() => { + jest.resetModules(); + config = { + plexApiKey: 'token', plexIP: '192.168.1.174', plexPort: '32400', + plexSubfolderLibraryMappings: [{ subfolder: 'Kids', libraryId: '38' }], + }; + jest.doMock('../../configModule', () => ({ getConfig: jest.fn(() => config), updateConfig: jest.fn((next) => { config = next; }) })); + jest.doMock('../../plexModule', () => ({ + getBaseUrl: jest.fn(() => 'http://192.168.1.174:32400'), + getLibraries: jest.fn().mockResolvedValue([{ id: '38', title: 'Kids' }, { id: '41', title: 'TV' }]), + })); + jest.doMock('../../subfolderModule', () => ({ + getAll: jest.fn().mockResolvedValue(['__Kids', '__TV']), + register: jest.fn().mockResolvedValue(null), + })); + plexRefreshMappings = require('../plexRefreshMappings'); + plexModule = require('../../plexModule'); + subfolderModule = require('../../subfolderModule'); + }); + + test('sets a library for a folder, in its registry spelling', async () => { + const result = await plexRefreshMappings.setMapping('tv', '41'); + + expect(result).toEqual({ + mappedLibraryId: '41', choice: 'library', + plexSubfolderLibraryMappings: [{ subfolder: 'Kids', libraryId: '38' }, { subfolder: 'TV', libraryId: '41' }], + }); + expect(subfolderModule.register).toHaveBeenCalledWith('TV'); + }); + + test('overwrites an existing entry', async () => { + const result = await plexRefreshMappings.setMapping('Kids', '41'); + expect(result.plexSubfolderLibraryMappings).toEqual([{ subfolder: 'Kids', libraryId: '41' }]); + }); + + test('stores the explicit default choice without asking Plex', async () => { + const result = await plexRefreshMappings.setMapping('Kids', null); + + expect(result).toMatchObject({ mappedLibraryId: null, choice: 'default' }); + expect(plexModule.getLibraries).not.toHaveBeenCalled(); + }); + + test('maps the main folder as a null subfolder', async () => { + const result = await plexRefreshMappings.setMapping('', '38'); + expect(result.plexSubfolderLibraryMappings).toContainEqual({ subfolder: null, libraryId: '38' }); + expect(subfolderModule.register).not.toHaveBeenCalled(); + }); + + test('400s a library Plex does not list', async () => { + await expect(plexRefreshMappings.setMapping('Kids', '99')).rejects.toMatchObject({ status: 400 }); + }); + + test('409s when Plex lists nothing (unreachable) or is not configured', async () => { + plexModule.getLibraries.mockResolvedValue([]); + await expect(plexRefreshMappings.setMapping('Kids', '41')).rejects.toMatchObject({ status: 409 }); + config.plexApiKey = ''; + await expect(plexRefreshMappings.setMapping('Kids', '41')).rejects.toMatchObject({ status: 409 }); + }); + + test('404s an unknown folder', async () => { + await expect(plexRefreshMappings.setMapping('Nope', null)).rejects.toMatchObject({ status: 404 }); + }); + + test('removes an entry without Plex, and answers the same for a missing one', async () => { + plexModule.getLibraries.mockRejectedValue(new Error('must not be called')); + + await expect(plexRefreshMappings.removeMapping('KIDS')).resolves.toEqual({ + mappedLibraryId: null, choice: 'none', plexSubfolderLibraryMappings: [], + }); + await expect(plexRefreshMappings.removeMapping('Gone')).resolves.toMatchObject({ choice: 'none' }); + }); +}); diff --git a/server/modules/mediaServers/libraryCheck.js b/server/modules/mediaServers/libraryCheck.js index 801bd6334..fb94883de 100644 --- a/server/modules/mediaServers/libraryCheck.js +++ b/server/modules/mediaServers/libraryCheck.js @@ -18,6 +18,7 @@ const serverRegistry = require('./serverRegistry'); const libraryLocator = require('./libraryLocator'); const { LIBRARY_TYPES, describeHttpError } = require('./adapters/baseAdapter'); const { RELATION_EXACT, RELATION_COVERS, RELATION_INSIDE } = require('./libraryMatcher'); +const { readMappings, mappingOf, CHOICE_DEFAULT, CHOICE_NONE } = require('./plexMappingEntries'); const SERVER_NAMES = { plex: 'Plex', jellyfin: 'Jellyfin', emby: 'Emby' }; const TYPE_NAMES = { @@ -59,6 +60,12 @@ function libraryView(library, relation) { }; } +// Where a server sees the downloads folder, when exactly one place is known. +function downloadsPathOf(match) { + const candidates = match.folderPaths(''); + return candidates.length === 1 ? candidates[0].path : null; +} + class LibraryCheck { /** * @param {Object} [options] @@ -90,6 +97,7 @@ class LibraryCheck { name: SERVER_NAMES[server.adapter.serverType] || server.adapter.serverType, reachable: !server.error, error: server.error || null, + downloadsPath: server.error ? null : downloadsPathOf(server.match), })), folders: folders .filter((folder) => !wanted || wanted.has(folderKey(folder.name))) @@ -315,25 +323,21 @@ class LibraryCheck { return issues; } - _mappedLibraryId(folder, config) { - const mappings = Array.isArray(config.plexSubfolderLibraryMappings) ? config.plexSubfolderLibraryMappings : []; - const mapping = mappings.find((entry) => entry && typeof entry === 'object' - && entry.subfolder && folderKey(entry.subfolder) === folderKey(folder.name)); - return mapping?.libraryId ? String(mapping.libraryId) : null; - } - _plexMapping(folder, exact, byId, config) { const tvLibraries = [...new Set(exact .filter((relation) => byId.get(relation.libraryId).type === LIBRARY_TYPES.TV) .map((relation) => relation.libraryId))]; + const { choice, libraryId } = mappingOf(readMappings(config), folder.name); return { - mappedLibraryId: this._mappedLibraryId(folder, config), + mappedLibraryId: libraryId, suggestedLibraryId: tvLibraries.length === 1 ? tvLibraries[0] : null, + choice, }; } - _plexMappingIssue(folder, { mappedLibraryId, suggestedLibraryId }, byId, config) { - if (!suggestedLibraryId || mappedLibraryId === suggestedLibraryId) return null; + // An explicit default choice is the user's: no issue, no automatic mapping. + _plexMappingIssue(folder, { mappedLibraryId, suggestedLibraryId, choice }, byId, config) { + if (!suggestedLibraryId || choice === CHOICE_DEFAULT || mappedLibraryId === suggestedLibraryId) return null; const suggested = byId.get(suggestedLibraryId); if (mappedLibraryId) { const mapped = byId.get(mappedLibraryId); @@ -341,7 +345,7 @@ class LibraryCheck { code: 'plexMappingMismatch', libraryId: suggestedLibraryId, message: `New episodes in ${folderLabel(folder.name)} refresh ${mapped ? mapped.name : `library ${mappedLibraryId}`}, ` - + `not ${suggested.name}. Change the subfolder mapping in Settings > Plex.`, + + `not ${suggested.name}.`, }; } const fallback = config.plexYoutubeLibraryId ? byId.get(String(config.plexYoutubeLibraryId)) : null; @@ -358,7 +362,7 @@ class LibraryCheck { * episodes refresh that library. Never replaces an existing mapping. * @param {string} folder - Subfolder name without __ * @param {string} libraryId - * @returns {Promise<{mappedLibraryId: string, plexSubfolderLibraryMappings: Array<Object>}>} + * @returns {Promise<{mappedLibraryId: string, choice: string, plexSubfolderLibraryMappings: Array<Object>}>} * with the saved mappings, so the client can take the change into its * copy of the config without reloading it */ @@ -369,18 +373,18 @@ class LibraryCheck { if (!entry || !entry.name) throw this._error('Choose a TV subfolder to map.', 400); if (!plex || plex.status === STATUS.UNREACHABLE) throw this._error('Plex isn\'t configured or can\'t be reached.', 409); if (entry.layout !== LAYOUT_TV) throw this._error(`${folderLabel(entry.name)} isn't a TV folder.`, 400); - const { mappedLibraryId, suggestedLibraryId } = plex.plexMapping; + const { mappedLibraryId, suggestedLibraryId, choice } = plex.plexMapping; const config = configModule.getConfig(); const mappings = Array.isArray(config.plexSubfolderLibraryMappings) ? config.plexSubfolderLibraryMappings : []; - if (mappedLibraryId === String(libraryId)) return { mappedLibraryId, plexSubfolderLibraryMappings: mappings }; - if (mappedLibraryId) throw this._error(`${folderLabel(entry.name)} already refreshes another Plex library. Change it in Settings > Plex.`, 409); + if (mappedLibraryId === String(libraryId)) return { mappedLibraryId, choice, plexSubfolderLibraryMappings: mappings }; + if (choice !== CHOICE_NONE) throw this._error(`${folderLabel(entry.name)} already refreshes another Plex library.`, 409); if (suggestedLibraryId !== String(libraryId)) { throw this._error(`That Plex library isn't the one TV Shows library that holds ${folderLabel(entry.name)}.`, 409); } const plexSubfolderLibraryMappings = [...mappings, { subfolder: entry.name, libraryId: String(libraryId) }]; configModule.updateConfig({ ...config, plexSubfolderLibraryMappings }); logger.info({ libraryFolder: entry.name, libraryId }, 'Mapped a TV folder to its Plex library for refreshes'); - return { mappedLibraryId: String(libraryId), plexSubfolderLibraryMappings }; + return { mappedLibraryId: String(libraryId), choice: 'library', plexSubfolderLibraryMappings }; } _error(message, status) { diff --git a/server/modules/mediaServers/libraryMatcher.js b/server/modules/mediaServers/libraryMatcher.js index 3e34d9cf9..c466129e0 100644 --- a/server/modules/mediaServers/libraryMatcher.js +++ b/server/modules/mediaServers/libraryMatcher.js @@ -33,6 +33,19 @@ function segmentsOf(p) { return String(p || '').split(/[\\/]+/).filter(Boolean); } +// How a server spells its paths: a POSIX leading slash or a UNC prefix, and +// its separator, so a path rebuilt from segments reads as the server shows it. +function pathStyleOf(original) { + const text = String(original || ''); + const separator = text.includes('\\') && !text.includes('/') ? '\\' : '/'; + const root = text.startsWith('\\\\') ? '\\\\' : text.startsWith('/') ? '/' : ''; + return { separator, root }; +} + +function formatPath(segments, style) { + return `${style.root}${segments.join(style.separator)}`; +} + function sameSegment(a, b) { return a.toLowerCase() === b.toLowerCase(); } @@ -61,17 +74,23 @@ function subfolderOfSegment(segment, subfolderKeys) { class FolderPaths { constructor() { - this.byFolder = new Map(); // folderKey -> Map(pathKey -> { segments, source }) + this.byFolder = new Map(); // folderKey -> Map(pathKey -> { segments, source, style }) } - add(folder, segments, source) { + /** + * @param {string} folder + * @param {string[]} segments + * @param {string} source + * @param {{separator: string, root: string}} style - how the server spells the path (pathStyleOf) + */ + add(folder, segments, source, style) { if (segments.length === 0) return; const key = folderKey(folder); if (!this.byFolder.has(key)) this.byFolder.set(key, new Map()); const paths = this.byFolder.get(key); const existing = paths.get(pathKey(segments)); // A path found by name or content outranks one only assumed. - if (!existing || existing.source === SOURCE_DERIVED) paths.set(pathKey(segments), { segments, source }); + if (!existing || existing.source === SOURCE_DERIVED) paths.set(pathKey(segments), { segments, source, style }); } of(folder) { @@ -106,7 +125,7 @@ function mappingFromSample({ serverPath, containerPath }, rootSegments, subfolde // video before the server rescans): it says nothing about this folder. const serverFolder = subfolderOfSegment(serverPrefix[serverPrefix.length - 1], subfolderKeys); if (serverFolder !== null && folderKey(serverFolder) !== folderKey(folder)) return null; - return { folder, segments: serverPrefix }; + return { folder, segments: serverPrefix, style: pathStyleOf(serverPath) }; } /** @@ -119,7 +138,7 @@ function mappingFromSample({ serverPath, containerPath }, rootSegments, subfolde * @returns {{ * relations: Array<{libraryId: string, location: string, folder: string, relation: string, source: string, * folderSegmentMissing: boolean}>, - * folderPaths: (folder: string) => Array<{path: string, source: string}>, + * folderPaths: (folder: string) => Array<{path: string, source: string}>, path in the server's own spelling * mainKnown: boolean, * scope: Set<string>|null * }} @@ -139,18 +158,19 @@ function matchLibraries({ folders, libraries, samples = [], containerRoot }) { const segments = segmentsOf(location); const folder = subfolderOfSegment(segments[segments.length - 1], subfolderKeys); if (folder === null) continue; - paths.add(folder, segments, SOURCE_NAME); - paths.add('', segments.slice(0, -1), SOURCE_NAME); + const style = pathStyleOf(location); + paths.add(folder, segments, SOURCE_NAME, style); + paths.add('', segments.slice(0, -1), SOURCE_NAME, style); } } for (const sample of samples) { const mapping = mappingFromSample(sample, rootSegments, subfolderKeys); - if (mapping) paths.add(mapping.folder, mapping.segments, SOURCE_SAMPLE); + if (mapping) paths.add(mapping.folder, mapping.segments, SOURCE_SAMPLE, mapping.style); } const mains = paths.of(''); for (const folder of subfolderKeys.values()) { for (const main of mains) { - paths.add(folder, [...main.segments, `${SUBFOLDER_PREFIX}${folder}`], SOURCE_DERIVED); + paths.add(folder, [...main.segments, `${SUBFOLDER_PREFIX}${folder}`], SOURCE_DERIVED, main.style); } } @@ -181,7 +201,7 @@ function matchLibraries({ folders, libraries, samples = [], containerRoot }) { const scope = mainKnown ? new Set(relations.map((relation) => relation.libraryId)) : null; return { relations: dedupeRelations(relations), - folderPaths: (folder) => paths.of(folder).map((known) => ({ path: known.segments.join('/'), source: known.source })), + folderPaths: (folder) => paths.of(folder).map((known) => ({ path: formatPath(known.segments, known.style), source: known.source })), mainKnown, scope, }; diff --git a/server/modules/mediaServers/plexMappingEntries.js b/server/modules/mediaServers/plexMappingEntries.js new file mode 100644 index 000000000..5b73495e2 --- /dev/null +++ b/server/modules/mediaServers/plexMappingEntries.js @@ -0,0 +1,74 @@ +/** + * The plexSubfolderLibraryMappings config list: which Plex library a library + * folder's downloads refresh. An entry with a library id chooses that + * library; an entry without one (libraryId null) is an explicit choice of the + * default library, which automatic mapping leaves alone. The main folder's + * entry has subfolder null. Folder names compare ignoring case. + * + * Pure: callers read and save the config. + */ + +const { folderKey } = require('../tvShows/constants'); + +const CHOICE_LIBRARY = 'library'; +const CHOICE_DEFAULT = 'default'; +const CHOICE_NONE = 'none'; + +function readMappings(config) { + const raw = config ? config.plexSubfolderLibraryMappings : null; + return Array.isArray(raw) ? raw.filter((entry) => entry && typeof entry === 'object') : []; +} + +function sameFolder(entry, folder) { + return folderKey(entry.subfolder) === folderKey(folder); +} + +function findEntry(mappings, folder) { + return mappings.find((entry) => sameFolder(entry, folder)) || null; +} + +function libraryIdOf(entry) { + if (entry.libraryId === null || entry.libraryId === undefined) return null; + const id = String(entry.libraryId).trim(); + return id || null; +} + +/** @returns {{choice: string, libraryId: string|null}} */ +function mappingOf(mappings, folder) { + const entry = findEntry(mappings, folder); + if (!entry) return { choice: CHOICE_NONE, libraryId: null }; + const libraryId = libraryIdOf(entry); + return { choice: libraryId ? CHOICE_LIBRARY : CHOICE_DEFAULT, libraryId }; +} + +/** The list with the folder's entry set to libraryId (null = the default library). */ +function withEntry(mappings, folder, libraryId) { + const entry = { subfolder: folder || null, libraryId: libraryId === null ? null : String(libraryId) }; + const next = []; + let placed = false; + for (const existing of mappings) { + if (!sameFolder(existing, folder)) { + next.push(existing); + } else if (!placed) { + next.push(entry); + placed = true; + } + } + if (!placed) next.push(entry); + return next; +} + +function withoutEntry(mappings, folder) { + return mappings.filter((entry) => !sameFolder(entry, folder)); +} + +module.exports = { + CHOICE_LIBRARY, + CHOICE_DEFAULT, + CHOICE_NONE, + readMappings, + findEntry, + mappingOf, + withEntry, + withoutEntry, +}; diff --git a/server/modules/mediaServers/plexRefreshMappings.js b/server/modules/mediaServers/plexRefreshMappings.js new file mode 100644 index 000000000..8c4be56a2 --- /dev/null +++ b/server/modules/mediaServers/plexRefreshMappings.js @@ -0,0 +1,77 @@ +/** + * Which Plex library a library folder's downloads refresh, set from the + * Library folders page: a library, the explicit default library (an entry + * with libraryId null, which automatic mapping leaves alone), or no setting. + * Removing a setting needs no Plex connection, so stale entries (Plex + * offline, a deleted library) can always be cleared. + */ + +const configModule = require('../configModule'); +const plexModule = require('../plexModule'); +const subfolderModule = require('../subfolderModule'); +const logger = require('../../logger'); +const { folderKey } = require('../tvShows/constants'); +const { readMappings, findEntry, mappingOf, withEntry, withoutEntry } = require('./plexMappingEntries'); + +function refusal(message, status) { + const error = new Error(message); + error.status = status; + return error; +} + +function view(mappings, folder) { + const { choice, libraryId } = mappingOf(mappings, folder); + return { mappedLibraryId: libraryId, choice, plexSubfolderLibraryMappings: mappings }; +} + +class PlexRefreshMappings { + async _knownFolderName(folder) { + if (!folder) return ''; + const names = (await subfolderModule.getAll()).map((display) => display.replace(/^__/, '')); + const match = names.find((name) => folderKey(name) === folderKey(folder)); + if (match === undefined) throw refusal('Library folder not found', 404); + return match; + } + + async _assertPlexLists(libraryId) { + const config = configModule.getConfig(); + if (!config.plexApiKey || !plexModule.getBaseUrl(config.plexIP, config, config.plexPort, config.plexViaHttps)) { + throw refusal('Plex isn\'t configured.', 409); + } + // getLibraries answers [] when Plex can't be reached. + const libraries = await plexModule.getLibraries(); + if (libraries.length === 0) throw refusal('Plex can\'t be reached, so the library can\'t be checked.', 409); + if (!libraries.some((library) => String(library.id) === libraryId)) { + throw refusal(`Plex doesn't list library ${libraryId}.`, 400); + } + } + + /** + * @param {string} folder - '' for the main folder + * @param {string|null} libraryId - a Plex library id, or null for the default library + */ + async setMapping(folder, libraryId) { + const name = await this._knownFolderName(folder); + if (libraryId !== null) await this._assertPlexLists(libraryId); + // A folder known only from config gets its registry row. + if (name) await subfolderModule.register(name); + const config = configModule.getConfig(); + const mappings = withEntry(readMappings(config), name, libraryId); + configModule.updateConfig({ ...config, plexSubfolderLibraryMappings: mappings }); + logger.info({ libraryFolder: name, libraryId }, 'Set the Plex library a library folder refreshes'); + return view(mappings, name); + } + + /** @param {string} folder - '' for the main folder */ + async removeMapping(folder) { + const config = configModule.getConfig(); + const current = readMappings(config); + if (!findEntry(current, folder)) return view(current, folder); + const mappings = withoutEntry(current, folder); + configModule.updateConfig({ ...config, plexSubfolderLibraryMappings: mappings }); + logger.info({ libraryFolder: folder }, 'Removed the Plex refresh setting of a library folder'); + return view(mappings, folder); + } +} + +module.exports = new PlexRefreshMappings(); diff --git a/server/modules/plexModule.js b/server/modules/plexModule.js index e794bd5d3..1391d944c 100644 --- a/server/modules/plexModule.js +++ b/server/modules/plexModule.js @@ -1,6 +1,7 @@ const axios = require('axios'); const configModule = require('./configModule'); const logger = require('../logger'); +const { readMappings, findEntry } = require('./mediaServers/plexMappingEntries'); // Plex HTTP request timeout in milliseconds. // Any call to the local Plex server should complete within a few seconds on @@ -46,14 +47,10 @@ class PlexModule { */ getLibraryIdForSubfolder(subfolder) { const config = configModule.getConfig(); - const raw = config.plexSubfolderLibraryMappings; - const mappings = Array.isArray(raw) ? raw : []; - - const normalizedSubfolder = subfolder || null; - const match = mappings - .filter((m) => m && typeof m === 'object') - .find((m) => (m.subfolder || null) === normalizedSubfolder); - return (match && match.libraryId) || config.plexYoutubeLibraryId || ''; + // Folder names compare ignoring case, like the library check; an explicit + // default entry (libraryId null) falls back to the default library. + const entry = findEntry(readMappings(config), subfolder || ''); + return (entry && entry.libraryId) || config.plexYoutubeLibraryId || ''; } /** @@ -137,6 +134,7 @@ class PlexModule { (directory) => ({ id: directory.key, title: directory.title, + type: directory.type, locations: directory.Location.map((location) => ({ // map the Location array id: location.id, diff --git a/server/modules/subfolderDeletion.js b/server/modules/subfolderDeletion.js new file mode 100644 index 000000000..8a6646958 --- /dev/null +++ b/server/modules/subfolderDeletion.js @@ -0,0 +1,48 @@ +/** + * Why a library folder can't be deleted. One rule for the DELETE guard + * (subfolderModule.delete, which answers with the first reason) and the + * folder usage the Library folders page shows (every reason), so they can't + * drift. A Plex refresh mapping is no reason: deleting the folder removes it. + * + * @typedef {{channels: number, disabledChannels: number, playlists: number, shows: number, + * isDefault: boolean, hasFiles: boolean}} FolderUsage + */ + +/** + * @param {FolderUsage} usage + * @returns {Array<{code: string, count?: number}>} + */ +function deletionBlockers(usage) { + const blockers = []; + if (usage.channels > 0) blockers.push({ code: 'channels', count: usage.channels }); + if (usage.disabledChannels > 0) blockers.push({ code: 'disabledChannels', count: usage.disabledChannels }); + if (usage.playlists > 0) blockers.push({ code: 'playlists', count: usage.playlists }); + if (usage.shows > 0) blockers.push({ code: 'shows', count: usage.shows }); + if (usage.isDefault) blockers.push({ code: 'default' }); + if (usage.hasFiles) blockers.push({ code: 'files' }); + return blockers; +} + +/** + * @param {FolderUsage} usage + * @returns {string|null} the first reason, as the DELETE route answers it + */ +function deletionBlockReason(usage) { + const [first] = deletionBlockers(usage); + if (!first) return null; + switch (first.code) { + case 'channels': + case 'disabledChannels': + return `Subfolder is in use by ${(usage.channels || 0) + (usage.disabledChannels || 0)} channel(s)`; + case 'playlists': + return `Subfolder is in use by ${usage.playlists} playlist(s)`; + case 'shows': + return `Subfolder holds ${usage.shows} TV show(s) with numbered episodes`; + case 'default': + return 'Subfolder is the global default and cannot be deleted'; + default: + return 'Subfolder still contains downloaded files and cannot be deleted'; + } +} + +module.exports = { deletionBlockers, deletionBlockReason }; diff --git a/server/modules/subfolderModule.js b/server/modules/subfolderModule.js index e2b6597d7..9e3fc1b84 100644 --- a/server/modules/subfolderModule.js +++ b/server/modules/subfolderModule.js @@ -9,7 +9,9 @@ const configModule = require('./configModule'); const { buildSubfolderSegment, directoryHasFiles, removeIfEmpty, resolveEffectiveSubfolder } = require('./filesystem'); const { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } = require('./filesystem/constants'); const { getLayoutResolver } = require('./tvShows/libraryLayouts'); -const { LAYOUT_TV, KIND_TITLE_SHOW } = require('./tvShows/constants'); +const { LAYOUT_TV, LAYOUT_VIDEOS, KIND_TITLE_SHOW } = require('./tvShows/constants'); +const { deletionBlockers, deletionBlockReason } = require('./subfolderDeletion'); +const { readMappings, findEntry, withoutEntry } = require('./mediaServers/plexMappingEntries'); const SENTINELS = new Set([GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL]); @@ -23,34 +25,6 @@ function makeError(message, status) { return err; } -/** - * Single rule for why a subfolder can't be deleted, shared by delete() and - * getUsage() so they can't drift. - * @param {{channels:number, playlists:number, shows:number, isDefault:boolean, plexMapped:boolean, hasFiles:boolean}} usage - * @returns {string|null} reason, or null when the subfolder is safe to delete - */ -function deletionBlockReason(usage) { - if (usage.channels > 0) { - return `Subfolder is in use by ${usage.channels} channel(s)`; - } - if (usage.playlists > 0) { - return `Subfolder is in use by ${usage.playlists} playlist(s)`; - } - if (usage.shows > 0) { - return `Subfolder holds ${usage.shows} TV show(s) with numbered episodes`; - } - if (usage.isDefault) { - return 'Subfolder is the global default and cannot be deleted'; - } - if (usage.plexMapped) { - return 'Subfolder is mapped to a Plex library and cannot be deleted'; - } - if (usage.hasFiles) { - return 'Subfolder still contains downloaded files and cannot be deleted'; - } - return null; -} - /** * Channels whose current folder has the TV layout, among the given ids. A * tracked channel that moved back to a videos folder keeps its show row (for @@ -162,17 +136,17 @@ class SubfolderModule { * Compute the usage of a single subfolder name (per-name queries). Used by * delete() where only one name is in play. * @param {string} clean - Clean subfolder name (no __ prefix) - * @returns {Promise<{channels:number, playlists:number, shows:number, isDefault:boolean, plexMapped:boolean, hasFiles:boolean}>} + * @returns {Promise<import('./subfolderDeletion').FolderUsage>} */ async _usageForName(clean) { - const channels = await Channel.count({ where: { sub_folder: clean } }); + const channels = await Channel.count({ where: { sub_folder: clean, enabled: true } }); + const disabledChannels = await Channel.count({ where: { sub_folder: clean, enabled: false } }); const playlists = await Playlist.count({ where: { default_sub_folder: clean } }); const shows = (await tallyNumberedShows()).get(clean.toLowerCase()) || 0; const def = configModule.getDefaultSubfolder(); const isDefault = !!(def && def.toLowerCase() === clean.toLowerCase()); - const plexMapped = this._plexMappingSubfolders().some((s) => s.toLowerCase() === clean.toLowerCase()); const hasFiles = await directoryHasFiles(path.join(configModule.directoryPath, buildSubfolderSegment(clean))); - return { channels, playlists, shows, isDefault, plexMapped, hasFiles }; + return { channels, disabledChannels, playlists, shows, isDefault, hasFiles }; } /** @@ -210,6 +184,7 @@ class SubfolderModule { ); const usage = { channels: channelTally.get(key) || 0, + disabledChannels: 0, playlists: playlistTally.get(key) || 0, shows: showTally.get(key) || 0, isDefault: defaultKey === key, @@ -228,43 +203,79 @@ class SubfolderModule { return items.sort((a, b) => a.displayName.localeCompare(b.displayName)); } + /** Every reason a folder can't be deleted, in guard order (see subfolderDeletion). */ + deletionBlockers(usage) { + return deletionBlockers(usage); + } + + /** TV shows with numbered episodes per library folder (lowercased name -> count). */ + numberedShowCounts() { + return tallyNumberedShows(); + } + + _removePlexMapping(clean) { + const config = configModule.getConfig(); + const mappings = readMappings(config); + if (!findEntry(mappings, clean)) return; + configModule.updateConfig({ ...config, plexSubfolderLibraryMappings: withoutEntry(mappings, clean) }); + logger.info({ subfolder: clean }, 'Removed the Plex refresh setting of a deleted library folder'); + } + /** * Idempotently register a subfolder name. Ignores sentinels/null/empty. + * Download-time callers rely on it never throwing; the Library folders + * create path asks for errors. * @param {string} name - * @returns {Promise<void>} + * @param {Object} [options] + * @param {string} [options.layout] - Layout for a new row ('videos' | 'tv'); an existing row keeps its own + * @param {boolean} [options.throwOnError=false] + * @returns {Promise<{name: string, layout: string, created: boolean}|null>} */ - async register(name) { - if (!isRealName(name)) return; + async register(name, { layout, throwOnError = false } = {}) { + if (!isRealName(name)) return null; const clean = name.trim(); try { - await Subfolder.findOrCreate({ where: { name: clean }, defaults: { name: clean } }); + const [row, created] = await Subfolder.findOrCreate({ + where: { name: clean }, + defaults: { name: clean, ...(layout ? { layout } : {}) }, + }); + return { name: row.name, layout: row.layout === LAYOUT_TV ? LAYOUT_TV : LAYOUT_VIDEOS, created }; } catch (err) { - // Unique-constraint race under case/accent-insensitive collation: treat as success. - if (err && err.name === 'SequelizeUniqueConstraintError') return; + // Unique-constraint race under case/accent-insensitive collation: the row exists. + if (err && err.name === 'SequelizeUniqueConstraintError') { + if (!throwOnError) return null; + const row = await Subfolder.findOne({ where: { name: clean } }); + return { name: row ? row.name : clean, layout: row && row.layout === LAYOUT_TV ? LAYOUT_TV : LAYOUT_VIDEOS, created: false }; + } + if (throwOnError) throw err; logger.warn({ err, name: clean }, 'Failed to register subfolder'); + return null; } } /** - * Delete a subfolder from the registry, only when empty on disk and unused. - * @param {string} name - * @returns {Promise<void>} - * @throws {Error} with .status 404 (unknown) or 409 (guard failed) + * Delete a library folder: its registry row, its Plex refresh mapping, and + * the directory when empty. A folder known only from a Plex mapping has no + * row; it is deleted the same way. + * @throws {Error} with .status 400, 404 (unknown) or 409 (guard failed) */ async delete(name) { const clean = (name || '').trim(); if (!clean) throw makeError('Invalid subfolder name', 400); const exists = await Subfolder.count({ where: { name: clean } }); - if (exists === 0) throw makeError('Subfolder not found', 404); + const mapped = Boolean(findEntry(readMappings(configModule.getConfig()), clean)); + const def = configModule.getDefaultSubfolder(); + const isDefault = Boolean(def && def.toLowerCase() === clean.toLowerCase()); + if (exists === 0 && !mapped && !isDefault) throw makeError('Subfolder not found', 404); const reason = deletionBlockReason(await this._usageForName(clean)); if (reason) throw makeError(reason, 409); - const dirPath = path.join(configModule.directoryPath, buildSubfolderSegment(clean)); - await Subfolder.destroy({ where: { name: clean } }); + if (exists > 0) await Subfolder.destroy({ where: { name: clean } }); + this._removePlexMapping(clean); // Best-effort, non-recursive cleanup of the now-empty directory. - await removeIfEmpty(dirPath); + await removeIfEmpty(path.join(configModule.directoryPath, buildSubfolderSegment(clean))); } } diff --git a/server/modules/tvShows/__tests__/folderDetail.test.js b/server/modules/tvShows/__tests__/folderDetail.test.js new file mode 100644 index 000000000..56ee1577c --- /dev/null +++ b/server/modules/tvShows/__tests__/folderDetail.test.js @@ -0,0 +1,158 @@ +jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/playlistvideo', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/tvshow', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/video', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: '/data' })); +jest.mock('../../subfolderModule', () => ({ getAll: jest.fn() })); +jest.mock('../../videoInfoStore', () => ({ readInfoOrFallback: jest.fn() })); +jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); +jest.mock('../layoutGuards', () => ({ + guardError: (message, status) => Object.assign(new Error(message), { status }), +})); +jest.mock('../channelFolders', () => ({ + effectiveLibraryFolder: (value) => { + const { resolveEffectiveSubfolder } = jest.requireActual('../../filesystem/pathBuilder'); + return resolveEffectiveSubfolder(value, require('../../configModule').getDefaultSubfolder()) || ''; + }, +})); +jest.mock('../folderUsage', () => ({ + chosenFolderOf: (value) => (value === '##USE_GLOBAL_DEFAULT##' ? null : (value || '').trim()), +})); + +const { Op } = require('sequelize'); +const Channel = require('../../../models/channel'); +const Playlist = require('../../../models/playlist'); +const PlaylistVideo = require('../../../models/playlistvideo'); +const TvShow = require('../../../models/tvshow'); +const Video = require('../../../models/video'); +const VideoClassification = require('../../../models/videoclassification'); +const configModule = require('../../configModule'); +const subfolderModule = require('../../subfolderModule'); +const videoInfoStore = require('../../videoInfoStore'); +const libraryLayouts = require('../libraryLayouts'); +const { getFolderDetail } = require('../folderDetail'); + +const video = (id, filePath, extra = {}) => ({ + id, youtubeId: `yt${id}`, channel_id: 'UC1', youTubeChannelName: 'Blippi', youTubeVideoName: `Title ${id}`, + originalDate: '20260928', filePath, audioFilePath: null, last_downloaded_at: null, ...extra, +}); + +describe('folderDetail', () => { + beforeEach(() => { + jest.clearAllMocks(); + configModule.getDefaultSubfolder.mockReturnValue('Kids'); + subfolderModule.getAll.mockResolvedValue(['__Kids', '__check', '__TV']); + libraryLayouts.getLayoutResolver.mockResolvedValue((folder) => (folder === 'TV' ? 'tv' : 'videos')); + Channel.findAll.mockImplementation(async ({ where }) => (where.enabled + ? [ + { channel_id: 'UC1', uploader: 'Blippi', title: 'Blippi', sub_folder: 'Kids' }, + { channel_id: 'UC2', uploader: 'Zed', title: 'Zed', sub_folder: '##USE_GLOBAL_DEFAULT##' }, + { channel_id: 'UC3', uploader: 'Abe', title: 'Abe', sub_folder: '##USE_GLOBAL_DEFAULT##' }, + ] + : [{ channel_id: 'UC9', uploader: 'Show Owner', title: 'Show Owner' }])); + Playlist.findAll.mockResolvedValue([{ playlist_id: 'PL1', title: 'Songs', default_sub_folder: '##USE_GLOBAL_DEFAULT##' }]); + PlaylistVideo.findAll.mockResolvedValue([{ playlist_id: 'PL1', youtube_id: 'yt1' }, { playlist_id: 'PL1', youtube_id: 'other' }]); + TvShow.findAll.mockResolvedValue([]); + VideoClassification.findAll.mockResolvedValue([]); + Video.findAll.mockResolvedValue([ + video(1, '/data/__Kids/Blippi/Blippi - Title 1 [yt1].mp4', { last_downloaded_at: new Date('2026-09-01') }), + video(2, '/data/__Kids/Blippi/Blippi - Title 2 [yt2].mp4', { last_downloaded_at: new Date('2026-09-30') }), + video(3, '/data/__Kidsville/X/x [yt3].mp4'), + ]); + videoInfoStore.readInfoOrFallback.mockResolvedValue({ timestamp: 1790611800 }); + }); + + test('404s an unknown folder', async () => { + await expect(getFolderDetail('Nope')).rejects.toMatchObject({ status: 404, message: 'Library folder not found' }); + }); + + test('resolves a folder named check and the main folder key', async () => { + await expect(getFolderDetail('check')).resolves.toMatchObject({ name: 'check' }); + await expect(getFolderDetail('~main')).resolves.toMatchObject({ name: '' }); + }); + + test('lists the channels that chose the folder with their videos here', async () => { + const detail = await getFolderDetail('kids'); + + expect(detail.name).toBe('Kids'); + expect(detail.channels).toEqual([{ channelId: 'UC1', name: 'Blippi', videoCount: 2 }]); + }); + + test('samples the followers of the default folder by name', async () => { + const detail = await getFolderDetail('Kids'); + + expect(detail.followers).toEqual({ count: 2, sample: ['Abe', 'Zed'] }); + }); + + test('counts a playlist\'s videos in this folder only', async () => { + const detail = await getFolderDetail('Kids'); + + expect(detail.playlists).toEqual([{ playlistId: 'PL1', name: 'Songs', videoCount: 1 }]); + }); + + test('takes the most recent download as the example, with its upload time and path', async () => { + const detail = await getFolderDetail('Kids'); + + expect(detail.example).toEqual({ + channelName: 'Blippi', + title: 'Title 2', + youtubeId: 'yt2', + uploadedAt: new Date(1790611800 * 1000).toISOString(), + uploadedAtSource: 'timestamp', + relativePath: 'Blippi/Blippi - Title 2 [yt2].mp4', + }); + }); + + test('falls back to the upload date at 00:00 UTC', async () => { + videoInfoStore.readInfoOrFallback.mockResolvedValue({ upload_date: '20260928' }); + + const detail = await getFolderDetail('Kids'); + + expect(detail.example).toMatchObject({ uploadedAt: '2026-09-28T00:00:00.000Z', uploadedAtSource: 'upload_date' }); + }); + + test('lists title shows with their channel and episodes here', async () => { + TvShow.findAll.mockResolvedValue([{ id: 7, name: 'Lessons', channel_id: 'UC9', library_folder: 'TV' }]); + VideoClassification.findAll.mockResolvedValue([{ show_id: 7, youtube_id: 'yt5' }]); + Video.findAll.mockResolvedValue([video(5, '/data/__TV/Lessons/Season 2026/S2026E01 - A [yt5].mp4')]); + + const detail = await getFolderDetail('TV'); + + expect(detail.titleShows).toEqual([{ id: 7, name: 'Lessons', channelId: 'UC9', channelName: 'Show Owner', episodeCount: 1 }]); + }); + + test('takes the example path from the folder as it is spelled on disk', async () => { + Video.findAll.mockResolvedValue([ + video(9, '/data/__kids/Blippi/Blippi - Title 9 [yt9].mp4', { last_downloaded_at: new Date('2026-10-01') }), + ]); + + const detail = await getFolderDetail('Kids'); + + expect(detail.example.relativePath).toBe('Blippi/Blippi - Title 9 [yt9].mp4'); + }); + + test('reads the main folder when the downloads path ends with a separator', async () => { + configModule.directoryPath = '/data/'; + const rows = [video(4, '/data/Chan/c [yt4].mp4', { last_downloaded_at: new Date('2026-09-15') })]; + Video.findAll.mockImplementation(async ({ where }) => { + const prefixes = where[Op.or].map((clause) => Object.values(clause)[0][Op.startsWith]); + return rows.filter((row) => prefixes.some((prefix) => (row.filePath || '').startsWith(prefix))); + }); + + try { + const detail = await getFolderDetail('~main'); + + expect(detail.example).toMatchObject({ youtubeId: 'yt4', relativePath: 'Chan/c [yt4].mp4' }); + } finally { + configModule.directoryPath = '/data'; + } + }); + + test('has no example without downloads', async () => { + Video.findAll.mockResolvedValue([]); + + await expect(getFolderDetail('Kids')).resolves.toMatchObject({ example: null }); + }); +}); diff --git a/server/modules/tvShows/__tests__/folderUsage.test.js b/server/modules/tvShows/__tests__/folderUsage.test.js new file mode 100644 index 000000000..f80333809 --- /dev/null +++ b/server/modules/tvShows/__tests__/folderUsage.test.js @@ -0,0 +1,136 @@ +jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/tvshow', () => ({ findAll: jest.fn() })); +jest.mock('../../../models/video', () => ({ findAll: jest.fn() })); +jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), getConfig: jest.fn(), directoryPath: '/data' })); +jest.mock('../../subfolderModule', () => ({ + numberedShowCounts: jest.fn(), + deletionBlockers: (usage) => jest.requireActual('../../subfolderDeletion').deletionBlockers(usage), +})); +jest.mock('../layoutGuards', () => ({ channelIdsWithDownloads: jest.fn() })); +jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn() })); +jest.mock('../channelFolders', () => ({ + effectiveLibraryFolder: (value) => { + const { resolveEffectiveSubfolder } = jest.requireActual('../../filesystem/pathBuilder'); + return resolveEffectiveSubfolder(value, require('../../configModule').getDefaultSubfolder()) || ''; + }, +})); + +const Channel = require('../../../models/channel'); +const Playlist = require('../../../models/playlist'); +const TvShow = require('../../../models/tvshow'); +const Video = require('../../../models/video'); +const configModule = require('../../configModule'); +const subfolderModule = require('../../subfolderModule'); +const layoutGuards = require('../layoutGuards'); +const libraryLayouts = require('../libraryLayouts'); +const { describeUsage, chosenFolderOf } = require('../folderUsage'); + +const DEFAULT = '##USE_GLOBAL_DEFAULT##'; +const base = (name, extra = {}) => ({ name, layout: 'videos', isDefault: false, hasFiles: false, channels: 0, ...extra }); + +describe('folderUsage', () => { + beforeEach(() => { + jest.clearAllMocks(); + configModule.getDefaultSubfolder.mockReturnValue('Kids'); + configModule.getConfig.mockReturnValue({ plexSubfolderLibraryMappings: [{ subfolder: 'KIDS', libraryId: '38' }] }); + Channel.findAll.mockResolvedValue([ + { channel_id: 'UC1', sub_folder: 'kids', enabled: true }, + { channel_id: 'UC2', sub_folder: DEFAULT, enabled: true }, + { channel_id: 'UC3', sub_folder: DEFAULT, enabled: false }, + { channel_id: 'UC4', sub_folder: 'Kids', enabled: false }, + { channel_id: 'UC5', sub_folder: null, enabled: true }, + { channel_id: 'UC6', sub_folder: 'TV', enabled: true }, + ]); + Playlist.findAll.mockResolvedValue([ + { default_sub_folder: DEFAULT, enabled: true }, + { default_sub_folder: 'Kids', enabled: false }, + ]); + TvShow.findAll.mockResolvedValue([{ library_folder: 'TV' }]); + Video.findAll.mockResolvedValue([ + { filePath: '/data/__Kids/A/a [x].mp4', audioFilePath: null }, + { filePath: null, audioFilePath: '/data/__Kids/A/b [y].mp3' }, + { filePath: '/data/Chan/c [z].mp4', audioFilePath: null }, + { filePath: '/elsewhere/d [w].mp4', audioFilePath: null }, + ]); + subfolderModule.numberedShowCounts.mockResolvedValue(new Map([['tv', 2]])); + layoutGuards.channelIdsWithDownloads.mockResolvedValue(new Set(['UC3'])); + libraryLayouts.getLayoutResolver.mockResolvedValue((folder) => (folder.toLowerCase() === 'tv' ? 'tv' : 'videos')); + }); + + test('chosenFolderOf reads the default sentinel as following, root values as the main folder', () => { + expect(chosenFolderOf(DEFAULT)).toBeNull(); + expect(chosenFolderOf('##ROOT##')).toBe(''); + expect(chosenFolderOf(null)).toBe(''); + expect(chosenFolderOf(' Kids ')).toBe('Kids'); + }); + + test('without include, folders come back unchanged', async () => { + const folders = [base('')]; + await expect(describeUsage(folders, {})).resolves.toBe(folders); + }); + + test('splits enabled channels into chosen and following, ignoring case', async () => { + const [kids] = await describeUsage([base('Kids', { isDefault: true, channels: 2 })], { usage: true }); + + expect(kids).toMatchObject({ channelsChosen: 1, channelsFollowing: 1, playlists: 1, titleShows: 0 }); + }); + + test('counts main folder choosers and title shows', async () => { + const [main, tv] = await describeUsage([base(''), base('TV', { layout: 'tv' })], { usage: true }); + + expect(main).toMatchObject({ channelsChosen: 1, channelsFollowing: 0 }); + expect(tv).toMatchObject({ channelsChosen: 1, titleShows: 1 }); + }); + + test('needs a review for a layout change when a user of the folder has downloads, even disabled', async () => { + const [kids] = await describeUsage([base('Kids', { isDefault: true })], { usage: true }); + + expect(kids.layoutChangeNeedsReview).toBe(true); + }); + + test('needs a review to become the default when the layouts differ and a follower has downloads', async () => { + const [tv] = await describeUsage([base('TV', { layout: 'tv' })], { usage: true }); + + expect(tv.makeDefaultNeedsReview).toBe(true); + }); + + test('does not need a review for a same-layout default switch', async () => { + const [main] = await describeUsage([base('')], { usage: true }); + + expect(main.makeDefaultNeedsReview).toBe(false); + }); + + test('reports the Plex mapping ignoring case', async () => { + const [kids] = await describeUsage([base('Kids')], { usage: true }); + + expect(kids.plexMapping).toEqual({ choice: 'library', libraryId: '38' }); + }); + + test('lists every delete blocker, disabled channels and literal playlists included', async () => { + const [kids] = await describeUsage([base('Kids', { isDefault: true, hasFiles: true })], { usage: true }); + + expect(kids.deleteBlockers).toEqual([ + { code: 'channels', count: 1 }, + { code: 'disabledChannels', count: 1 }, + { code: 'playlists', count: 1 }, + { code: 'default' }, + { code: 'files' }, + ]); + expect(kids.deletable).toBe(false); + }); + + test('the main folder is never deletable', async () => { + const [main] = await describeUsage([base('')], { usage: true }); + + expect(main.deleteBlockers).toEqual([{ code: 'main' }]); + }); + + test('fileCount buckets videos and audio-only files by folder, skipping paths outside the downloads folder', async () => { + const [main, kids] = await describeUsage([base(''), base('Kids')], { files: true }); + + expect(main.fileCount).toBe(1); + expect(kids.fileCount).toBe(2); + expect(kids.channelsChosen).toBeUndefined(); + }); +}); diff --git a/server/modules/tvShows/__tests__/layoutGuards.test.js b/server/modules/tvShows/__tests__/layoutGuards.test.js index 5c8c60427..bf9ab0537 100644 --- a/server/modules/tvShows/__tests__/layoutGuards.test.js +++ b/server/modules/tvShows/__tests__/layoutGuards.test.js @@ -1,6 +1,6 @@ jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); -jest.mock('../../../models/video', () => ({ count: jest.fn() })); +jest.mock('../../../models/video', () => ({ count: jest.fn(), findAll: jest.fn() })); jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn() })); jest.mock('../../../models/tvshow', () => ({ findAll: jest.fn() })); jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: '/data' })); @@ -46,6 +46,26 @@ describe('layoutGuards', () => { }); }); + describe('channelIdsWithDownloads', () => { + test('collects channels with their own videos and owners of routed episodes', async () => { + Video.findAll + .mockResolvedValueOnce([{ channel_id: 'UC1' }, { channel_id: null }]) + .mockResolvedValueOnce([{ youtubeId: 'vevo1' }]); + VideoClassification.findAll.mockResolvedValue([ + { channel_id: 'UC1', youtube_id: 'a' }, + { channel_id: 'UC2', youtube_id: 'vevo1' }, + { channel_id: 'UC3', youtube_id: 'gone' }, + ]); + + const ids = await layoutGuards.channelIdsWithDownloads(); + + expect([...ids].sort()).toEqual(['UC1', 'UC2']); + expect(Video.findAll).toHaveBeenLastCalledWith({ + where: { youtubeId: ['vevo1', 'gone'], removed: false }, attributes: ['youtubeId'], raw: true, + }); + }); + }); + describe('channelHasDownloads', () => { it('counts the channel videos that still have files', async () => { Video.count.mockResolvedValue(2); diff --git a/server/modules/tvShows/__tests__/libraryFolders.test.js b/server/modules/tvShows/__tests__/libraryFolders.test.js index 56bdf8478..521decc7c 100644 --- a/server/modules/tvShows/__tests__/libraryFolders.test.js +++ b/server/modules/tvShows/__tests__/libraryFolders.test.js @@ -7,13 +7,22 @@ const mockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'library-folders-')); jest.mock('../../../models/channel', () => ({ findAll: jest.fn() })); jest.mock('../../../models/playlist', () => ({ findAll: jest.fn() })); jest.mock('../../../models/video', () => ({ count: jest.fn() })); +jest.mock('../../../models/subfolder', () => ({ findOne: jest.fn() })); jest.mock('../../../models/videoclassification', () => ({ findAll: jest.fn().mockResolvedValue([]) })); jest.mock('../../../models/tvshow', () => ({ findAll: jest.fn().mockResolvedValue([]) })); jest.mock('../../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); -jest.mock('../../configModule', () => ({ getDefaultSubfolder: jest.fn(), directoryPath: mockRoot })); +jest.mock('../../configModule', () => ({ + getDefaultSubfolder: jest.fn(), + getConfig: jest.fn(() => ({ defaultSubfolder: 'Kids' })), + updateConfig: jest.fn(), + directoryPath: mockRoot, +})); jest.mock('../../subfolderModule', () => ({ getUsage: jest.fn(), getAll: jest.fn(), register: jest.fn() })); jest.mock('../libraryLayouts', () => ({ getLayoutResolver: jest.fn(), setLayout: jest.fn() })); jest.mock('../showStore', () => ({ findChannelShow: jest.fn() })); +jest.mock('../folderUsage', () => ({ + describeUsage: jest.fn(async (folders) => folders.map((f) => ({ ...f, fileCount: 0 }))), +})); const usage = (name, overrides = {}) => ({ name, @@ -26,6 +35,7 @@ describe('libraryFolders', () => { let Channel; let Playlist; let Video; + let Subfolder; let configModule; let subfolderModule; let libraryLayouts; @@ -45,6 +55,7 @@ describe('libraryFolders', () => { Channel = require('../../../models/channel'); Playlist = require('../../../models/playlist'); Video = require('../../../models/video'); + Subfolder = require('../../../models/subfolder'); configModule = require('../../configModule'); subfolderModule = require('../../subfolderModule'); libraryLayouts = require('../libraryLayouts'); @@ -72,6 +83,16 @@ describe('libraryFolders', () => { { name: 'TV', layout: 'tv', isDefault: false, hasFiles: false, channels: 1 }, ]); }); + + it('adds usage when asked to, and leaves plain calls unchanged', async () => { + const folderUsage = require('../folderUsage'); + await libraryFolders.listLibraryFolders({ include: ['files'] }); + expect(folderUsage.describeUsage).toHaveBeenCalledWith(expect.any(Array), { usage: false, files: true }); + + folderUsage.describeUsage.mockClear(); + await libraryFolders.listLibraryFolders(); + expect(folderUsage.describeUsage).not.toHaveBeenCalled(); + }); }); describe('setFolderLayout', () => { @@ -172,6 +193,96 @@ describe('libraryFolders', () => { }); }); + describe('createLibraryFolder', () => { + beforeEach(() => { + Subfolder.findOne.mockResolvedValue(null); + subfolderModule.register.mockImplementation(async (name, { layout }) => ({ name, layout, created: true })); + }); + + it('creates the directory and registers a new TV folder in one insert', async () => { + await expect(libraryFolders.createLibraryFolder('Science', 'tv')).resolves.toEqual({ + name: 'Science', layout: 'tv', created: true, existingContent: false, + }); + expect(fs.existsSync(path.join(mockRoot, '__Science'))).toBe(true); + expect(subfolderModule.register).toHaveBeenCalledWith('Science', { layout: 'tv', throwOnError: true }); + }); + + it('registers nothing when the directory cannot be created', async () => { + writeFile('__Blocked'); + + await expect(libraryFolders.createLibraryFolder('Blocked', null)).rejects.toMatchObject({ + status: 500, message: expect.stringMatching(/^Couldn't create the folder on disk: /), + }); + expect(subfolderModule.register).not.toHaveBeenCalled(); + }); + + it('registers a directory that already holds files as Videos and reports it', async () => { + writeFile('__Old/Chan/video [abcdefghijk].mp4'); + + await expect(libraryFolders.createLibraryFolder('Old', null)).resolves.toEqual({ + name: 'Old', layout: 'videos', created: true, existingContent: true, + }); + }); + + it('sends TV over a directory with files to the reorganize', async () => { + writeFile('__Old/Chan/video [abcdefghijk].mp4'); + subfolderModule.getAll.mockResolvedValue(['__Old']); + + await expect(libraryFolders.createLibraryFolder('Old', 'tv')).rejects.toMatchObject({ + status: 409, reorganizeRequired: true, change: { type: 'folderLayout', folder: 'Old', layout: 'tv' }, + }); + expect(subfolderModule.register).toHaveBeenCalledWith('Old', { layout: 'videos', throwOnError: true }); + }); + + it('keeps an existing folder\'s layout when none is given', async () => { + Subfolder.findOne.mockResolvedValue({ name: 'TV', layout: 'tv' }); + + await expect(libraryFolders.createLibraryFolder('tv', null)).resolves.toEqual({ name: 'TV', layout: 'tv', created: false }); + expect(subfolderModule.register).not.toHaveBeenCalled(); + }); + + it('changes an existing folder\'s layout through the guards', async () => { + Subfolder.findOne.mockResolvedValue({ name: 'Kids', layout: 'videos' }); + writeFile('__Kids/Chan/video [abcdefghijk].mp4'); + + await expect(libraryFolders.createLibraryFolder('Kids', 'tv')).rejects.toMatchObject({ status: 409, reorganizeRequired: true }); + }); + }); + + describe('setDefaultFolder', () => { + it('answers unchanged for the current default, ignoring case', async () => { + await expect(libraryFolders.setDefaultFolder('kids')).resolves.toEqual({ changed: false, defaultSubfolder: 'Kids' }); + expect(configModule.updateConfig).not.toHaveBeenCalled(); + }); + + it('saves a same-layout switch at once, in the registry spelling', async () => { + subfolderModule.getAll.mockResolvedValue(['__Kids', '__Music']); + + await expect(libraryFolders.setDefaultFolder('music')).resolves.toEqual({ changed: true, defaultSubfolder: 'Music' }); + expect(configModule.updateConfig).toHaveBeenCalledWith(expect.objectContaining({ defaultSubfolder: 'Music' })); + }); + + it('saves the main folder as an empty string', async () => { + await libraryFolders.setDefaultFolder(''); + expect(configModule.updateConfig).toHaveBeenCalledWith(expect.objectContaining({ defaultSubfolder: '' })); + }); + + it('404s an unknown folder', async () => { + await expect(libraryFolders.setDefaultFolder('Nope')).rejects.toMatchObject({ status: 404 }); + }); + + it('sends a layout-changing switch with downloaded followers to the reorganize', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'UC1', sub_folder: '##USE_GLOBAL_DEFAULT##', enabled: false }]); + Video.count.mockResolvedValue(3); + + await expect(libraryFolders.setDefaultFolder('TV')).rejects.toMatchObject({ + status: 409, reorganizeRequired: true, change: { type: 'defaultSubfolder', value: 'TV' }, + message: expect.stringContaining('default folder'), + }); + expect(configModule.updateConfig).not.toHaveBeenCalled(); + }); + }); + describe('checkDefaultSubfolderChange', () => { const check = (overrides) => libraryFolders.checkDefaultSubfolderChange({ oldDefault: 'Kids', newDefault: 'TV', ...overrides }); diff --git a/server/modules/tvShows/folderDetail.js b/server/modules/tvShows/folderDetail.js new file mode 100644 index 000000000..63e03e221 --- /dev/null +++ b/server/modules/tvShows/folderDetail.js @@ -0,0 +1,182 @@ +/** + * One library folder in detail, for the Library folders page inspector + * (GET /api/library-folders/folder/:key): the enabled channels that chose it, + * the channels following the default into it, the playlists and title shows + * that use it (each with its downloaded videos in the folder), and the most + * recently downloaded video as the example for the layout preview. The + * example's upload time is read the way date numbering reads it, so example + * episode numbers match real ones. + */ + +const path = require('path'); +const { Op } = require('sequelize'); +const Channel = require('../../models/channel'); +const Playlist = require('../../models/playlist'); +const PlaylistVideo = require('../../models/playlistvideo'); +const TvShow = require('../../models/tvshow'); +const Video = require('../../models/video'); +const VideoClassification = require('../../models/videoclassification'); +const configModule = require('../configModule'); +const subfolderModule = require('../subfolderModule'); +const videoInfoStore = require('../videoInfoStore'); +const { buildSubfolderSegment } = require('../filesystem/pathBuilder'); +const { GLOBAL_DEFAULT_SENTINEL } = require('../filesystem/constants'); +const { resolveLibraryFolder } = require('../filesystem/showFolderCleanup'); +const { folderKey, KIND_TITLE_SHOW } = require('./constants'); +const { effectiveLibraryFolder } = require('./channelFolders'); +const { getLayoutResolver } = require('./libraryLayouts'); +const { releaseTime } = require('./dateNumbering'); +const { chosenFolderOf } = require('./folderUsage'); +const { guardError } = require('./layoutGuards'); + +// The main folder's route key; `~` can't appear in a subfolder name. +const MAIN_FOLDER_KEY = '~main'; +const FOLLOWER_SAMPLE_SIZE = 3; +const VIDEO_ATTRIBUTES = [ + 'id', 'youtubeId', 'channel_id', 'youTubeChannelName', 'youTubeVideoName', 'originalDate', + 'filePath', 'audioFilePath', 'last_downloaded_at', +]; + +const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }); +const channelName = (channel) => channel.uploader || channel.title || channel.channel_id; +const mediaPathOf = (video) => video.filePath || video.audioFilePath || null; + +async function resolveFolderName(routeKey) { + if (routeKey === MAIN_FOLDER_KEY) return ''; + const wanted = folderKey(routeKey); + if (!wanted) return null; + const names = (await subfolderModule.getAll()).map((display) => display.replace(/^__/, '')); + return names.find((name) => folderKey(name) === wanted) ?? null; +} + +// Each video keeps its library root as spelled on disk: a channel set to +// `kids` downloads into __kids even when the registry row says Kids. +async function videosInFolder(name) { + const base = configModule.directoryPath; + const root = name ? path.join(base, buildSubfolderSegment(name)) : base; + // DATA_PATH may end with a separator. + const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + const rows = await Video.findAll({ + where: { + removed: false, + [Op.or]: [{ filePath: { [Op.startsWith]: prefix } }, { audioFilePath: { [Op.startsWith]: prefix } }], + }, + attributes: VIDEO_ATTRIBUTES, + raw: true, + }); + const videos = []; + for (const video of rows) { + const located = resolveLibraryFolder(mediaPathOf(video), base); + if (located && folderKey(located.libraryFolder) === folderKey(name)) { + videos.push({ ...video, libraryRoot: located.libraryRoot }); + } + } + return videos; +} + +async function countMembers(Model, groupField, idField, groupIds, videoIds) { + const counts = new Map(); + if (groupIds.length === 0) return counts; + const rows = await Model.findAll({ where: { [groupField]: groupIds }, attributes: [groupField, idField], raw: true }); + for (const row of rows) { + if (videoIds.has(row[idField])) counts.set(row[groupField], (counts.get(row[groupField]) || 0) + 1); + } + return counts; +} + +function latestDownload(videos) { + const timeOf = (video) => (video.last_downloaded_at ? new Date(video.last_downloaded_at).getTime() : -Infinity); + return videos.reduce((best, video) => { + if (!best) return video; + if (timeOf(video) !== timeOf(best)) return timeOf(video) > timeOf(best) ? video : best; + return video.id > best.id ? video : best; + }, null); +} + +async function exampleOf(video) { + if (!video) return null; + const time = releaseTime(await videoInfoStore.readInfoOrFallback(video)); + return { + channelName: video.youTubeChannelName || '', + title: video.youTubeVideoName || '', + youtubeId: video.youtubeId, + uploadedAt: time ? new Date(time.epochSeconds * 1000).toISOString() : null, + uploadedAtSource: time ? time.source : null, + relativePath: path.relative(video.libraryRoot, mediaPathOf(video)).split(path.sep).join('/'), + }; +} + +/** + * @param {string} routeKey - the subfolder name, or '~main' + */ +async function getFolderDetail(routeKey) { + const name = await resolveFolderName(routeKey); + if (name === null) throw guardError('Library folder not found', 404); + const key = folderKey(name); + const [layoutOf, channels, playlists, shows, videos] = await Promise.all([ + getLayoutResolver(), + Channel.findAll({ where: { enabled: true }, attributes: ['channel_id', 'title', 'uploader', 'sub_folder'], raw: true }), + Playlist.findAll({ where: { enabled: true }, attributes: ['playlist_id', 'title', 'default_sub_folder'], raw: true }), + TvShow.findAll({ + where: { kind: KIND_TITLE_SHOW, retired_at: null }, + attributes: ['id', 'name', 'channel_id', 'library_folder'], + raw: true, + }), + videosInFolder(name), + ]); + + const videoIds = new Set(videos.map((video) => video.youtubeId)); + const perChannel = new Map(); + for (const video of videos) perChannel.set(video.channel_id, (perChannel.get(video.channel_id) || 0) + 1); + + const folderPlaylists = playlists.filter((playlist) => folderKey(effectiveLibraryFolder(playlist.default_sub_folder)) === key); + const folderShows = shows.filter((show) => folderKey(show.library_folder) === key); + const showChannelIds = [...new Set(folderShows.map((show) => show.channel_id))]; + const [perPlaylist, perShow, showChannels] = await Promise.all([ + countMembers(PlaylistVideo, 'playlist_id', 'youtube_id', folderPlaylists.map((p) => p.playlist_id), videoIds), + countMembers(VideoClassification, 'show_id', 'youtube_id', folderShows.map((s) => s.id), videoIds), + showChannelIds.length + ? Channel.findAll({ where: { channel_id: showChannelIds }, attributes: ['channel_id', 'title', 'uploader'], raw: true }) + : [], + ]); + const showChannelNames = new Map(showChannels.map((channel) => [channel.channel_id, channelName(channel)])); + + const isDefault = key === folderKey(configModule.getDefaultSubfolder()); + const followerNames = isDefault + ? channels.filter((channel) => channel.sub_folder === GLOBAL_DEFAULT_SENTINEL).map(channelName).sort(byName) + : []; + + return { + name, + layout: layoutOf(name), + channels: channels + .filter((channel) => { + const chosen = chosenFolderOf(channel.sub_folder); + return chosen !== null && folderKey(chosen) === key; + }) + .map((channel) => ({ + channelId: channel.channel_id, name: channelName(channel), videoCount: perChannel.get(channel.channel_id) || 0, + })) + .sort((a, b) => byName(a.name, b.name)), + followers: { count: followerNames.length, sample: followerNames.slice(0, FOLLOWER_SAMPLE_SIZE) }, + playlists: folderPlaylists + .map((playlist) => ({ + playlistId: playlist.playlist_id, + name: playlist.title || playlist.playlist_id, + videoCount: perPlaylist.get(playlist.playlist_id) || 0, + })) + .sort((a, b) => byName(a.name, b.name)), + titleShows: folderShows + .map((show) => ({ + id: show.id, + name: show.name, + channelId: show.channel_id, + channelName: showChannelNames.get(show.channel_id) || show.channel_id, + episodeCount: perShow.get(show.id) || 0, + })) + .sort((a, b) => byName(a.name, b.name)), + example: await exampleOf(latestDownload(videos)), + }; +} + +module.exports = { MAIN_FOLDER_KEY, getFolderDetail }; diff --git a/server/modules/tvShows/folderUsage.js b/server/modules/tvShows/folderUsage.js new file mode 100644 index 000000000..cf1691f48 --- /dev/null +++ b/server/modules/tvShows/folderUsage.js @@ -0,0 +1,128 @@ +/** + * What uses each library folder, for the Library folders page and the Core + * card (GET /api/library-folders?include=usage,files): the enabled channels + * that chose the folder or follow the default into it, its playlists and + * title shows, whether a layout change or a default switch goes through the + * reorganize (the rules setFolderLayout and checkDefaultSubfolderChange + * apply, disabled channels included), the Plex refresh mapping, every reason + * a delete is refused, and how many downloaded videos sit in the folder. + */ + +const Channel = require('../../models/channel'); +const Playlist = require('../../models/playlist'); +const TvShow = require('../../models/tvshow'); +const Video = require('../../models/video'); +const configModule = require('../configModule'); +const subfolderModule = require('../subfolderModule'); +const { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } = require('../filesystem/constants'); +const { resolveLibraryFolder } = require('../filesystem/showFolderCleanup'); +const { readMappings, mappingOf } = require('../mediaServers/plexMappingEntries'); +const { folderKey, KIND_TITLE_SHOW } = require('./constants'); +const { effectiveLibraryFolder } = require('./channelFolders'); +const { getLayoutResolver } = require('./libraryLayouts'); +const layoutGuards = require('./layoutGuards'); + +/** + * The folder a sub_folder value names: '' for the main folder, null for a + * value that follows the default folder. + * @param {string|null} subFolderValue + * @returns {string|null} + */ +function chosenFolderOf(subFolderValue) { + if (subFolderValue === GLOBAL_DEFAULT_SENTINEL) return null; + if (subFolderValue === ROOT_SENTINEL || typeof subFolderValue !== 'string') return ''; + return subFolderValue.trim(); +} + +function choosesFolder(subFolderValue, key) { + const chosen = chosenFolderOf(subFolderValue); + return chosen !== null && folderKey(chosen) === key; +} + +async function loadUsageData() { + const [channels, playlists, titleShows, numberedShows, withDownloads, layoutOf] = await Promise.all([ + Channel.findAll({ attributes: ['channel_id', 'sub_folder', 'enabled'], raw: true }), + Playlist.findAll({ attributes: ['default_sub_folder', 'enabled'], raw: true }), + TvShow.findAll({ where: { kind: KIND_TITLE_SHOW, retired_at: null }, attributes: ['library_folder'], raw: true }), + subfolderModule.numberedShowCounts(), + layoutGuards.channelIdsWithDownloads(), + getLayoutResolver(), + ]); + return { + channels, + playlists, + titleShows, + numberedShows, + withDownloads, + layoutOf, + defaultFolder: configModule.getDefaultSubfolder() || '', + mappings: readMappings(configModule.getConfig()), + }; +} + +function usageFields(folder, data) { + const key = folderKey(folder.name); + const isDefaultFolder = key === folderKey(data.defaultFolder); + const hasDownloads = (channel) => data.withDownloads.has(channel.channel_id); + const choosers = data.channels.filter((channel) => choosesFolder(channel.sub_folder, key)); + const channelsChosen = choosers.filter((channel) => channel.enabled).length; + const followers = data.channels.filter((channel) => channel.sub_folder === GLOBAL_DEFAULT_SENTINEL); + const users = data.channels.filter((channel) => folderKey(effectiveLibraryFolder(channel.sub_folder)) === key); + const deleteBlockers = folder.name + ? subfolderModule.deletionBlockers({ + channels: channelsChosen, + disabledChannels: choosers.length - channelsChosen, + playlists: data.playlists.filter((playlist) => choosesFolder(playlist.default_sub_folder, key)).length, + shows: data.numberedShows.get(key) || 0, + isDefault: folder.isDefault, + hasFiles: folder.hasFiles, + }) + : [{ code: 'main' }]; + return { + channelsChosen, + channelsFollowing: isDefaultFolder ? followers.filter((channel) => channel.enabled).length : 0, + playlists: data.playlists.filter((playlist) => playlist.enabled + && folderKey(effectiveLibraryFolder(playlist.default_sub_folder)) === key).length, + titleShows: data.titleShows.filter((show) => folderKey(show.library_folder) === key).length, + layoutChangeNeedsReview: Boolean(folder.hasFiles) || users.some(hasDownloads), + makeDefaultNeedsReview: !isDefaultFolder + && data.layoutOf(folder.name) !== data.layoutOf(data.defaultFolder) + && followers.some(hasDownloads), + plexMapping: mappingOf(data.mappings, folder.name), + deleteBlockers, + deletable: deleteBlockers.length === 0, + }; +} + +// One scan of the videos table (no index on filePath), bucketed here. +async function countFilesByFolder() { + const videos = await Video.findAll({ where: { removed: false }, attributes: ['filePath', 'audioFilePath'], raw: true }); + const counts = new Map(); + for (const video of videos) { + const located = resolveLibraryFolder(video.filePath || video.audioFilePath, configModule.directoryPath); + if (!located) continue; + const key = folderKey(located.libraryFolder); + counts.set(key, (counts.get(key) || 0) + 1); + } + return counts; +} + +/** + * @param {Array<Object>} folders - listLibraryFolders() entries + * @param {{usage?: boolean, files?: boolean}} include + * @returns {Promise<Array<Object>>} + */ +async function describeUsage(folders, { usage = false, files = false } = {}) { + if (!usage && !files) return folders; + const [data, counts] = await Promise.all([ + usage ? loadUsageData() : null, + files ? countFilesByFolder() : null, + ]); + return folders.map((folder) => ({ + ...folder, + ...(data ? usageFields(folder, data) : {}), + ...(counts ? { fileCount: counts.get(folderKey(folder.name)) || 0 } : {}), + })); +} + +module.exports = { chosenFolderOf, describeUsage }; diff --git a/server/modules/tvShows/layoutGuards.js b/server/modules/tvShows/layoutGuards.js index 800ce2602..e94240dd9 100644 --- a/server/modules/tvShows/layoutGuards.js +++ b/server/modules/tvShows/layoutGuards.js @@ -75,6 +75,30 @@ async function channelHasDownloads(channelId) { return (await Video.count({ where: { youtubeId: youtubeIds, removed: false } })) > 0; } +/** + * Ids of the channels with downloaded videos: channelHasDownloads for every + * channel at once (their own videos, plus the episodes routed to their show + * from another uploader's id). + * @returns {Promise<Set<string>>} + */ +async function channelIdsWithDownloads() { + const [owned, classified] = await Promise.all([ + Video.findAll({ where: { removed: false }, attributes: ['channel_id'], group: ['channel_id'], raw: true }), + VideoClassification.findAll({ attributes: ['channel_id', 'youtube_id'], raw: true }), + ]); + const ids = new Set(owned.map((row) => row.channel_id).filter(Boolean)); + const pending = classified.filter((row) => !ids.has(row.channel_id)); + if (pending.length === 0) return ids; + const present = await Video.findAll({ + where: { youtubeId: pending.map((row) => row.youtube_id), removed: false }, attributes: ['youtubeId'], raw: true, + }); + const presentIds = new Set(present.map((row) => row.youtubeId)); + for (const row of pending) { + if (presentIds.has(row.youtube_id)) ids.add(row.channel_id); + } + return ids; +} + // Downloaded files directly in the main folder; __subfolders, the local temp // folder and dotfiles are not the main folder's content. async function mainFolderHasFiles(baseDir) { @@ -195,6 +219,7 @@ module.exports = { isMp3Format, assertNoDownloadRunning, channelHasDownloads, + channelIdsWithDownloads, mainFolderHasFiles, folderHasFiles, usersOfFolder, diff --git a/server/modules/tvShows/libraryFolders.js b/server/modules/tvShows/libraryFolders.js index 0b7b28f5b..a0682a772 100644 --- a/server/modules/tvShows/libraryFolders.js +++ b/server/modules/tvShows/libraryFolders.js @@ -13,10 +13,13 @@ const configModule = require('../configModule'); const subfolderModule = require('../subfolderModule'); const logger = require('../../logger'); const Channel = require('../../models/channel'); +const Subfolder = require('../../models/subfolder'); +const { buildSubfolderSegment, directoryHasFiles, ensureDir } = require('../filesystem'); const { LAYOUT_TV, LAYOUT_VIDEOS, folderKey } = require('./constants'); const libraryLayouts = require('./libraryLayouts'); const layoutGuards = require('./layoutGuards'); const { effectiveLibraryFolder } = require('./channelFolders'); +const folderUsage = require('./folderUsage'); const reorganizeLock = require('../reorganize/reorganizeLock'); const { CHANGE_FOLDER_LAYOUT, CHANGE_DEFAULT_SUBFOLDER } = require('../reorganize/constants'); @@ -29,18 +32,20 @@ const PLEXIGNORE_CONTENT = `${PLEXIGNORE_SUBFOLDER_RULE}\n`; const MESSAGES = { running: 'Wait for the current download to finish before changing a folder\'s layout.', reorganize: 'This folder holds downloaded videos, so changing its layout moves them. Review the move first.', - defaultRunning: 'Wait for the current download to finish before switching the default subfolder to a folder ' + defaultRunning: 'Wait for the current download to finish before switching the default folder to a folder ' + 'with a different layout.', - defaultReorganize: 'Channels that use the default subfolder have downloaded videos, so switching the default to a ' + defaultReorganize: 'Channels that use the default folder have downloaded videos, so switching the default to a ' + 'folder with a different layout moves them. Review the move first.', reorganizing: 'Downloads are being reorganized. Change folder layouts when that finishes.', }; /** + * @param {Object} [options] + * @param {Array<'usage'|'files'>} [options.include] - add usage fields / the downloaded video count * @returns {Promise<Array<{name: string, layout: string, isDefault: boolean, hasFiles: boolean, channels: number}>>} * name '' is the main folder, listed first */ -async function listLibraryFolders() { +async function listLibraryFolders({ include = [] } = {}) { const [layoutOf, usage, channels] = await Promise.all([ libraryLayouts.getLayoutResolver(), subfolderModule.getUsage(), @@ -66,7 +71,9 @@ async function listLibraryFolders() { hasFiles: item.usage.hasFiles, channels: channelCounts.get(folderKey(item.name)) || 0, })); - return [main, ...subfolders]; + const folders = [main, ...subfolders]; + if (include.length === 0) return folders; + return folderUsage.describeUsage(folders, { usage: include.includes('usage'), files: include.includes('files') }); } async function syncPlexIgnore(layout) { @@ -141,6 +148,49 @@ async function setFolderLayout(name, layout, { isDownloadRunning } = {}) { return { changed: true }; } +/** + * Create a library folder: its __ directory on disk, then its registry row + * with a layout. A new, empty folder nothing uses yet takes the layout in its + * insert; anything else (files already in the directory, channels or + * playlists already pointing at the name, an existing row with another + * layout) is registered as Videos and switched through setFolderLayout, whose + * guards send downloaded videos to the reorganize. + * + * @param {string} name - subfolder name without __, already validated + * @param {string|null} layout - 'videos' | 'tv'; null keeps an existing folder's layout (Videos for a new one) + * @param {Object} [options] + * @param {() => boolean} [options.isDownloadRunning] + */ +async function createLibraryFolder(name, layout, { isDownloadRunning } = {}) { + const clean = String(name || '').trim(); + const existing = await Subfolder.findOne({ where: { name: clean }, attributes: ['name', 'layout'] }); + if (existing) { + const stored = existing.layout === LAYOUT_TV ? LAYOUT_TV : LAYOUT_VIDEOS; + if (!layout || layout === stored) return { name: existing.name, layout: stored, created: false }; + await setFolderLayout(existing.name, layout, { isDownloadRunning }); + return { name: existing.name, layout, created: false }; + } + + const directory = path.join(configModule.directoryPath, buildSubfolderSegment(clean)); + try { + await ensureDir(directory); + } catch (err) { + logger.error({ err, libraryFolder: clean }, 'Could not create a library folder on disk'); + throw layoutGuards.guardError(`Couldn't create the folder on disk: ${err.message}`, 500); + } + const existingContent = await directoryHasFiles(directory); + const users = await layoutGuards.usersOfFolder(clean); + const inUse = users.channels.length > 0 || users.playlists.length > 0; + const tvAtOnce = layout === LAYOUT_TV && !existingContent && !inUse; + const row = await subfolderModule.register(clean, { layout: tvAtOnce ? LAYOUT_TV : LAYOUT_VIDEOS, throwOnError: true }); + logger.info({ libraryFolder: row.name, layout: row.layout, existingContent }, 'Library folder created'); + if (layout === LAYOUT_TV && row.layout !== LAYOUT_TV) { + await setFolderLayout(row.name, LAYOUT_TV, { isDownloadRunning }); + return { name: row.name, layout: LAYOUT_TV, created: row.created, existingContent }; + } + return { name: row.name, layout: row.layout, created: row.created, existingContent }; +} + /** * Check a default subfolder change that switches the channels and playlists * following the default between videos and TV: refused while a reorganize @@ -164,7 +214,7 @@ async function checkDefaultSubfolderChange({ oldDefault, newDefault, isDownloadR if (layoutOf(before) === newLayout) return; const users = await layoutGuards.usersOfGlobalDefault(); - if (newLayout === LAYOUT_TV) layoutGuards.assertNoMp3Users(users, 'the default subfolder'); + if (newLayout === LAYOUT_TV) layoutGuards.assertNoMp3Users(users, 'the default folder'); for (const channel of users.channels) { if (await layoutGuards.channelHasDownloads(channel.channel_id)) { throw layoutGuards.reorganizeRequiredError(MESSAGES.defaultReorganize, { @@ -175,6 +225,34 @@ async function checkDefaultSubfolderChange({ oldDefault, newDefault, isDownloadR layoutGuards.assertNoDownloadRunning(isDownloadRunning, MESSAGES.defaultRunning); } +/** + * Make a library folder the default folder (the folder channels set to the + * default download to, and the fallback for downloads with no more specific + * folder). The only writer of defaultSubfolder: a same-layout switch saves at + * once (downloaded videos stay where they are), a layout-changing one goes + * through checkDefaultSubfolderChange. + * + * @param {string} name - '' for the main folder, else a known subfolder name + * @param {Object} [options] + * @param {() => boolean} [options.isDownloadRunning] + * @returns {Promise<{changed: boolean, defaultSubfolder: string}>} + */ +async function setDefaultFolder(name, { isDownloadRunning } = {}) { + const requested = String(name || '').trim(); + let target = ''; + if (requested) { + const names = (await subfolderModule.getAll()).map((display) => display.replace(/^__/, '')); + target = names.find((known) => folderKey(known) === folderKey(requested)); + if (target === undefined) throw layoutGuards.guardError('Library folder not found', 404); + } + const current = configModule.getDefaultSubfolder() || ''; + if (folderKey(current) === folderKey(target)) return { changed: false, defaultSubfolder: current }; + await checkDefaultSubfolderChange({ oldDefault: current, newDefault: target, isDownloadRunning }); + configModule.updateConfig({ ...configModule.getConfig(), defaultSubfolder: target }); + logger.info({ previous: current, defaultSubfolder: target }, 'Default folder changed'); + return { changed: true, defaultSubfolder: target }; +} + module.exports = { PLEXIGNORE_NAME, PLEXIGNORE_CONTENT, @@ -182,5 +260,7 @@ module.exports = { syncPlexIgnore, listLibraryFolders, setFolderLayout, - checkDefaultSubfolderChange + createLibraryFolder, + checkDefaultSubfolderChange, + setDefaultFolder }; diff --git a/server/routes/__tests__/subfolders.test.js b/server/routes/__tests__/subfolders.test.js index da408a244..9a4d183c9 100644 --- a/server/routes/__tests__/subfolders.test.js +++ b/server/routes/__tests__/subfolders.test.js @@ -7,20 +7,32 @@ jest.mock('../../logger', () => ({ error: jest.fn(), info: jest.fn(), warn: jest describe('Subfolder routes', () => { let app; let mockSubfolderModule; + let libraryFolders; + let layoutGuards; + let jobModule; beforeEach(() => { jest.resetModules(); mockSubfolderModule = { - register: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), getUsage: jest.fn().mockResolvedValue([]), }; + libraryFolders = { + createLibraryFolder: jest.fn().mockResolvedValue({ name: 'Sports', layout: 'videos', created: true, existingContent: false }), + }; + layoutGuards = { + errorBody: (e) => ({ error: e.message, ...(e.reorganizeRequired ? { reorganizeRequired: true, change: e.change } : {}) }), + }; + jobModule = { getInProgressJobId: jest.fn().mockReturnValue(null) }; const createSubfolderRoutes = require('../subfolders'); app = express(); app.use(express.json()); app.use(createSubfolderRoutes({ verifyToken: (req, res, next) => next(), subfolderModule: mockSubfolderModule, + libraryFolders, + layoutGuards, + jobModule, })); }); @@ -46,18 +58,53 @@ describe('Subfolder routes', () => { }); describe('POST /api/subfolders', () => { - test('registers a valid name and returns 200', async () => { + test('creates a folder with a layout and answers 201', async () => { + const res = await request(app).post('/api/subfolders').send({ name: ' Sports ', layout: 'tv' }); + expect(res.status).toBe(201); + expect(libraryFolders.createLibraryFolder).toHaveBeenCalledWith('Sports', 'tv', { isDownloadRunning: expect.any(Function) }); + }); + + test('answers 200 for a folder that existed', async () => { + libraryFolders.createLibraryFolder.mockResolvedValueOnce({ name: 'Sports', layout: 'videos', created: false }); const res = await request(app).post('/api/subfolders').send({ name: 'Sports' }); expect(res.status).toBe(200); - expect(res.body).toEqual({ name: 'Sports' }); - expect(mockSubfolderModule.register).toHaveBeenCalledWith('Sports'); + expect(libraryFolders.createLibraryFolder).toHaveBeenCalledWith('Sports', null, expect.any(Object)); + }); + + test('rejects the reserved playlists name and an unknown layout', async () => { + expect((await request(app).post('/api/subfolders').send({ name: 'Playlists' })).status).toBe(400); + expect((await request(app).post('/api/subfolders').send({ name: 'X', layout: 'music' })).status).toBe(400); + }); + + test('passes a reorganize refusal through with its change', async () => { + libraryFolders.createLibraryFolder.mockRejectedValueOnce(Object.assign(new Error('Review the move'), { + status: 409, reorganizeRequired: true, change: { type: 'folderLayout', folder: 'Old', layout: 'tv' }, + })); + const res = await request(app).post('/api/subfolders').send({ name: 'Old', layout: 'tv' }); + expect(res.status).toBe(409); + expect(res.body.change).toEqual({ type: 'folderLayout', folder: 'Old', layout: 'tv' }); + }); + + test('reports a directory that could not be created', async () => { + libraryFolders.createLibraryFolder.mockRejectedValueOnce(Object.assign( + new Error('Couldn\'t create the folder on disk: EACCES'), { status: 500 } + )); + const res = await request(app).post('/api/subfolders').send({ name: 'X' }); + expect(res.body).toEqual({ error: 'Couldn\'t create the folder on disk: EACCES' }); + }); + + test('answers 500 when saving fails unexpectedly', async () => { + libraryFolders.createLibraryFolder.mockRejectedValueOnce(new Error('db down')); + const res = await request(app).post('/api/subfolders').send({ name: 'X' }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to create the folder' }); }); test('rejects an empty name with 400', async () => { const res = await request(app).post('/api/subfolders').send({ name: ' ' }); expect(res.status).toBe(400); expect(res.body.error).toBeDefined(); - expect(mockSubfolderModule.register).not.toHaveBeenCalled(); + expect(libraryFolders.createLibraryFolder).not.toHaveBeenCalled(); }); test('rejects a sentinel name with 400', async () => { diff --git a/server/routes/__tests__/tvShows.test.js b/server/routes/__tests__/tvShows.test.js index 0bf4e8b3b..9e0bfd332 100644 --- a/server/routes/__tests__/tvShows.test.js +++ b/server/routes/__tests__/tvShows.test.js @@ -17,6 +17,8 @@ describe('TV show routes', () => { let models; let reorganize; let libraryCheck; + let folderDetail; + let plexRefreshMappings; const channel = { channel_id: 'UC1', sub_folder: 'Kids' }; const REORGANIZE_STATE = { running: false, unmoved: null }; @@ -25,6 +27,7 @@ describe('TV show routes', () => { libraryFolders = { listLibraryFolders: jest.fn().mockResolvedValue([{ name: '', layout: 'videos' }]), setFolderLayout: jest.fn().mockResolvedValue({ changed: true }), + setDefaultFolder: jest.fn().mockResolvedValue({ changed: true, defaultSubfolder: 'TV' }), }; channelLayout = { getChannelTvState: jest.fn().mockResolvedValue({ layout: 'tv' }), @@ -40,6 +43,11 @@ describe('TV show routes', () => { check: jest.fn().mockResolvedValue({ servers: [], folders: [] }), applyPlexMapping: jest.fn().mockResolvedValue({ mappedLibraryId: '41' }), }; + folderDetail = { getFolderDetail: jest.fn().mockResolvedValue({ name: 'check' }) }; + plexRefreshMappings = { + setMapping: jest.fn().mockResolvedValue({ choice: 'library' }), + removeMapping: jest.fn().mockResolvedValue({ choice: 'none' }), + }; const createTvShowRoutes = require('../tvShows'); app = express(); app.use(express.json()); @@ -51,6 +59,7 @@ describe('TV show routes', () => { errorBody: (error) => ({ error: error.message, ...(error.reorganizeRequired ? { reorganizeRequired: true, change: error.change } : {}), + ...(error.code ? { code: error.code } : {}), }), }, reorganize, @@ -58,6 +67,8 @@ describe('TV show routes', () => { jobModule, models, libraryCheck, + folderDetail, + plexRefreshMappings, })); }); @@ -137,6 +148,62 @@ describe('TV show routes', () => { }); }); + describe('plex-mapping replace and delete', () => { + test('replace sets any folder, the main folder included', async () => { + await request(app).put('/api/library-folders/plex-mapping').send({ folder: '', libraryId: '37', replace: true }); + expect(plexRefreshMappings.setMapping).toHaveBeenCalledWith('', '37'); + }); + + test('replace accepts the explicit default choice', async () => { + await request(app).put('/api/library-folders/plex-mapping').send({ folder: 'TV', libraryId: null, replace: true }); + expect(plexRefreshMappings.setMapping).toHaveBeenCalledWith('TV', null); + }); + + test('replace rejects a non-numeric library id', async () => { + const res = await request(app).put('/api/library-folders/plex-mapping').send({ folder: 'TV', libraryId: 'abc', replace: true }); + expect(res.status).toBe(400); + }); + + test('without replace keeps today\'s applyPlexMapping', async () => { + await request(app).put('/api/library-folders/plex-mapping').send({ folder: 'TV', libraryId: '41' }); + expect(libraryCheck.applyPlexMapping).toHaveBeenCalledWith('TV', '41'); + expect(plexRefreshMappings.setMapping).not.toHaveBeenCalled(); + }); + + test('delete removes a folder\'s entry, the main folder included', async () => { + const res = await request(app).delete('/api/library-folders/plex-mapping?folder='); + expect(res.status).toBe(200); + expect(plexRefreshMappings.removeMapping).toHaveBeenCalledWith(''); + }); + + test('delete requires the folder parameter', async () => { + const res = await request(app).delete('/api/library-folders/plex-mapping'); + expect(res.status).toBe(400); + }); + }); + + describe('GET /api/library-folders/folder/:key', () => { + test('answers a folder named check (outside the /check path)', async () => { + const res = await request(app).get('/api/library-folders/folder/check'); + expect(res.status).toBe(200); + expect(folderDetail.getFolderDetail).toHaveBeenCalledWith('check'); + }); + + test('decodes the key and passes the main folder key as is', async () => { + await request(app).get('/api/library-folders/folder/Science%20Shows'); + expect(folderDetail.getFolderDetail).toHaveBeenCalledWith('Science Shows'); + await request(app).get('/api/library-folders/folder/~main'); + expect(folderDetail.getFolderDetail).toHaveBeenLastCalledWith('~main'); + }); + + test('answers 404 with the module\'s message', async () => { + folderDetail.getFolderDetail.mockRejectedValueOnce(refusal('Library folder not found', 404)); + const res = await request(app).get('/api/library-folders/folder/Nope'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Library folder not found' }); + }); + }); + describe('GET /api/library-folders', () => { test('returns the folder list', async () => { const res = await request(app).get('/api/library-folders'); @@ -149,6 +216,21 @@ describe('TV show routes', () => { const res = await request(app).get('/api/library-folders'); expect(res.status).toBe(500); }); + + test('passes include to the module', async () => { + await request(app).get('/api/library-folders?include=usage,files'); + expect(libraryFolders.listLibraryFolders).toHaveBeenCalledWith({ include: ['usage', 'files'] }); + }); + + test('keeps the plain call without include', async () => { + await request(app).get('/api/library-folders'); + expect(libraryFolders.listLibraryFolders).toHaveBeenCalledWith(); + }); + + test('rejects an unknown include', async () => { + const res = await request(app).get('/api/library-folders?include=everything'); + expect(res.status).toBe(400); + }); }); describe('PUT /api/library-folders', () => { @@ -196,6 +278,43 @@ describe('TV show routes', () => { }); }); + describe('PUT /api/library-folders/default', () => { + test('saves and answers with the folders', async () => { + const res = await request(app).put('/api/library-folders/default').send({ name: 'TV' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ changed: true, defaultSubfolder: 'TV', folders: [{ name: '', layout: 'videos' }] }); + expect(libraryFolders.setDefaultFolder).toHaveBeenCalledWith('TV', { isDownloadRunning: expect.any(Function) }); + }); + + test('answers unchanged', async () => { + libraryFolders.setDefaultFolder.mockResolvedValueOnce({ changed: false, defaultSubfolder: 'TV' }); + const res = await request(app).put('/api/library-folders/default').send({ name: 'TV' }); + expect(res.body.changed).toBe(false); + }); + + test('passes a reorganize refusal with its change', async () => { + libraryFolders.setDefaultFolder.mockRejectedValueOnce(Object.assign(refusal('Review the move first.', 409), { + reorganizeRequired: true, change: { type: 'defaultSubfolder', value: 'TV' }, + })); + const res = await request(app).put('/api/library-folders/default').send({ name: 'TV' }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'Review the move first.', reorganizeRequired: true, change: { type: 'defaultSubfolder', value: 'TV' } }); + }); + + test('passes the reorganize-running code', async () => { + libraryFolders.setDefaultFolder.mockRejectedValueOnce(Object.assign(refusal('Downloads are being reorganized.', 409), { + code: 'REORGANIZE_RUNNING', + })); + const res = await request(app).put('/api/library-folders/default').send({ name: 'TV' }); + expect(res.body.code).toBe('REORGANIZE_RUNNING'); + }); + + test('rejects an invalid name', async () => { + const res = await request(app).put('/api/library-folders/default').send({ name: 'bad/name' }); + expect(res.status).toBe(400); + }); + }); + describe('GET /api/channels/:channelId/tv', () => { test('returns the channel TV state with its reorganize state', async () => { const res = await request(app).get('/api/channels/UC1/tv'); diff --git a/server/routes/channels.js b/server/routes/channels.js index 898c1270a..9a1c5e438 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -774,7 +774,7 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * /api/channels/using-global-file-structure: * get: * summary: Get channels using the global file structure setting - * description: Get count and names of enabled channels that inherit the global flat-folder-structure default (no per-channel override). + * description: Count and names of enabled channels that inherit the global flat-folder-structure default (no per-channel override) and download to a Videos folder. Channels in TV shows folders are left out; their episodes always go into Season folders. * tags: [Channels] * responses: * 200: diff --git a/server/routes/index.js b/server/routes/index.js index 708c3d088..66261a410 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -47,6 +47,8 @@ const cookieTest = require('../modules/cookieTest'); const logger = require('../logger'); const logFilesModule = require('../modules/logFilesModule'); const libraryFolders = require('../modules/tvShows/libraryFolders'); +const folderDetail = require('../modules/tvShows/folderDetail'); +const plexRefreshMappings = require('../modules/mediaServers/plexRefreshMappings'); const channelLayout = require('../modules/tvShows/channelLayout'); const layoutGuards = require('../modules/tvShows/layoutGuards'); const titleShowService = require('../modules/tvShows/titleShowService'); @@ -153,7 +155,7 @@ function registerRoutes(app, deps) { })); // Subfolder registry routes - app.use(createSubfolderRoutes({ verifyToken, subfolderModule })); + app.use(createSubfolderRoutes({ verifyToken, subfolderModule, libraryFolders, layoutGuards, jobModule })); // Scheduled task status routes app.use(createSchedulesRoutes({ verifyToken, scheduledTaskManager, scheduledTaskRuns, scheduleConfig })); @@ -164,7 +166,7 @@ function registerRoutes(app, deps) { // TV show layouts (library folders and channel layouts) app.use(createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, layoutGuards, reorganize, channelSettingsModule, jobModule, models, - libraryCheck, + libraryCheck, folderDetail, plexRefreshMappings, })); // Title shows (shows defined on a channel by title patterns) and episode assignment diff --git a/server/routes/plex.js b/server/routes/plex.js index 865b0512c..29d72ed50 100644 --- a/server/routes/plex.js +++ b/server/routes/plex.js @@ -50,12 +50,20 @@ module.exports = function createPlexRoutes({ verifyToken, plexModule, configModu * items: * type: object * properties: - * key: + * id: * type: string * title: * type: string * type: * type: string + * description: Plex section type (movie, show, artist, photo) + * locations: + * type: array + * items: + * type: object + * properties: + * id: { type: integer } + * path: { type: string } */ router.get('/getplexlibraries', verifyToken, async (req, res) => { try { diff --git a/server/routes/subfolders.js b/server/routes/subfolders.js index 9c11c99c4..44f04bde3 100644 --- a/server/routes/subfolders.js +++ b/server/routes/subfolders.js @@ -3,14 +3,19 @@ const logger = require('../logger'); const { validateSubFolderName } = require('../modules/filesystem/subfolderValidation'); const { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } = require('../modules/filesystem/constants'); +const LAYOUTS = new Set(['videos', 'tv']); + /** * Subfolder registry routes (session-auth only). * @param {Object} deps * @param {Function} deps.verifyToken * @param {Object} deps.subfolderModule + * @param {Object} deps.libraryFolders - tvShows/libraryFolders (creates folders with a layout) + * @param {Object} deps.layoutGuards - tvShows/layoutGuards (refusal bodies) + * @param {Object} deps.jobModule - Its running job blocks layout switches * @returns {express.Router} */ -function createSubfolderRoutes({ verifyToken, subfolderModule }) { +function createSubfolderRoutes({ verifyToken, subfolderModule, libraryFolders, layoutGuards, jobModule }) { const router = express.Router(); /** @@ -37,8 +42,8 @@ function createSubfolderRoutes({ verifyToken, subfolderModule }) { * @swagger * /api/subfolders: * post: - * summary: Create (register) a subfolder - * description: Persists a subfolder name so it is available in every picker. + * summary: Create a library folder + * description: Creates the __name directory in the downloads folder and registers the folder with a layout (Videos when omitted for a new name; an existing folder keeps its layout unless one is given). A directory that already holds files is registered as Videos (existingContent true); asking for TV there, or for a different layout of an existing folder that holds downloads, answers 409 reorganizeRequired with the folderLayout change to review. The folder pickers' inline add sends no layout. * tags: [Subfolders] * requestBody: * required: true @@ -46,30 +51,41 @@ function createSubfolderRoutes({ verifyToken, subfolderModule }) { * application/json: * schema: * type: object + * required: [name] * properties: * name: { type: string } + * layout: { type: string, enum: [videos, tv] } * responses: - * 200: { description: Registered } - * 400: { description: Invalid name } + * 201: { description: "Created: { name, layout, created: true, existingContent }" } + * 200: { description: "Already existed: { name, layout, created: false }" } + * 400: { description: Invalid name (including the reserved playlists) or layout } + * 409: { description: "Its downloads must move (reorganizeRequired, with change), or a download, a reorganize or MP3 channels refuse the layout" } + * 500: { description: "Couldn't create the directory, or the folder couldn't be saved" } */ router.post('/api/subfolders', verifyToken, async (req, res) => { + const { name, layout } = req.body || {}; + if (typeof name !== 'string' || name.trim() === '') { + return res.status(400).json({ error: 'Subfolder name is required' }); + } + if (name === GLOBAL_DEFAULT_SENTINEL || name === ROOT_SENTINEL) { + return res.status(400).json({ error: 'Invalid subfolder name' }); + } + const validation = validateSubFolderName(name); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); + } + if (layout !== undefined && layout !== null && !LAYOUTS.has(layout)) { + return res.status(400).json({ error: 'layout must be "videos" or "tv"' }); + } try { - const { name } = req.body || {}; - if (typeof name !== 'string' || name.trim() === '') { - return res.status(400).json({ error: 'Subfolder name is required' }); - } - if (name === GLOBAL_DEFAULT_SENTINEL || name === ROOT_SENTINEL) { - return res.status(400).json({ error: 'Invalid subfolder name' }); - } - const validation = validateSubFolderName(name); - if (!validation.valid) { - return res.status(400).json({ error: validation.error }); - } - await subfolderModule.register(name.trim()); - return res.json({ name: name.trim() }); + const result = await libraryFolders.createLibraryFolder(name.trim(), layout || null, { + isDownloadRunning: () => Boolean(jobModule.getInProgressJobId()), + }); + return res.status(result.created ? 201 : 200).json(result); } catch (error) { - logger.error({ err: error }, 'Failed to create subfolder'); - return res.status(500).json({ error: 'Failed to create subfolder' }); + if (error.status) return res.status(error.status).json(layoutGuards.errorBody(error)); + logger.error({ err: error, name }, 'Failed to create the folder'); + return res.status(500).json({ error: 'Failed to create the folder' }); } }); @@ -78,7 +94,7 @@ function createSubfolderRoutes({ verifyToken, subfolderModule }) { * /api/subfolders/{name}: * delete: * summary: Delete a subfolder - * description: Deletes a subfolder only when it is empty on disk and unused by any channel, playlist, the global default, or a Plex mapping. + * description: Deletes a library folder only when it is empty on disk and no channel (enabled or not), playlist or numbered TV show uses it and it is not the default folder. Its Plex refresh mapping is removed with it. A folder known only from a Plex mapping is deleted too. * tags: [Subfolders] * parameters: * - in: path diff --git a/server/routes/tvShows.js b/server/routes/tvShows.js index ec3379c22..8e27e805d 100644 --- a/server/routes/tvShows.js +++ b/server/routes/tvShows.js @@ -1,8 +1,10 @@ const express = require('express'); const logger = require('../logger'); +const { validateSubFolderName } = require('../modules/filesystem/subfolderValidation'); const MAX_FOLDER_NAME_LENGTH = 100; const LIBRARY_LAYOUTS = new Set(['videos', 'tv']); +const INCLUDE_OPTIONS = new Set(['usage', 'files']); /** * TV show routes: library folder layouts and per-channel TV layout. @@ -16,11 +18,13 @@ const LIBRARY_LAYOUTS = new Set(['videos', 'tv']); * @param {Object} deps.jobModule - Its running job blocks layout switches * @param {Object} deps.models * @param {Object} deps.libraryCheck - mediaServers/libraryCheck + * @param {Object} deps.folderDetail - tvShows/folderDetail + * @param {Object} deps.plexRefreshMappings - mediaServers/plexRefreshMappings * @returns {express.Router} */ function createTvShowRoutes({ verifyToken, libraryFolders, channelLayout, layoutGuards, reorganize, channelSettingsModule, jobModule, models, - libraryCheck, + libraryCheck, folderDetail, plexRefreshMappings, }) { const router = express.Router(); const isDownloadRunning = () => Boolean(jobModule.getInProgressJobId()); @@ -44,6 +48,12 @@ function createTvShowRoutes({ * summary: List library folders with their layouts * description: The main downloads folder (name "") and every subfolder, each with its layout (videos or tv), whether it is the default subfolder, whether it holds downloaded files, and how many enabled channels download to it. * tags: [TV Shows] + * parameters: + * - in: query + * name: include + * required: false + * schema: { type: string, example: 'usage,files' } + * description: "usage adds what uses each folder and what a change would need; files adds fileCount (one scan of the videos table)" * responses: * 200: * description: Library folders @@ -62,11 +72,42 @@ function createTvShowRoutes({ * isDefault: { type: boolean } * hasFiles: { type: boolean } * channels: { type: integer } + * channelsChosen: { type: integer, description: 'usage: enabled channels whose own setting names this folder' } + * channelsFollowing: { type: integer, description: 'usage: enabled channels following the default folder (default folder only)' } + * playlists: { type: integer, description: 'usage: enabled playlists downloading here by default' } + * titleShows: { type: integer, description: 'usage: active title shows in this folder' } + * layoutChangeNeedsReview: { type: boolean, description: 'usage: a layout change answers 409 reorganizeRequired' } + * makeDefaultNeedsReview: { type: boolean, description: 'usage: making this the default answers 409 reorganizeRequired' } + * plexMapping: + * type: object + * properties: + * choice: { type: string, enum: [library, default, none] } + * libraryId: { type: string, nullable: true } + * deleteBlockers: + * type: array + * items: + * type: object + * properties: + * code: { type: string, enum: [channels, disabledChannels, playlists, shows, default, files, main] } + * count: { type: integer } + * deletable: { type: boolean } + * fileCount: { type: integer, description: 'files: downloaded videos whose file sits in this folder' } + * 400: { description: Unknown include value } * 500: { description: Failed to list library folders } */ router.get('/api/library-folders', verifyToken, async (req, res) => { + const include = [].concat(req.query.include ?? []) + .flatMap((value) => String(value).split(',')) + .map((value) => value.trim()) + .filter(Boolean); + if (include.some((value) => !INCLUDE_OPTIONS.has(value))) { + return res.status(400).json({ error: 'include must be usage and/or files' }); + } try { - return res.json({ folders: await libraryFolders.listLibraryFolders() }); + const folders = include.length + ? await libraryFolders.listLibraryFolders({ include }) + : await libraryFolders.listLibraryFolders(); + return res.json({ folders }); } catch (error) { return sendError(res, error, 'Failed to list library folders'); } @@ -112,6 +153,44 @@ function createTvShowRoutes({ } }); + /** + * @swagger + * /api/library-folders/default: + * put: + * summary: Set the default folder + * description: Makes a library folder ("" for the main folder) the default folder. A switch between two folders with the same layout is saved at once; downloaded videos stay where they are. A switch to a folder with the other layout, while a channel following the default (enabled or not) has downloaded videos, is answered with a reorganizeRequired 409 naming the defaultSubfolder change to preview; it is refused while a download or a reorganize runs, and for TV while channels or playlists following the default download MP3. The only writer of defaultSubfolder. + * tags: [TV Shows] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name] + * properties: + * name: { type: string } + * responses: + * 200: { description: "{ changed, defaultSubfolder, folders }" } + * 400: { description: Invalid folder name } + * 404: { description: Library folder not found } + * 409: { description: "Review the move (reorganizeRequired, change), a reorganize is running (code REORGANIZE_RUNNING), a download is running, or MP3 users refuse TV" } + * 500: { description: Failed to change the default folder } + */ + router.put('/api/library-folders/default', verifyToken, async (req, res) => { + const { name } = req.body || {}; + if (typeof name !== 'string' || name.length > MAX_FOLDER_NAME_LENGTH) { + return res.status(400).json({ error: 'name must be a folder name ("" for the main folder)' }); + } + const validation = validateSubFolderName(name); + if (!validation.valid) return res.status(400).json({ error: validation.error }); + try { + const result = await libraryFolders.setDefaultFolder(name, { isDownloadRunning }); + return res.json({ ...result, folders: await libraryFolders.listLibraryFolders() }); + } catch (error) { + return sendError(res, error, 'Failed to change the default folder', { name }); + } + }); + /** * @swagger * /api/library-folders/check: @@ -151,6 +230,7 @@ function createTvShowRoutes({ * name: { type: string } * reachable: { type: boolean } * error: { type: string, nullable: true } + * downloadsPath: { type: string, nullable: true, description: "Where the server sees the downloads folder, in its own path spelling; null unless exactly one place is known" } * folders: * type: array * items: @@ -191,6 +271,7 @@ function createTvShowRoutes({ * properties: * mappedLibraryId: { type: string, nullable: true } * suggestedLibraryId: { type: string, nullable: true } + * choice: { type: string, enum: [library, default, none] } * 400: { description: Invalid folder parameter } * 500: { description: Failed to check the media server libraries } */ @@ -211,12 +292,40 @@ function createTvShowRoutes({ } }); + /** + * @swagger + * /api/library-folders/folder/{key}: + * get: + * summary: Get one library folder in detail + * description: The enabled channels that chose the folder, the channels following the default folder into it (default folder only), the playlists and title shows that use it, each with its downloaded videos in this folder, and the most recently downloaded video as an example (its upload time from the stored info.json, else its upload date at 00:00 UTC). + * tags: [TV Shows] + * parameters: + * - in: path + * name: key + * required: true + * schema: { type: string } + * description: The subfolder name without __, or ~main for the main folder + * responses: + * 200: { description: "{ name, layout, channels, followers: { count, sample }, playlists, titleShows, example }" } + * 404: { description: Library folder not found } + * 500: { description: Failed to load the library folder } + */ + router.get('/api/library-folders/folder/:key', verifyToken, async (req, res) => { + const { key } = req.params; + if (key.length > MAX_FOLDER_NAME_LENGTH) return res.status(400).json({ error: 'key must be a folder name or ~main' }); + try { + return res.json(await folderDetail.getFolderDetail(key)); + } catch (error) { + return sendError(res, error, 'Failed to load the library folder', { key }); + } + }); + /** * @swagger * /api/library-folders/plex-mapping: * put: - * summary: Map a TV folder to its Plex library for refreshes - * description: Adds a Plex subfolder library mapping so new episodes in a TV subfolder refresh the one Plex TV Shows library that holds it (as the library check reports it). An existing mapping for the folder is never replaced. + * summary: Set the Plex library a library folder refreshes + * description: Without replace, adds the mapping a TV subfolder's library check suggests (the one Plex TV Shows library holding it), never replacing an existing setting. With replace true, sets any folder's ("" = main folder, stored as subfolder null) library, overwriting its entry; libraryId null stores the explicit choice of the default library (no Plex connection needed), which automatic mapping leaves alone. * tags: [TV Shows] * requestBody: * required: true @@ -226,16 +335,31 @@ function createTvShowRoutes({ * type: object * required: [folder, libraryId] * properties: - * folder: { type: string, description: Subfolder name without __ } - * libraryId: { type: string } + * folder: { type: string, description: Subfolder name without __ ("" for the main folder with replace) } + * libraryId: { type: string, nullable: true } + * replace: { type: boolean } * responses: - * 200: { description: "The mapping, as { mappedLibraryId, plexSubfolderLibraryMappings } with the saved mappings" } - * 400: { description: Invalid folder or library id, or the folder isn't a TV subfolder } - * 409: { description: "Plex can't be reached, the folder already has another mapping, or that library isn't the one Plex TV library holding the folder" } + * 200: { description: "{ mappedLibraryId, choice (library|default|none), plexSubfolderLibraryMappings }" } + * 400: { description: "Invalid folder or library id, a library Plex doesn't list, or (without replace) not a TV subfolder" } + * 404: { description: Library folder not found (replace) } + * 409: { description: "Plex isn't configured or can't be reached, or (without replace) the folder already has a setting or that library isn't the suggested one" } * 500: { description: Failed to save the mapping } */ router.put('/api/library-folders/plex-mapping', verifyToken, async (req, res) => { - const { folder, libraryId } = req.body || {}; + const { folder, libraryId, replace } = req.body || {}; + if (replace === true) { + if (typeof folder !== 'string' || folder.length > MAX_FOLDER_NAME_LENGTH) { + return res.status(400).json({ error: 'folder must be a folder name ("" for the main folder)' }); + } + if (libraryId !== null && (typeof libraryId !== 'string' || !/^\d+$/.test(libraryId))) { + return res.status(400).json({ error: 'libraryId must be a Plex library id or null' }); + } + try { + return res.json(await plexRefreshMappings.setMapping(folder.trim(), libraryId)); + } catch (error) { + return sendError(res, error, 'Failed to save the Plex library mapping', { folder, libraryId }); + } + } if (typeof folder !== 'string' || !folder.trim() || folder.length > MAX_FOLDER_NAME_LENGTH) { return res.status(400).json({ error: 'folder must be a subfolder name' }); } @@ -249,6 +373,36 @@ function createTvShowRoutes({ } }); + /** + * @swagger + * /api/library-folders/plex-mapping: + * delete: + * summary: Remove a library folder's Plex refresh setting + * description: Removes the folder's entry from plexSubfolderLibraryMappings, so its downloads refresh the default library and a later suggestion may map it again. Needs no Plex connection. A folder without an entry is answered the same way. + * tags: [TV Shows] + * parameters: + * - in: query + * name: folder + * required: true + * schema: { type: string } + * description: Subfolder name without __, or empty for the main folder + * responses: + * 200: { description: "{ mappedLibraryId: null, choice: none, plexSubfolderLibraryMappings }" } + * 400: { description: Invalid folder } + * 500: { description: Failed to remove the mapping } + */ + router.delete('/api/library-folders/plex-mapping', verifyToken, async (req, res) => { + const { folder } = req.query; + if (typeof folder !== 'string' || folder.length > MAX_FOLDER_NAME_LENGTH) { + return res.status(400).json({ error: 'folder must be a folder name ("" for the main folder)' }); + } + try { + return res.json(await plexRefreshMappings.removeMapping(folder.trim())); + } catch (error) { + return sendError(res, error, 'Failed to remove the Plex library mapping', { folder }); + } + }); + /** * @swagger * /api/channels/{channelId}/tv: From 308b879dca8828f971dd72d1743aa985743744a9 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Tue, 6 Oct 2026 19:18:15 -0700 Subject: [PATCH 10/12] feat(library): add the Library folders page Add Settings > Library folders: folders on Videos and TV shelves with one status per media server, a guide to how folders, layouts and libraries fit together, a needs-attention strip that counts a library setting once, and an inspector per folder with media server setup, the Plex refresh library, a layout preview, what downloads there and delete. Adding a folder creates its directory and asks for its layout. The Start using TV shows panel lays out the steps from the current setup, including the exact library edits on each server. Layout and default folder changes that move files open the existing reorganize review. Refs: #175 --- .../components/ChannelTvMediaServers.tsx | 3 +- .../__tests__/ChannelTvMediaServers.test.tsx | 9 + .../sections/PlexSubfolderMappings.tsx | 4 +- .../components/MainFolderTvDialog.tsx | 5 +- .../__tests__/LibraryFoldersList.test.tsx | 1 + .../__tests__/MainFolderTvDialog.test.tsx | 5 + client/src/components/Configuration/types.ts | 2 + .../LibraryFolders/LibraryFoldersContext.tsx | 65 ++++ .../__tests__/LibraryFolders.test.tsx | 269 ++++++++++++++++ .../LibraryFolders/__tests__/copyText.test.ts | 38 +++ .../__tests__/folderText.test.ts | 81 +++++ .../__tests__/layoutPreview.test.ts | 40 +++ .../__tests__/libraryTypes.test.ts | 34 ++ .../__tests__/mediaServerText.test.ts | 87 ++++++ .../LibraryFolders/__tests__/renderPage.tsx | 76 +++++ .../__tests__/tvSetupPaths.test.ts | 82 +++++ .../components/AfterwardsNote.tsx | 38 +++ .../components/AttentionStrip.tsx | 55 ++++ .../components/ChannelLinks.tsx | 48 +++ .../components/CheckBanners.tsx | 42 +++ .../LibraryFolders/components/CopyButton.tsx | 20 ++ .../components/DeleteSection.tsx | 23 ++ .../components/DownloadsHereSection.tsx | 105 +++++++ .../components/FolderDetailScreen.tsx | 20 ++ .../components/FolderInspector.tsx | 44 +++ .../LibraryFolders/components/FolderRow.tsx | 70 +++++ .../LibraryFolders/components/FolderShelf.tsx | 152 +++++++++ .../components/InspectorHeader.tsx | 86 +++++ .../components/LayoutPreviewTree.tsx | 60 ++++ .../components/LayoutSection.tsx | 82 +++++ .../components/LibraryGuide.tsx | 113 +++++++ .../components/MediaServersSection.tsx | 89 ++++++ .../components/NoTvFoldersRow.tsx | 30 ++ .../components/OverlapFixBlock.tsx | 82 +++++ .../LibraryFolders/components/PageHeader.tsx | 62 ++++ .../components/PlexRefreshControl.tsx | 89 ++++++ .../components/SectionHeading.tsx | 9 + .../LibraryFolders/components/ServerCard.tsx | 69 ++++ .../LibraryFolders/components/ServerIssue.tsx | 68 ++++ .../LibraryFolders/components/ServerLine.tsx | 42 +++ .../LibraryFolders/components/SetupBox.tsx | 47 +++ .../LibraryFolders/components/StatusCell.tsx | 48 +++ .../components/UnusedFolderRow.tsx | 82 +++++ .../__tests__/AddFolderDialog.test.tsx | 147 +++++++++ .../__tests__/AfterwardsNote.test.tsx | 46 +++ .../__tests__/AttentionStrip.test.tsx | 37 +++ .../__tests__/ChannelLinks.test.tsx | 15 + .../__tests__/CheckBanners.test.tsx | 43 +++ .../components/__tests__/CopyButton.test.tsx | 44 +++ .../__tests__/DeleteFolderDialog.test.tsx | 30 ++ .../__tests__/DeleteSection.test.tsx | 22 ++ .../__tests__/DownloadsHereSection.test.tsx | 43 +++ .../__tests__/FolderDetailScreen.test.tsx | 16 + .../__tests__/FolderInspector.test.tsx | 60 ++++ .../components/__tests__/FolderRow.test.tsx | 55 ++++ .../components/__tests__/FolderShelf.test.tsx | 70 +++++ .../__tests__/InspectorHeader.test.tsx | 61 ++++ .../__tests__/LayoutPreviewTree.test.tsx | 24 ++ .../__tests__/LayoutSection.test.tsx | 61 ++++ .../__tests__/LibraryGuide.test.tsx | 50 +++ .../__tests__/MakeDefaultDialog.test.tsx | 44 +++ .../__tests__/MediaServersSection.test.tsx | 114 +++++++ .../__tests__/NoTvFoldersRow.test.tsx | 16 + .../__tests__/OverlapFixBlock.test.tsx | 55 ++++ .../components/__tests__/PageHeader.test.tsx | 57 ++++ .../__tests__/PlexRefreshControl.test.tsx | 99 ++++++ .../components/__tests__/ServerCard.test.tsx | 60 ++++ .../components/__tests__/ServerIssue.test.tsx | 28 ++ .../components/__tests__/ServerLine.test.tsx | 23 ++ .../components/__tests__/SetupBox.test.tsx | 26 ++ .../__tests__/StartTvShowsDialog.test.tsx | 76 +++++ .../components/__tests__/StatusCell.test.tsx | 30 ++ .../__tests__/UnusedFolderRow.test.tsx | 44 +++ .../__tests__/serverCardRules.test.ts | 30 ++ .../components/dialogs/AddFolderDialog.tsx | 131 ++++++++ .../components/dialogs/AddFolderNameStep.tsx | 112 +++++++ .../dialogs/AddFolderServersStep.tsx | 131 ++++++++ .../components/dialogs/DeleteFolderDialog.tsx | 60 ++++ .../components/dialogs/MakeDefaultDialog.tsx | 59 ++++ .../components/dialogs/StartTvShowsDialog.tsx | 138 ++++++++ .../components/serverCardRules.ts | 12 + .../src/components/LibraryFolders/copyText.ts | 32 ++ .../components/LibraryFolders/folderText.ts | 215 +++++++++++++ .../__tests__/useCreateLibraryFolder.test.ts | 36 +++ .../hooks/__tests__/useDefaultFolder.test.ts | 32 ++ .../hooks/__tests__/useGuideOpen.test.ts | 31 ++ .../hooks/__tests__/useLayoutChange.test.ts | 64 ++++ .../__tests__/useLibraryFolderDetail.test.ts | 35 +++ .../__tests__/usePlexRefreshMapping.test.ts | 36 +++ .../__tests__/useReorganizeHandoff.test.tsx | 143 +++++++++ .../__tests__/useSelectedFolder.test.tsx | 70 +++++ .../hooks/useCreateLibraryFolder.ts | 36 +++ .../LibraryFolders/hooks/useDefaultFolder.ts | 46 +++ .../LibraryFolders/hooks/useGuideOpen.ts | 37 +++ .../LibraryFolders/hooks/useLayoutChange.ts | 70 +++++ .../hooks/useLibraryFolderDetail.ts | 55 ++++ .../hooks/usePlexRefreshMapping.ts | 45 +++ .../hooks/useReorganizeHandoff.ts | 95 ++++++ .../LibraryFolders/hooks/useSelectedFolder.ts | 71 +++++ .../src/components/LibraryFolders/index.tsx | 276 ++++++++++++++++ .../LibraryFolders/layoutPreview.ts | 113 +++++++ .../components/LibraryFolders/libraryTypes.ts | 85 +++++ .../LibraryFolders/mediaServerText.ts | 220 +++++++++++++ .../components/LibraryFolders/tvSetupPaths.ts | 294 ++++++++++++++++++ client/src/components/Settings/Settings.tsx | 37 ++- .../src/components/Settings/SettingsIndex.tsx | 11 +- .../__tests__/settingsHeading.test.ts | 14 + .../components/Settings/settingsHeading.ts | 13 + client/src/components/layout/AppShell.tsx | 3 +- .../components/layout/NavDrawerContent.tsx | 6 +- .../components/layout/NavHeaderTopItems.tsx | 4 +- .../layout/NavSidebarMobileBottomNav.tsx | 4 +- .../layout/__tests__/AppShell.test.tsx | 42 ++- .../layout/__tests__/navigation.test.tsx | 19 +- client/src/components/layout/navigation.ts | 12 +- client/src/components/shared/LayoutChip.tsx | 29 ++ .../__tests__/useActiveReorganize.test.ts | 61 ++++ .../Reorganize/hooks/useActiveReorganize.ts | 44 ++- .../shared/__tests__/LayoutChip.test.tsx | 11 + .../ui/__tests__/segmented-control.test.tsx | 35 +++ client/src/components/ui/index.ts | 1 + .../src/components/ui/segmented-control.tsx | 65 ++++ client/src/config/configSchema.ts | 2 +- .../__tests__/useContainerWidth.test.tsx | 45 +++ .../hooks/__tests__/useLibraryCheck.test.ts | 57 ++++ .../hooks/__tests__/useLibraryFolders.test.ts | 64 ++++ client/src/hooks/useContainerWidth.ts | 27 ++ client/src/hooks/useLibraryCheck.ts | 25 +- client/src/hooks/useLibraryFolders.ts | 41 ++- client/src/lib/icons.ts | 6 + client/src/types/libraryCheck.ts | 5 +- client/src/types/tvShows.ts | 61 ++++ .../utils/__tests__/libraryAttention.test.ts | 110 +++++++ .../utils/__tests__/libraryLayouts.test.ts | 15 + client/src/utils/libraryAttention.ts | 252 +++++++++++++++ client/src/utils/libraryLayouts.ts | 22 ++ client/src/utils/plexLibraries.ts | 2 + 137 files changed, 7866 insertions(+), 61 deletions(-) create mode 100644 client/src/components/LibraryFolders/LibraryFoldersContext.tsx create mode 100644 client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx create mode 100644 client/src/components/LibraryFolders/__tests__/copyText.test.ts create mode 100644 client/src/components/LibraryFolders/__tests__/folderText.test.ts create mode 100644 client/src/components/LibraryFolders/__tests__/layoutPreview.test.ts create mode 100644 client/src/components/LibraryFolders/__tests__/libraryTypes.test.ts create mode 100644 client/src/components/LibraryFolders/__tests__/mediaServerText.test.ts create mode 100644 client/src/components/LibraryFolders/__tests__/renderPage.tsx create mode 100644 client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts create mode 100644 client/src/components/LibraryFolders/components/AfterwardsNote.tsx create mode 100644 client/src/components/LibraryFolders/components/AttentionStrip.tsx create mode 100644 client/src/components/LibraryFolders/components/ChannelLinks.tsx create mode 100644 client/src/components/LibraryFolders/components/CheckBanners.tsx create mode 100644 client/src/components/LibraryFolders/components/CopyButton.tsx create mode 100644 client/src/components/LibraryFolders/components/DeleteSection.tsx create mode 100644 client/src/components/LibraryFolders/components/DownloadsHereSection.tsx create mode 100644 client/src/components/LibraryFolders/components/FolderDetailScreen.tsx create mode 100644 client/src/components/LibraryFolders/components/FolderInspector.tsx create mode 100644 client/src/components/LibraryFolders/components/FolderRow.tsx create mode 100644 client/src/components/LibraryFolders/components/FolderShelf.tsx create mode 100644 client/src/components/LibraryFolders/components/InspectorHeader.tsx create mode 100644 client/src/components/LibraryFolders/components/LayoutPreviewTree.tsx create mode 100644 client/src/components/LibraryFolders/components/LayoutSection.tsx create mode 100644 client/src/components/LibraryFolders/components/LibraryGuide.tsx create mode 100644 client/src/components/LibraryFolders/components/MediaServersSection.tsx create mode 100644 client/src/components/LibraryFolders/components/NoTvFoldersRow.tsx create mode 100644 client/src/components/LibraryFolders/components/OverlapFixBlock.tsx create mode 100644 client/src/components/LibraryFolders/components/PageHeader.tsx create mode 100644 client/src/components/LibraryFolders/components/PlexRefreshControl.tsx create mode 100644 client/src/components/LibraryFolders/components/SectionHeading.tsx create mode 100644 client/src/components/LibraryFolders/components/ServerCard.tsx create mode 100644 client/src/components/LibraryFolders/components/ServerIssue.tsx create mode 100644 client/src/components/LibraryFolders/components/ServerLine.tsx create mode 100644 client/src/components/LibraryFolders/components/SetupBox.tsx create mode 100644 client/src/components/LibraryFolders/components/StatusCell.tsx create mode 100644 client/src/components/LibraryFolders/components/UnusedFolderRow.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/AddFolderDialog.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/AfterwardsNote.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/AttentionStrip.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/ChannelLinks.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/CheckBanners.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/CopyButton.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/DeleteFolderDialog.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/DeleteSection.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/DownloadsHereSection.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/FolderDetailScreen.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/FolderInspector.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/FolderRow.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/FolderShelf.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/InspectorHeader.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/LayoutPreviewTree.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/LayoutSection.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/MakeDefaultDialog.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/MediaServersSection.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/NoTvFoldersRow.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/OverlapFixBlock.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/PageHeader.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/PlexRefreshControl.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/ServerCard.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/ServerIssue.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/ServerLine.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/SetupBox.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/StatusCell.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/UnusedFolderRow.test.tsx create mode 100644 client/src/components/LibraryFolders/components/__tests__/serverCardRules.test.ts create mode 100644 client/src/components/LibraryFolders/components/dialogs/AddFolderDialog.tsx create mode 100644 client/src/components/LibraryFolders/components/dialogs/AddFolderNameStep.tsx create mode 100644 client/src/components/LibraryFolders/components/dialogs/AddFolderServersStep.tsx create mode 100644 client/src/components/LibraryFolders/components/dialogs/DeleteFolderDialog.tsx create mode 100644 client/src/components/LibraryFolders/components/dialogs/MakeDefaultDialog.tsx create mode 100644 client/src/components/LibraryFolders/components/dialogs/StartTvShowsDialog.tsx create mode 100644 client/src/components/LibraryFolders/components/serverCardRules.ts create mode 100644 client/src/components/LibraryFolders/copyText.ts create mode 100644 client/src/components/LibraryFolders/folderText.ts create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/useCreateLibraryFolder.test.ts create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/useDefaultFolder.test.ts create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/useGuideOpen.test.ts create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/useLayoutChange.test.ts create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/useLibraryFolderDetail.test.ts create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/usePlexRefreshMapping.test.ts create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/useReorganizeHandoff.test.tsx create mode 100644 client/src/components/LibraryFolders/hooks/__tests__/useSelectedFolder.test.tsx create mode 100644 client/src/components/LibraryFolders/hooks/useCreateLibraryFolder.ts create mode 100644 client/src/components/LibraryFolders/hooks/useDefaultFolder.ts create mode 100644 client/src/components/LibraryFolders/hooks/useGuideOpen.ts create mode 100644 client/src/components/LibraryFolders/hooks/useLayoutChange.ts create mode 100644 client/src/components/LibraryFolders/hooks/useLibraryFolderDetail.ts create mode 100644 client/src/components/LibraryFolders/hooks/usePlexRefreshMapping.ts create mode 100644 client/src/components/LibraryFolders/hooks/useReorganizeHandoff.ts create mode 100644 client/src/components/LibraryFolders/hooks/useSelectedFolder.ts create mode 100644 client/src/components/LibraryFolders/index.tsx create mode 100644 client/src/components/LibraryFolders/layoutPreview.ts create mode 100644 client/src/components/LibraryFolders/libraryTypes.ts create mode 100644 client/src/components/LibraryFolders/mediaServerText.ts create mode 100644 client/src/components/LibraryFolders/tvSetupPaths.ts create mode 100644 client/src/components/Settings/__tests__/settingsHeading.test.ts create mode 100644 client/src/components/Settings/settingsHeading.ts create mode 100644 client/src/components/shared/LayoutChip.tsx create mode 100644 client/src/components/shared/__tests__/LayoutChip.test.tsx create mode 100644 client/src/components/ui/__tests__/segmented-control.test.tsx create mode 100644 client/src/components/ui/segmented-control.tsx create mode 100644 client/src/hooks/__tests__/useContainerWidth.test.tsx create mode 100644 client/src/hooks/useContainerWidth.ts create mode 100644 client/src/utils/__tests__/libraryAttention.test.ts create mode 100644 client/src/utils/libraryAttention.ts diff --git a/client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx b/client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx index 178411ca2..0446b674f 100644 --- a/client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx +++ b/client/src/components/ChannelPage/components/ChannelTvMediaServers.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Alert, Box, Button, CircularProgress, Typography } from '../../ui'; import { useLibraryCheck } from '../../../hooks/useLibraryCheck'; +import { plexMappingChoice } from '../../../utils/libraryAttention'; import { folderKey } from '../../../utils/libraryLayouts'; import { LibraryCheckNotes } from '../../shared/LibraryCheck/LibraryCheckNotes'; @@ -45,7 +46,7 @@ function ChannelTvMediaServers({ token, folder }: ChannelTvMediaServersProps) { const report = data?.folders.find((entry) => folderKey(entry.name) === folderKey(folder)) ?? null; const plex = report?.servers.find((server) => server.serverType === 'plex'); const suggestion = plex?.plexMapping?.suggestedLibraryId ?? null; - const needsMapping = Boolean(report?.name && suggestion && !plex?.plexMapping?.mappedLibraryId); + const needsMapping = Boolean(report?.name && suggestion && plexMappingChoice(plex?.plexMapping) === 'none'); useEffect(() => { if (!needsMapping || !report || !suggestion) return; diff --git a/client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx b/client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx index 6998149dd..0b32fc386 100644 --- a/client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx +++ b/client/src/components/ChannelPage/components/__tests__/ChannelTvMediaServers.test.tsx @@ -34,6 +34,7 @@ function setCheck(overrides: Partial<UseLibraryCheckResult>) { data: null, loading: false, error: null, + lastCheckedAt: null, refetch: jest.fn().mockResolvedValue(undefined), applyPlexMapping: jest.fn().mockResolvedValue(undefined), ...overrides, @@ -96,4 +97,12 @@ describe('ChannelTvMediaServers', () => { expect(mockCheck.refetch).toHaveBeenCalled(); }); + + test('does not map a folder whose refresh library the user chose', () => { + const applyPlexMapping = jest.fn().mockResolvedValue(undefined); + setCheck({ data: checkWith({ mappedLibraryId: null, suggestedLibraryId: '41', choice: 'default' }), applyPlexMapping }); + render(<ChannelTvMediaServers token="token" folder="TV Shows" />); + + expect(applyPlexMapping).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/Configuration/sections/PlexSubfolderMappings.tsx b/client/src/components/Configuration/sections/PlexSubfolderMappings.tsx index 8b323c993..a685a4fe4 100644 --- a/client/src/components/Configuration/sections/PlexSubfolderMappings.tsx +++ b/client/src/components/Configuration/sections/PlexSubfolderMappings.tsx @@ -28,7 +28,7 @@ import { PlexLibraryLabel } from './components/PlexLibraryLabel'; export interface PlexSubfolderMapping { subfolder: string | null; - libraryId: string; + libraryId: string | null; } interface PlexSubfolderMappingsProps { @@ -155,7 +155,7 @@ export const PlexSubfolderMappings: React.FC<PlexSubfolderMappingsProps> = ({ </TableHead> <TableBody> {mappings.map((mapping) => { - const display = resolveLibraryDisplay(plexLibraries, mapping.libraryId); + const display = resolveLibraryDisplay(plexLibraries, mapping.libraryId ?? ''); return ( <TableRow key={`${mapping.subfolder === null ? '\x00root' : mapping.subfolder}-${mapping.libraryId}`}> <TableCell> diff --git a/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx b/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx index dc8d31fea..786224ae9 100644 --- a/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx +++ b/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx @@ -14,6 +14,8 @@ interface MainFolderTvDialogProps { onCancel: () => void; onConfirm: () => void; busy?: boolean; + /** "Review the move" when the server says the change moves files */ + confirmLabel?: string; } const PARAGRAPHS = [ @@ -28,6 +30,7 @@ export const MainFolderTvDialog: React.FC<MainFolderTvDialogProps> = ({ onCancel, onConfirm, busy = false, + confirmLabel = 'Use for TV shows', }) => { const titleId = useId(); @@ -58,7 +61,7 @@ export const MainFolderTvDialog: React.FC<MainFolderTvDialogProps> = ({ disabled={busy} startIcon={busy ? <CircularProgress size={14} /> : undefined} > - Use for TV shows + {confirmLabel} </Button> </DialogActions> </Dialog> diff --git a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx index af93d451a..8e301bb3e 100644 --- a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx +++ b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx @@ -58,6 +58,7 @@ function mockHook(overrides: Partial<UseLibraryFoldersResult> = {}) { library = { folders: FOLDERS, loading: false, + loaded: true, error: null, layoutOf: () => 'videos', refetch: mockRefetch, diff --git a/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx b/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx index 0fb5a39f7..c07c4e080 100644 --- a/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx +++ b/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx @@ -50,6 +50,11 @@ describe('MainFolderTvDialog', () => { expect(screen.getByText(/Choose this if all your content is TV-style/)).toBeInTheDocument(); }); + test('uses the confirm label it is given', () => { + showDialog({ confirmLabel: 'Review the move' }); + expect(screen.getByRole('button', { name: 'Review the move' })).toBeInTheDocument(); + }); + test('confirm calls onConfirm', async () => { const user = userEvent.setup(); const props = showDialog(); diff --git a/client/src/components/Configuration/types.ts b/client/src/components/Configuration/types.ts index f1bf02294..efffda0b0 100644 --- a/client/src/components/Configuration/types.ts +++ b/client/src/components/Configuration/types.ts @@ -85,6 +85,8 @@ export interface PlatformManagedState { authEnabled: boolean; useTmpForDownloads: boolean; ytdlpUpdates: boolean; + /** DATA_PATH sets the downloads folder (platform deployments) */ + youtubeOutputDirectory?: boolean; } export interface DeploymentEnvironment { diff --git a/client/src/components/LibraryFolders/LibraryFoldersContext.tsx b/client/src/components/LibraryFolders/LibraryFoldersContext.tsx new file mode 100644 index 000000000..2127a41ac --- /dev/null +++ b/client/src/components/LibraryFolders/LibraryFoldersContext.tsx @@ -0,0 +1,65 @@ +import { createContext, useContext } from 'react'; +import type { LibraryFolder, LibraryFolderDetail, LibraryLayout } from '../../types/tvShows'; +import type { MediaServerType } from '../../types/libraryCheck'; +import type { ReorganizeChange } from '../../types/reorganize'; +import type { PlexLibrary } from '../../utils/plexLibraries'; +import type { LibraryCheckState, ServerRef } from '../../utils/libraryAttention'; +import type { ConfigState, PlatformManagedState, PlexConnectionStatus, SnackbarState } from '../Configuration/types'; +import type { LayoutResult } from './hooks/useLayoutChange'; +import type { ReorganizeHandoffContext } from './hooks/useReorganizeHandoff'; + +/** What every section of the Library folders page reads, so props don't drill through the inspector. */ +export interface LibraryPageValue { + token: string | null; + config: ConfigState; + isPlatformManaged: PlatformManagedState; + timeZone: string | null; + phone: boolean; + /** Shelves and inspector side by side (content >= 1012px); else list and detail screens */ + twoColumn: boolean; + folders: LibraryFolder[]; + /** The folder list's first answer (or failure) has arrived */ + foldersLoaded: boolean; + /** The main folder's detail, loaded once for every section that lists its channels */ + mainDetail: LibraryFolderDetail | null; + check: LibraryCheckState & { + refetch: () => Promise<void>; + applyPlexMapping: (folder: string, libraryId: string) => Promise<void>; + }; + configuredServers: MediaServerType[]; + /** The media server status or the check has answered; until then nothing says no server is connected */ + serversKnown: boolean; + /** The check's servers, else the configured ones */ + servers: ServerRef[]; + plexLibraries: PlexLibrary[]; + plexConnectionStatus: PlexConnectionStatus; + reorganizing: boolean; + /** folderKey of each folder a running reorganize moves */ + movingFolders: string[]; + layoutResult: LayoutResult | null; + busyLayoutFolder: string | null; + changeLayout: (name: string, target: LibraryLayout) => Promise<void>; + selectFolder: (name: string) => void; + openAddFolder: (layout: LibraryLayout) => void; + openStartTv: () => void; + openMainFolderTv: () => void; + openMakeDefault: (folder: LibraryFolder) => void; + openDelete: (folder: LibraryFolder) => void; + reviewChange: (change: ReorganizeChange, context: ReorganizeHandoffContext) => void; + /** Select a folder from the attention list; the inspector then scrolls to the server's card and takes focus */ + jumpTo: (folder: string, serverType?: MediaServerType) => void; + focusTarget: { folder: string; serverType?: MediaServerType } | null; + clearFocusTarget: () => void; + /** A toast; success unless told otherwise */ + notify: (message: string, severity?: SnackbarState['severity']) => void; +} + +const LibraryPageContext = createContext<LibraryPageValue | null>(null); + +export const LibraryPageProvider = LibraryPageContext.Provider; + +export function useLibraryPage(): LibraryPageValue { + const value = useContext(LibraryPageContext); + if (!value) throw new Error('useLibraryPage must be used inside the Library folders page'); + return value; +} diff --git a/client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx b/client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx new file mode 100644 index 000000000..3e1219def --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx @@ -0,0 +1,269 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import LibraryFolders from '..'; +import { DEFAULT_CONFIG } from '../../../config/configSchema'; +import { TooltipProvider } from '../../ui/tooltip'; +import type { LibraryFolder } from '../../../types/tvShows'; +import type { LibraryCheckResponse } from '../../../types/libraryCheck'; +import type { ReorganizeOperation } from '../../../types/reorganize'; +import { folder } from './renderPage'; +import { useLibraryFolders } from '../../../hooks/useLibraryFolders'; +import { useLibraryCheck } from '../../../hooks/useLibraryCheck'; +import { useMediaServerStatus } from '../../../hooks/useMediaServerStatus'; +import { useContainerWidth } from '../../../hooks/useContainerWidth'; +import { useMediaQuery } from '../../../hooks/useMediaQuery'; + +jest.mock('../../../hooks/useLibraryFolders', () => ({ useLibraryFolders: jest.fn(), LIBRARY_FOLDERS_UPDATED_EVENT: 'library-folders-updated' })); +jest.mock('../../../hooks/useLibraryCheck', () => ({ useLibraryCheck: jest.fn() })); +jest.mock('../../../hooks/useMediaServerStatus', () => ({ useMediaServerStatus: jest.fn() })); +jest.mock('../../../hooks/useContainerWidth', () => ({ useContainerWidth: jest.fn() })); +jest.mock('../../../hooks/useMediaQuery', () => ({ __esModule: true, default: jest.fn(), useMediaQuery: jest.fn() })); +let mockOperation: ReorganizeOperation | null = null; +jest.mock('../../shared/Reorganize', () => ({ + ...jest.requireActual('../../shared/Reorganize'), + ReorganizeDialog: ({ onApplied }: { onApplied?: (result: { operationId: number; applied: boolean }) => void }) => { + const React = require('react'); + return React.createElement('button', { type: 'button', onClick: () => onApplied?.({ operationId: 3, applied: true }) }, 'start move 3'); + }, + useActiveReorganize: () => ({ operation: mockOperation }), + useReorganizeOutcome: () => undefined, +})); +jest.mock('../hooks/useLibraryFolderDetail', () => ({ useLibraryFolderDetail: () => ({ detail: null }) })); +jest.mock('../components/FolderInspector', () => ({ FolderInspector: ({ folder: selected }: { folder: LibraryFolder }) => { + const React = require('react'); + const { useLibraryPage } = require('../LibraryFoldersContext'); + const page = useLibraryPage(); + const target = page.focusTarget ? `${page.focusTarget.folder}:${page.focusTarget.serverType ?? ''}` : 'none'; + return React.createElement('div', null, + React.createElement('p', null, `inspector ${selected.name || 'main'}`), + React.createElement('p', null, `focus target ${target}`), + React.createElement('button', { type: 'button', onClick: () => page.openDelete(selected) }, 'Open delete')); +} })); +jest.mock('../components/dialogs/DeleteFolderDialog', () => ({ + DeleteFolderDialog: ({ folder: target, onDeleted }: { folder: LibraryFolder; onDeleted: (deleted: LibraryFolder) => void }) => { + const React = require('react'); + return React.createElement('button', { type: 'button', onClick: () => onDeleted(target) }, 'Confirm delete'); + }, +})); + +const folders = [folder('', { channels: 1 }), folder('Kids', { isDefault: true, channels: 3 }), folder('Empty')]; + +function libraryResult(overrides: Record<string, unknown> = {}) { + return { folders, loading: false, loaded: true, error: null, refetch: jest.fn(), setFolderLayout: jest.fn(), layoutOf: () => 'videos', ...overrides }; +} + +const page = ( + <LibraryFolders token="token" config={{ ...DEFAULT_CONFIG, youtubeOutputDirectory: '/data' }} + isPlatformManaged={{ plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false }} + deploymentEnvironment={{ isWsl: false, timezone: 'UTC' }} plexLibraries={[]} plexConnectionStatus="not_tested" setSnackbar={jest.fn()} /> +); + +function pageAt(path: string) { + return ( + <MemoryRouter initialEntries={[path]}> + <TooltipProvider> + <Routes> + <Route path="/settings/library/*" element={page} /> + </Routes> + </TooltipProvider> + </MemoryRouter> + ); +} + +function HistoryBack() { + const navigate = useNavigate(); + return <button type="button" onClick={() => navigate(-1)}>history back</button>; +} + +function checkResult(data: LibraryCheckResponse | null, extra: Record<string, unknown> = {}) { + return { data, loading: false, error: null, lastCheckedAt: 0, refetch: jest.fn(), applyPlexMapping: jest.fn(), ...extra }; +} + +function renderAt(path: string, width: number) { + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), width]); + return render(pageAt(path)); +} + +describe('LibraryFolders page', () => { + beforeEach(() => { + window.localStorage.clear(); + mockOperation = null; + jest.spyOn(window, 'scrollTo').mockImplementation(() => undefined); + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult()); + (useLibraryCheck as jest.Mock).mockReturnValue(checkResult({ servers: [], folders: [] })); + (useMediaServerStatus as jest.Mock).mockReturnValue({ status: { plex: false, jellyfin: false, emby: false }, loading: false }); + (useMediaQuery as jest.Mock).mockReturnValue(false); + }); + + test('a running move marks its folder Moving', () => { + mockOperation = { id: 3, label: 'Kids', status: 'running', change: { type: 'folderLayout', folder: 'kids', layout: 'tv' } }; + renderAt('/settings/library', 1100); + expect(screen.getByRole('button', { name: /__Kids/ })).toHaveTextContent('Moving'); + expect(screen.getByRole('button', { name: /^Main folder/ })).not.toHaveTextContent('Moving'); + }); + + test('when a running reorganize ends, the folders and the check are fetched again', () => { + const refetchFolders = jest.fn(); + const refetchCheck = jest.fn(); + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult({ refetch: refetchFolders })); + (useLibraryCheck as jest.Mock).mockReturnValue(checkResult({ servers: [], folders: [] }, { refetch: refetchCheck })); + mockOperation = { id: 3, label: 'Elsewhere', status: 'running' }; + const { rerender } = renderAt('/settings/library', 1100); + expect(refetchFolders).not.toHaveBeenCalled(); + + mockOperation = null; + rerender(pageAt('/settings/library')); + + expect(refetchFolders).toHaveBeenCalledTimes(1); + expect(refetchCheck).toHaveBeenCalledTimes(1); + }); + + test('a move this page started is left to its own follow-up when it ends, so the page refreshes once', async () => { + const refetchFolders = jest.fn(); + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult({ refetch: refetchFolders })); + const { rerender } = renderAt('/settings/library', 1100); + await userEvent.click(screen.getByRole('button', { name: 'start move 3' })); + mockOperation = { id: 3, label: 'Kids', status: 'running' }; + rerender(pageAt('/settings/library')); + + mockOperation = null; + rerender(pageAt('/settings/library')); + + expect(refetchFolders).not.toHaveBeenCalled(); + }); + + test('narrow: Back returns to the list and focuses the folder\'s row', async () => { + renderAt('/settings/library', 800); + await userEvent.click(screen.getByRole('button', { name: /__Kids/ })); + await userEvent.click(await screen.findByRole('button', { name: 'Library folders' })); + await waitFor(() => expect(screen.getByRole('button', { name: /__Kids/ })).toHaveFocus()); + }); + + test('narrow: deleting from a detail opened from the list goes back instead of adding a list entry', async () => { + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 800]); + render( + <MemoryRouter initialEntries={['/settings', '/settings/library']} initialIndex={1}> + <TooltipProvider> + <HistoryBack /> + <Routes> + <Route path="/settings" element={<p>settings index</p>} /> + <Route path="/settings/library/*" element={page} /> + </Routes> + </TooltipProvider> + </MemoryRouter> + ); + await userEvent.click(screen.getByRole('button', { name: /__Kids/ })); + await userEvent.click(await screen.findByRole('button', { name: 'Open delete' })); + await userEvent.click(screen.getByRole('button', { name: 'Confirm delete' })); + expect(await screen.findByRole('region', { name: /Video folders/ })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'history back' })); + + expect(await screen.findByText('settings index')).toBeInTheDocument(); + }); + + test('an attention jump selects the folder and points the inspector at the server', async () => { + (useMediaServerStatus as jest.Mock).mockReturnValue({ status: { plex: false, jellyfin: true, emby: false }, loading: false }); + (useLibraryCheck as jest.Mock).mockReturnValue(checkResult({ + servers: [{ serverType: 'jellyfin', name: 'Jellyfin', reachable: true, error: null, downloadsPath: '/data' }], + folders: [{ name: 'Empty', layout: 'videos', hasFiles: false, channels: 0, servers: [{ + serverType: 'jellyfin', status: 'warning', issues: [{ code: 'nfoSaver', message: 'Saves NFO files', libraryId: '5' }], + libraries: [{ id: '5', name: 'Spare', type: 'videos', location: '/data/__Empty', relation: 'exact' }], + }] }], + })); + renderAt('/settings/library', 1100); + await screen.findByText('inspector Kids'); + + await userEvent.click(screen.getByRole('button', { name: 'Jellyfin library Spare saves NFO files' })); + + expect(await screen.findByText('inspector Empty')).toBeInTheDocument(); + expect(screen.getByText('focus target Empty:jellyfin')).toBeInTheDocument(); + }); + + test('while the media server status loads, nothing says no server is connected', () => { + (useMediaServerStatus as jest.Mock).mockReturnValue({ status: { plex: false, jellyfin: false, emby: false }, loading: true }); + (useLibraryCheck as jest.Mock).mockReturnValue(checkResult(null, { loading: true })); + renderAt('/settings/library', 1100); + expect(screen.queryByText(/No media server connected/)).not.toBeInTheDocument(); + }); + + test('two columns select the default folder and show its inspector', async () => { + renderAt('/settings/library', 1100); + expect(await screen.findByText('inspector Kids')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 1, name: 'Library folders' })).toBeInTheDocument(); + }); + + test('narrow: the list, then a pushed detail screen', async () => { + renderAt('/settings/library', 800); + expect(screen.queryByText(/^inspector/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /__Kids/ })); + expect(await screen.findByText('inspector Kids')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Library folders' })).toBeInTheDocument(); + }); + + test('a folder list error shows Retry and keeps the header', async () => { + const refetch = jest.fn(); + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult({ folders: [], error: 'Failed to load library folders', refetch })); + renderAt('/settings/library', 1100); + expect(screen.getByText('Failed to load library folders')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(refetch).toHaveBeenCalled(); + expect(screen.getByRole('heading', { level: 1, name: 'Library folders' })).toBeInTheDocument(); + }); + + test('without TV folders, the No TV folders row follows the Video folders shelf', () => { + renderAt('/settings/library', 1100); + const regions = screen.getAllByRole('region'); + const videos = screen.getByRole('region', { name: /Video folders/ }); + const tv = screen.getByRole('region', { name: 'TV show folders' }); + expect(regions.indexOf(videos)).toBeLessThan(regions.indexOf(tv)); + }); + + test('with a TV folder, the TV show folders shelf comes first', () => { + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult({ folders: [...folders, folder('Shows', { layout: 'tv', channels: 2 })] })); + renderAt('/settings/library', 1100); + const regions = screen.getAllByRole('region'); + const tv = screen.getByRole('region', { name: 'TV show folders' }); + const videos = screen.getByRole('region', { name: /Video folders/ }); + expect(regions.indexOf(tv)).toBeLessThan(regions.indexOf(videos)); + }); + + test('an unknown folder URL shows the not-found notice', async () => { + renderAt('/settings/library/Gone', 1100); + expect(await screen.findByText("Library folder __Gone wasn't found. It may have been deleted.")).toBeInTheDocument(); + }); + + test('a folder URL waits while the list reloads instead of reporting it missing', () => { + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult({ loading: true })); + renderAt('/settings/library/New', 1100); + expect(screen.queryByText(/wasn't found/)).not.toBeInTheDocument(); + }); + + test('selecting another folder starts the inspector scrolled to the top', async () => { + renderAt('/settings/library', 1100); + await screen.findByText('inspector Kids'); + screen.getByRole('complementary').scrollTop = 240; + await userEvent.click(screen.getByRole('button', { name: /^Main folder/ })); + await screen.findByText('inspector main'); + expect(screen.getByRole('complementary').scrollTop).toBe(0); + }); + + test('after deleting the last TV folder, focus lands on the Video folders heading once the list drops it', async () => { + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult({ folders: [...folders, folder('Shows', { layout: 'tv' })] })); + const { rerender } = renderAt('/settings/library/Shows', 1100); + await userEvent.click(await screen.findByRole('button', { name: 'Open delete' })); + await userEvent.click(screen.getByRole('button', { name: 'Confirm delete' })); + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult()); + rerender(pageAt('/settings/library/Shows')); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Video folders' })).toHaveFocus()); + }); + + test('narrow: opening a folder scrolls the window to the top', async () => { + renderAt('/settings/library', 800); + await userEvent.click(screen.getByRole('button', { name: /__Kids/ })); + await screen.findByText('inspector Kids'); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }); +}); diff --git a/client/src/components/LibraryFolders/__tests__/copyText.test.ts b/client/src/components/LibraryFolders/__tests__/copyText.test.ts new file mode 100644 index 000000000..9ad5ca276 --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/copyText.test.ts @@ -0,0 +1,38 @@ +import { copyText } from '../copyText'; + +describe('copyText', () => { + afterEach(() => { + Object.assign(navigator, { clipboard: undefined }); + Object.assign(document, { execCommand: undefined }); + }); + + test('uses the clipboard API where it exists', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + await expect(copyText('/yt/__Kids')).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith('/yt/__Kids'); + }); + + test('falls back to a hidden textarea copy on plain HTTP, where the clipboard API is missing', async () => { + const exec = jest.fn().mockReturnValue(true); + Object.assign(document, { execCommand: exec }); + await expect(copyText('/yt/__Kids')).resolves.toBe(true); + expect(exec).toHaveBeenCalledWith('copy'); + }); + + test('falls back when the clipboard API refuses', async () => { + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) } }); + Object.assign(document, { execCommand: jest.fn().mockReturnValue(true) }); + await expect(copyText('/yt')).resolves.toBe(true); + }); + + test('reports a failure when both ways fail', async () => { + Object.assign(document, { execCommand: jest.fn().mockReturnValue(false) }); + await expect(copyText('/yt')).resolves.toBe(false); + }); + + test('reports a failure when the fallback throws', async () => { + Object.assign(document, { execCommand: jest.fn(() => { throw new Error('unsupported'); }) }); + await expect(copyText('/yt')).resolves.toBe(false); + }); +}); diff --git a/client/src/components/LibraryFolders/__tests__/folderText.test.ts b/client/src/components/LibraryFolders/__tests__/folderText.test.ts new file mode 100644 index 000000000..0eac12ddb --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/folderText.test.ts @@ -0,0 +1,81 @@ +import { + deleteReasonText, followersLine, layoutConsequence, rowSummary, verbLabel, makeDefaultText, +} from '../folderText'; +import type { LibraryFolder } from '../../../types/tvShows'; + +const f = (name: string, extra: Partial<LibraryFolder> = {}): LibraryFolder => ({ + name, layout: 'videos', isDefault: false, hasFiles: false, channels: 0, deleteBlockers: [], deletable: true, ...extra, +}); + +describe('followersLine', () => { + test('names the sample and how many more after the channel links', () => { + expect(followersLine({ count: 5, sample: ['Blippi', 'Cocomelon', 'Numberblocks'] }, true)) + .toBe('5 more follow the default folder (Blippi, Cocomelon, Numberblocks and 2 more): make another folder the default, ' + + 'or set their Library folder in Channel Settings.'); + }); + + test('without channel links before it, and a single follower', () => { + expect(followersLine({ count: 1, sample: ['Blippi'] }, false)) + .toBe('1 follows the default folder (Blippi): make another folder the default, or set their Library folder in Channel Settings.'); + }); + + test('nothing when no channel follows the default folder', () => { + expect(followersLine({ count: 0, sample: [] }, true)).toBeNull(); + }); +}); + +describe('folderText', () => { + test('row summaries per state', () => { + expect(rowSummary(f('Kids', { isDefault: true, channels: 24, channelsChosen: 3, channelsFollowing: 21, playlists: 1, fileCount: 1208 }))) + .toBe('3 chose this folder, 21 follow the default \u00b7 1 playlist \u00b7 1,208 videos on disk'); + expect(rowSummary(f('TV', { layout: 'tv', channels: 4, titleShows: 1, fileCount: 233 }))) + .toBe('4 channels \u00b7 1 title show \u00b7 233 episodes on disk'); + expect(rowSummary(f('Old', { fileCount: 31, hasFiles: true }))) + .toBe('Nothing downloads here now \u00b7 31 videos on disk'); + expect(rowSummary(f('Raw', { hasFiles: true, fileCount: 0 }))) + .toBe("Nothing downloads here now \u00b7 Holds files Youtarr doesn't track"); + expect(rowSummary(f(''))).toBe('Nothing saved here \u00b7 Downloads go to subfolders'); + expect(rowSummary(f('New', { isDefault: true }))).toBe('Nothing downloaded yet'); + expect(rowSummary(f('Gone', { deletable: false, deleteBlockers: [{ code: 'disabledChannels', count: 1 }] }))) + .toBe('Unused \u00b7 Empty \u00b7 1 unsubscribed channel still points here'); + }); + + test('delete reasons', () => { + expect(deleteReasonText(f(''))).toBe("The main folder is the downloads folder itself, so it can't be deleted."); + expect(deleteReasonText(f('X', { plexMapping: { choice: 'library', libraryId: '3' } }))) + .toBe('Empty and unused, so you can delete it. Its Plex refresh setting is removed too.'); + expect(deleteReasonText(f('TV', { + layout: 'tv', deletable: false, fileCount: 58, channels: 2, + deleteBlockers: [{ code: 'channels', count: 2 }, { code: 'files' }], + }))).toBe("Can't delete: 2 channels download here; it holds 58 downloaded episodes."); + }); + + test('layout consequence and verb follow the server flag', () => { + expect(verbLabel(f('Kids', { layoutChangeNeedsReview: true }))).toBe('Move to TV shows'); + expect(verbLabel(f('Kids'))).toBe('Use for TV shows'); + expect(layoutConsequence({ folder: f('Kids'), titleShows: [], reorganizing: false }).text) + .toBe('Nothing needs to move, so it switches at once.'); + expect(layoutConsequence({ + folder: f('TV', { layout: 'tv' }), + titleShows: [{ id: 1, name: 'Lessons', channelId: 'UC1', channelName: 'Prof', episodeCount: 3 }], + reorganizing: false, + })).toEqual({ + text: "Lessons is a title show, and title shows need a TV folder. Move it to another TV folder first, in Prof's Channel Settings > TV Show.", + blocked: true, + }); + expect(layoutConsequence({ folder: f('Kids'), titleShows: [], reorganizing: true }).blocked).toBe(true); + }); + + test('make default copy for a same-layout and a cross-layout switch', () => { + const same = makeDefaultText({ folder: f('Music'), current: f('Kids', { isDefault: true, channelsFollowing: 3 }) }); + expect(same.body).toMatch(/^Channels set to the default folder \(3\) will download to __Music instead of __Kids\./); + expect(same.sameLayoutNote).toBe('Videos they already downloaded stay where they are. Keep a library on __Kids to watch them.'); + const cross = makeDefaultText({ + folder: f('TV', { layout: 'tv', makeDefaultNeedsReview: true }), + current: f('Kids', { isDefault: true, channelsFollowing: 3 }), + }); + expect(cross.confirmLabel).toBe('Review the move'); + expect(cross.warning).toBe('__TV uses TV shows and __Kids uses Videos, so channels that follow the default become TV shows and their downloaded videos move. You review every move first.'); + expect(cross.tvLine).toBe("Each channel you don't subscribe to becomes its own show in __TV."); + }); +}); diff --git a/client/src/components/LibraryFolders/__tests__/layoutPreview.test.ts b/client/src/components/LibraryFolders/__tests__/layoutPreview.test.ts new file mode 100644 index 000000000..d9fd6182a --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/layoutPreview.test.ts @@ -0,0 +1,40 @@ +import { exampleFrom, previewRows, readsAs } from '../layoutPreview'; + +const stored = { + channelName: 'Blippi', title: 'Fire Trucks', youtubeId: 'abcDEF12345', + uploadedAt: '2026-09-28T15:30:00.000Z', uploadedAtSource: 'timestamp' as const, + relativePath: 'Blippi/Blippi - Fire Trucks - abcDEF12345/Blippi - Fire Trucks [abcDEF12345].mp4', +}; +const example = exampleFrom(stored); + +describe('layoutPreview', () => { + test('the current Videos layout uses the real path', () => { + const rows = previewRows({ folderName: 'Kids', baseName: 'data', layout: 'videos', currentLayout: 'videos', example, flat: false }); + expect(rows.map((row) => `${row.prefix}${row.name}`)).toEqual([ + '__Kids/', + '\u2514\u2500 Blippi/', + ' \u251c\u2500 poster.jpg', + ' \u2514\u2500 Blippi - Fire Trucks - abcDEF12345/', + ' \u2514\u2500 Blippi - Fire Trucks [abcDEF12345].mp4', + ]); + }); + + test('the TV preview computes the season and episode from the upload time in UTC', () => { + const rows = previewRows({ folderName: '', baseName: 'data', layout: 'tv', currentLayout: 'videos', example, flat: false }); + expect(rows.map((row) => row.name)).toEqual([ + 'data/', '.plexignore', 'Blippi/', 'tvshow.nfo, poster.jpg', 'Season 2026/', 'S2026E09281530 - Fire Trucks [abcDEF12345].mp4', + ]); + expect(readsAs('tv', example)).toBe('Shows up as the show Blippi, Season 2026, episode 09281530: uploaded Sep 28, 15:30 UTC.'); + }); + + test('flat structure drops the per-video folder; an upload date reads as midnight', () => { + const dated = exampleFrom({ ...stored, relativePath: '', uploadedAt: '2026-09-28T00:00:00.000Z', uploadedAtSource: 'upload_date' as const }); + const rows = previewRows({ folderName: 'Kids', baseName: 'data', layout: 'videos', currentLayout: 'tv', example: dated, flat: true }); + expect(rows.map((row) => row.name)).toEqual(['__Kids/', 'Blippi/', 'poster.jpg', 'Blippi - Fire Trucks [abcDEF12345].mp4']); + expect(readsAs('tv', dated)).toBe('Shows up as the show Blippi, Season 2026, episode 09280000: uploaded Sep 28 (no time recorded, read as 00:00 UTC).'); + }); + + test('falls back to the sample video without a download', () => { + expect(exampleFrom(null)).toMatchObject({ channelName: 'Channel Name', title: 'Video Title', youtubeId: 'aBcD3fGh1jK', real: false }); + }); +}); diff --git a/client/src/components/LibraryFolders/__tests__/libraryTypes.test.ts b/client/src/components/LibraryFolders/__tests__/libraryTypes.test.ts new file mode 100644 index 000000000..70f075cf7 --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/libraryTypes.test.ts @@ -0,0 +1,34 @@ +import { folderServerPath, joinServerPath, serverLibraryType, setupRows } from '../libraryTypes'; + +describe('libraryTypes', () => { + test('joins a folder onto the server path in its own spelling', () => { + expect(joinServerPath('/data/yt', 'Kids')).toBe('/data/yt/__Kids'); + expect(joinServerPath('Q:\\Youtube_test\\', 'Kids')).toBe('Q:\\Youtube_test\\__Kids'); + expect(joinServerPath('\\\\nas\\media\\yt', '')).toBe('\\\\nas\\media\\yt'); + }); + + test('keeps the UNC separator for a subfolder', () => { + expect(folderServerPath('Kids', '\\\\nas\\media\\yt', 'Plex')).toEqual({ text: '\\\\nas\\media\\yt\\__Kids', copyable: true }); + }); + + test('describes the path when the server path is unknown', () => { + expect(folderServerPath('Kids', null, 'Plex')).toEqual({ text: '__Kids in your downloads folder, as Plex sees it', copyable: false }); + expect(folderServerPath('', undefined, 'Emby')).toEqual({ text: 'Your downloads folder, as Emby sees it', copyable: false }); + }); + + test('names library types per server and layout', () => { + expect(serverLibraryType('plex', 'videos')).toBe('Plex Other Videos'); + expect(serverLibraryType('emby', 'tv')).toBe('Emby TV shows'); + }); + + test('setup rows for a Jellyfin TV folder', () => { + const rows = setupRows('jellyfin', 'tv', { text: '/yt/__TV', copyable: true }); + expect(rows.map((row) => [row.key, row.value])).toEqual([ + ['Content type', 'Shows'], + ['Folder (as Jellyfin sees it)', '/yt/__TV'], + ['Metadata savers', 'Nfo: off'], + ['Downloaders', 'All off'], + ['Image fetchers', 'All off'], + ]); + }); +}); diff --git a/client/src/components/LibraryFolders/__tests__/mediaServerText.test.ts b/client/src/components/LibraryFolders/__tests__/mediaServerText.test.ts new file mode 100644 index 000000000..e495d1b48 --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/mediaServerText.test.ts @@ -0,0 +1,87 @@ +import { afterwardsLine, checkStatusText, footerNote, inspectorIntro, statusDescription } from '../mediaServerText'; +import type { LibraryFolder } from '../../../types/tvShows'; +import type { LibraryCheckResponse } from '../../../types/libraryCheck'; + +const f = (name: string, extra: Partial<LibraryFolder> = {}): LibraryFolder => ({ + name, layout: 'videos', isDefault: false, hasFiles: false, channels: 0, ...extra, +}); +const plex = { serverType: 'plex' as const, name: 'Plex' }; +const jellyfin = { serverType: 'jellyfin' as const, name: 'Jellyfin' }; +const wholeFolder: LibraryCheckResponse = { + servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null, downloadsPath: '/yt' }], + folders: [ + { name: '', layout: 'videos', hasFiles: false, channels: 0, servers: [{ serverType: 'plex', status: 'ok', issues: [], + libraries: [{ id: '37', name: 'YouTube', type: 'videos', location: '/yt', relation: 'exact' }] }] }, + { name: 'Kids', layout: 'videos', hasFiles: true, channels: 2, servers: [{ serverType: 'plex', status: 'ok', issues: [], + libraries: [{ id: '37', name: 'YouTube', type: 'videos', location: '/yt', relation: 'covers' }] }] }, + ], +}; + +describe('mediaServerText', () => { + test('a Videos subfolder held by a whole-folder library says so', () => { + expect(inspectorIntro({ folder: f('Kids', { channels: 2 }), folders: [f(''), f('Kids', { channels: 2 })], check: wholeFolder, servers: [plex] })) + .toEqual({ text: 'Shown by YouTube, which is pointed at the whole downloads folder.' }); + }); + + test('the main folder of a whole-folder setup without TV folders', () => { + expect(inspectorIntro({ folder: f(''), folders: [f(''), f('Kids', { channels: 2 })], check: wholeFolder, servers: [plex] })) + .toEqual({ text: 'YouTube shows your whole downloads folder, including every subfolder. That works while every folder uses Videos.' }); + }); + + test('a fresh install where everything downloads into the main folder', () => { + expect(inspectorIntro({ folder: f('', { isDefault: true }), folders: [f('', { isDefault: true })], check: null, servers: [plex, jellyfin] })) + .toEqual({ text: 'Everything downloads here. A Plex Other Videos and a Jellyfin Movies library on the downloads folder shows it.' }); + }); + + test('a TV subfolder needs its own library', () => { + expect(inspectorIntro({ folder: f('TV', { layout: 'tv', channels: 1 }), folders: [f(''), f('TV', { layout: 'tv', channels: 1 })], check: null, servers: [plex, jellyfin] })) + .toEqual({ text: "TV show folders need their own Plex TV Shows and Jellyfin Shows library, pointed at __TV itself. A library on a parent folder doesn't count on Jellyfin and Emby." }); + }); + + test('footer note names the servers not connected and Kodi', () => { + expect(footerNote(f('TV', { layout: 'tv' }), [plex])).toBe( + "Jellyfin and Emby aren't connected. Kodi isn't checked: add __TV as a TV shows source set to Local information only." + ); + expect(footerNote(f(''), [plex, jellyfin, { serverType: 'emby', name: 'Emby' }])).toBe("Kodi isn't checked."); + }); + + test('status description for screen readers', () => { + expect(statusDescription({ ...plex, display: 'ok', word: 'YouTube', report: wholeFolder.folders[1].servers[0] })) + .toBe('Plex: OK, shown by YouTube through the whole downloads folder.'); + }); + + test('afterwards lines per check result', () => { + expect(afterwardsLine(plex, { serverType: 'plex', status: 'missing', libraries: [], issues: [] }, { target: 'tv', label: '__Kids' })) + .toBe('Plex: needs a TV Shows library on __Kids.'); + expect(afterwardsLine(plex, null, { target: 'tv', label: '__Kids' })) + .toBe("Plex: couldn't be checked. It needs a TV Shows library on __Kids."); + }); + + test('holding videos in the main folder with no counted files does not say 0 videos', () => { + expect(inspectorIntro({ folder: f('', { hasFiles: true, fileCount: 0 }), folders: [f('', { hasFiles: true, fileCount: 0 })], check: null, servers: [plex] })?.text) + .toMatch(/^No library shows the files still in the main folder\. To watch them,/); + }); + + test('status description while checking has three dots', () => { + expect(statusDescription({ ...plex, display: 'checking', word: 'Checking...', report: null })).toBe('Plex: Checking...'); + }); +}); + +describe('checkStatusText', () => { + const base = { running: false, checked: [] as string[], unreachable: [] as Array<{ name: string; error: string | null }>, lastCheckedAt: 0, hasEarlierResults: true, error: null }; + const servers = [{ serverType: 'plex' as const, name: 'Plex' }, { serverType: 'jellyfin' as const, name: 'Jellyfin' }]; + + test('full, partial and failed checks', () => { + expect(checkStatusText({ ...base, kind: 'full', checked: ['Plex', 'Jellyfin'] }, servers, 30_000, null)).toBe('Checked Plex and Jellyfin just now'); + expect(checkStatusText({ ...base, kind: 'partial', checked: ['Plex'], unreachable: [{ name: 'Jellyfin', error: 'x' }] }, servers, 30_000, null)) + .toBe("Checked Plex just now. Jellyfin couldn't be reached."); + expect(checkStatusText({ ...base, kind: 'failed' }, servers, 5 * 60_000, null)).toBe('Last checked 5 min ago. The latest check failed.'); + expect(checkStatusText({ ...base, kind: 'failed', hasEarlierResults: false, lastCheckedAt: null }, servers, 0, null)).toBe('Not checked'); + }); + + test('running and no servers', () => { + expect(checkStatusText({ ...base, kind: 'full', running: true }, servers, 0, null)).toBe('Checking Plex and Jellyfin...'); + expect(checkStatusText({ ...base, kind: 'none' }, [], 0, null)).toBe('No media server connected.'); + expect(checkStatusText({ ...base, kind: 'checking', running: true }, [], 0, null)).toBe('No media server connected.'); + }); +}); diff --git a/client/src/components/LibraryFolders/__tests__/renderPage.tsx b/client/src/components/LibraryFolders/__tests__/renderPage.tsx new file mode 100644 index 000000000..67dd2059f --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/renderPage.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { TooltipProvider } from '../../ui/tooltip'; +import { DEFAULT_CONFIG } from '../../../config/configSchema'; +import type { LibraryFolder } from '../../../types/tvShows'; +import type { LibraryCheckResponse } from '../../../types/libraryCheck'; +import { LibraryPageProvider, LibraryPageValue } from '../LibraryFoldersContext'; + +export const folder = (name: string, extra: Partial<LibraryFolder> = {}): LibraryFolder => ({ + name, layout: 'videos', isDefault: false, hasFiles: false, channels: 0, + channelsChosen: 0, channelsFollowing: 0, playlists: 0, titleShows: 0, fileCount: 0, + layoutChangeNeedsReview: false, makeDefaultNeedsReview: false, + plexMapping: { choice: 'none', libraryId: null }, deleteBlockers: [], deletable: true, ...extra, +}); + +export const emptyCheck: LibraryCheckResponse = { servers: [], folders: [] }; + +export function makePageValue(overrides: Partial<LibraryPageValue> = {}): LibraryPageValue { + return { + token: 'token', + config: { ...DEFAULT_CONFIG, youtubeOutputDirectory: '/usr/src/app/data' }, + isPlatformManaged: { plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false }, + timeZone: 'UTC', + phone: false, + twoColumn: true, + folders: [folder('', { isDefault: true })], + foldersLoaded: true, + mainDetail: null, + check: { data: emptyCheck, loading: false, error: null, lastCheckedAt: Date.now(), refetch: jest.fn().mockResolvedValue(undefined), applyPlexMapping: jest.fn().mockResolvedValue(undefined) }, + configuredServers: [], + serversKnown: true, + servers: [], + plexLibraries: [], + plexConnectionStatus: 'not_tested', + reorganizing: false, + movingFolders: [], + layoutResult: null, + busyLayoutFolder: null, + changeLayout: jest.fn().mockResolvedValue(undefined), + selectFolder: jest.fn(), + openAddFolder: jest.fn(), + openStartTv: jest.fn(), + openMainFolderTv: jest.fn(), + openMakeDefault: jest.fn(), + openDelete: jest.fn(), + reviewChange: jest.fn(), + jumpTo: jest.fn(), + focusTarget: null, + clearFocusTarget: jest.fn(), + notify: jest.fn(), + ...overrides, + }; +} + +/** The tree renderInPage renders, for rerender with a new page value. */ +export function inPage(ui: React.ReactElement, value: LibraryPageValue = makePageValue(), route = '/settings/library') { + return ( + <MemoryRouter initialEntries={[route]}> + <TooltipProvider> + <LibraryPageProvider value={value}> + <Routes> + <Route path="/settings/library/*" element={ui} /> + <Route path="*" element={<div>other page</div>} /> + </Routes> + </LibraryPageProvider> + </TooltipProvider> + </MemoryRouter> + ); +} + +export function renderInPage(ui: React.ReactElement, { value = makePageValue(), route = '/settings/library' }: { + value?: LibraryPageValue; route?: string; +} = {}) { + return render(inPage(ui, value, route)); +} diff --git a/client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts b/client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts new file mode 100644 index 000000000..600c3578b --- /dev/null +++ b/client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts @@ -0,0 +1,82 @@ +import { detectSetup, pathOptions, pathSteps, setupLines } from '../tvSetupPaths'; +import type { LibraryFolder } from '../../../types/tvShows'; +import type { LibraryCheckResponse } from '../../../types/libraryCheck'; + +const f = (name: string, extra: Partial<LibraryFolder> = {}): LibraryFolder => ({ + name, layout: 'videos', isDefault: false, hasFiles: false, channels: 0, ...extra, +}); +const servers = [{ serverType: 'jellyfin' as const, name: 'Jellyfin' }]; +const check = (mainExact: boolean, kidsExact: boolean): LibraryCheckResponse => ({ + servers: [{ serverType: 'jellyfin', name: 'Jellyfin', reachable: true, error: null, downloadsPath: '/yt' }], + folders: [ + { name: '', layout: 'videos', hasFiles: true, channels: 3, servers: [{ serverType: 'jellyfin', status: mainExact ? 'ok' : 'missing', issues: [], + libraries: mainExact ? [{ id: '1', name: 'YouTube', type: 'videos', location: '/yt', relation: 'exact' }] : [] }] }, + { name: 'Kids', layout: 'videos', hasFiles: true, channels: 2, servers: [{ serverType: 'jellyfin', status: 'ok', issues: [], + libraries: [kidsExact + ? { id: '2', name: 'Kids', type: 'videos', location: '/yt/__Kids', relation: 'exact' } + : { id: '1', name: 'YouTube', type: 'videos', location: '/yt', relation: 'covers' }] }] }, + ], +}); +const folders = [f('', { channels: 3, channelsChosen: 3, fileCount: 10 }), f('Kids', { channels: 2, fileCount: 5 })]; + +describe('tvSetupPaths', () => { + test('detects a whole-folder library with channels in the main folder', () => { + const setup = detectSetup(folders, check(true, false), servers); + expect(setup.servers[0]).toMatchObject({ kind: 'whole', whole: { name: 'YouTube', location: '/yt' } }); + expect(setupLines(setup)).toEqual([ + '1 Video folder in use: __Kids.', + '3 channels download straight into the main folder.', + 'Jellyfin: YouTube (Movies) shows your whole downloads folder.', + ]); + }); + + test('path A recommended; B unavailable while Video folders are in use on Jellyfin', () => { + const options = pathOptions(detectSetup(folders, check(true, false), servers), servers); + expect(options.map((option) => [option.key, option.recommended, option.disabledReason])).toEqual([ + ['A', true, null], + ['B', false, 'You use Video folders (__Kids): Jellyfin and Emby would show each one as an extra show.'], + ['C', false, null], + ]); + }); + + test('path A steps edit the whole-folder library with exact paths', () => { + const steps = pathSteps('A', detectSetup(folders, check(true, false), servers), {}); + expect(steps.youtarr.map((step) => step.text)).toEqual([ + 'Give the 3 channels in the main folder a Video folder (Channel Settings > Library folder moves their videos).', + 'Add a TV show folder.', + 'Switch the channels you want as shows to it: Channel Settings > Library folder, or TV Show > Show this channel as TV show. Each switch shows its moves first.', + ]); + expect(steps.servers[0].steps[0]).toMatchObject({ + text: 'Edit YouTube: remove /yt and add your Video folders:', + paths: [{ text: '/yt/__Kids', copyable: true }], + after: 'It keeps its type.', + }); + }); + + test('path C with a library on the folder itself says to rebuild it', () => { + const steps = pathSteps('C', detectSetup(folders, check(false, true), servers), { folder: 'Kids' }); + expect(steps.youtarr[0]).toMatchObject({ text: 'Open __Kids and choose Move to TV shows. You review every move first.', action: { kind: 'openFolder', folder: 'Kids' } }); + expect(steps.servers[0].steps[0].text).toBe("Jellyfin can't change a library's type: remove Kids and create a Shows library pointed at /yt/__Kids."); + }); + + test('path C whole-folder lists the other Video folders', () => { + const withTwo = [...folders, f('Music', { channels: 1 })]; + const steps = pathSteps('C', detectSetup(withTwo, check(true, false), servers), { folder: 'Kids' }); + expect(steps.servers[0].steps[0]).toMatchObject({ + text: 'Edit YouTube: remove /yt and add your other Video folders:', + paths: [{ text: '/yt/__Music', copyable: true }], + }); + }); + + test('path C whole-folder with no other Video folder lists no paths', () => { + const step = pathSteps('C', detectSetup(folders, check(true, false), servers), { folder: 'kids' }).servers[0].steps[0]; + expect(step.text).toBe('Remove YouTube: no other Video folder is in use.'); + expect(step.paths).toBeUndefined(); + expect(step.after).toBe('Then add a Shows library pointed at /yt/__kids only.'); + }); + + test('without servers the steps cover each server type', () => { + const steps = pathSteps('A', detectSetup(folders, null, []), {}); + expect(steps.servers.map((group) => group.name)).toEqual(['Plex', 'Jellyfin', 'Emby']); + }); +}); diff --git a/client/src/components/LibraryFolders/components/AfterwardsNote.tsx b/client/src/components/LibraryFolders/components/AfterwardsNote.tsx new file mode 100644 index 000000000..adf4c4142 --- /dev/null +++ b/client/src/components/LibraryFolders/components/AfterwardsNote.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { cn } from '../../../lib/cn'; +import { CircularProgress } from '../../ui'; +import type { LibraryFolder, LibraryLayout } from '../../../types/tvShows'; +import { useLibraryCheck } from '../../../hooks/useLibraryCheck'; +import { isOverlapIssue, joinNames, reportFor } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { folderLabel } from '../folderText'; +import { afterwardsLine, afterwardsWithoutServers } from '../mediaServerText'; + +/** What each server would need after the change: the check run as the target layout (UI 5.7.3). */ +export function AfterwardsNote({ folder, target }: { folder: LibraryFolder; target: LibraryLayout }) { + const page = useLibraryPage(); + const check = useLibraryCheck(page.token, { folders: [folder.name], layout: target, enabled: page.servers.length > 0 }); + const report = reportFor(check.data, folder.name); + const label = folderLabel(folder.name, true); + return ( + <div className="mt-2.5 text-[12.5px]"> + <p className="font-semibold">Afterwards on your media servers</p> + {page.servers.length === 0 && <p>{afterwardsWithoutServers(target)}</p>} + {page.servers.length > 0 && check.loading && !check.data && ( + <p className="flex items-center gap-1.5"> + <CircularProgress size={12} />Checking what {joinNames(page.servers.map((server) => server.name))} would need... + </p> + )} + {page.servers.length > 0 && (check.data || check.error) && page.servers.map((server) => { + const entry = check.error ? null : report?.servers.find((item) => item.serverType === server.serverType) ?? null; + const overlap = Boolean(entry?.issues.some((issue) => isOverlapIssue(issue.code))); + return ( + <p key={server.serverType}> + {afterwardsLine(server, entry, { target, label })} + {overlap && <> <button type="button" onClick={page.openStartTv} className={cn('text-primary underline', page.phone && 'inline-flex min-h-[44px] items-center')}>See all your options</button></>} + </p> + ); + })} + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/AttentionStrip.tsx b/client/src/components/LibraryFolders/components/AttentionStrip.tsx new file mode 100644 index 000000000..c25a0e53b --- /dev/null +++ b/client/src/components/LibraryFolders/components/AttentionStrip.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { ChevronRight, RefreshCw, Warning } from '../../../lib/icons'; +import { CircularProgress } from '../../ui'; +import { useNow } from '../../Configuration/hooks/useNow'; +import { AttentionItem, checkStatus } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { checkStatusText } from '../mediaServerText'; + +/** Needs attention: library-wide items first, then folders (UI 4.5, 5.5, 6.2). */ +export function AttentionStrip({ items }: { items: AttentionItem[] }) { + const page = useLibraryPage(); + const now = useNow(); + if (items.length === 0) return null; + const jump = (item: AttentionItem) => (item.kind === 'library' ? page.jumpTo(item.folders[0], item.serverType) : page.jumpTo(item.folder)); + + if (page.phone) { + const status = checkStatusText(checkStatus(page.check, page.configuredServers), page.servers, now, page.timeZone); + return ( + <section aria-label="Needs attention" className="rounded-ui border border-warning bg-card"> + <div className="flex items-center gap-2.5 p-3"> + <Warning size={18} aria-hidden="true" className="text-warning" /> + <div className="min-w-0 flex-1"> + <p className="font-semibold text-warning">Needs attention</p> + <p className="text-xs text-muted-foreground">{status}</p> + </div> + <button type="button" aria-label="Check media servers again" onClick={() => { void page.check.refetch(); }} + className="inline-flex h-11 w-11 items-center justify-center rounded-ui text-muted-foreground"> + {page.check.loading ? <CircularProgress size={16} /> : <RefreshCw size={18} aria-hidden="true" />} + </button> + </div> + {items.map((item) => ( + <button key={item.key} type="button" onClick={() => jump(item)} + className="flex min-h-[44px] w-full items-center gap-2 border-t border-border py-2 pl-10 pr-3 text-left text-[13px] font-medium text-primary"> + <span className="min-w-0 flex-1">{item.text}</span> + <ChevronRight size={16} aria-hidden="true" /> + </button> + ))} + </section> + ); + } + + return ( + <div role="status" className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 rounded-ui border border-warning bg-card px-3 py-[9px] text-[13px]"> + <span className="inline-flex items-center gap-1.5 font-semibold text-warning"> + <Warning size={15} aria-hidden="true" />Needs attention: + </span> + {items.map((item, index) => ( + <span key={item.key}> + <button type="button" onClick={() => jump(item)} className="text-primary underline">{item.text}</button> + {index < items.length - 1 ? ',' : ''} + </span> + ))} + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/ChannelLinks.tsx b/client/src/components/LibraryFolders/components/ChannelLinks.tsx new file mode 100644 index 000000000..7a39a2801 --- /dev/null +++ b/client/src/components/LibraryFolders/components/ChannelLinks.tsx @@ -0,0 +1,48 @@ +import React, { Fragment, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { cn } from '../../../lib/cn'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { countOf, SEP } from '../folderText'; + +const VISIBLE = 8; + +export interface ChannelLink { + channelId: string; + name: string; + videoCount?: number; +} + +/** Channel page links, the first eight then "and N more" (UI 5.7.2, 5.7.4). Phones get 44px rows. */ +export function ChannelLinks({ channels, unit }: { channels: ChannelLink[]; unit?: { one: string; many: string } }) { + const { phone } = useLibraryPage(); + const [all, setAll] = useState(false); + const shown = all ? channels : channels.slice(0, VISIBLE); + const count = (channel: ChannelLink) => (unit && channel.videoCount !== undefined + ? <span className="text-xs text-muted-foreground"> {countOf(channel.videoCount, unit.one, unit.many)}</span> : null); + const more = !all && channels.length > VISIBLE + ? <button type="button" onClick={() => setAll(true)} className={cn('text-primary underline', phone && 'min-h-[44px]')}>and {channels.length - VISIBLE} more</button> + : null; + if (phone) { + return ( + <ul className="flex flex-col"> + {shown.map((channel) => ( + <li key={channel.channelId} className="flex min-h-[44px] items-center"> + <Link to={`/channel/${channel.channelId}`} className="text-primary">{channel.name}</Link>{count(channel)} + </li> + ))} + {more && <li>{more}</li>} + </ul> + ); + } + return ( + <span className="text-[13px]"> + {shown.map((channel, index) => ( + <Fragment key={channel.channelId}> + {index > 0 && <span aria-hidden="true" className="text-muted-foreground/70">{SEP}</span>} + <Link to={`/channel/${channel.channelId}`} className="text-primary">{channel.name}</Link>{count(channel)} + </Fragment> + ))} + {more ? <> {more}</> : null} + </span> + ); +} diff --git a/client/src/components/LibraryFolders/components/CheckBanners.tsx b/client/src/components/LibraryFolders/components/CheckBanners.tsx new file mode 100644 index 000000000..e8be39927 --- /dev/null +++ b/client/src/components/LibraryFolders/components/CheckBanners.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Warning, XCircle } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; +import { useLibraryPage } from '../LibraryFoldersContext'; + +const SETTINGS_PATH = { plex: '/settings/plex', jellyfin: '/settings/jellyfin', emby: '/settings/emby' } as const; + +/** Unreachable servers, a failed check, and an unknown folder URL (UI 5.3). */ +export function CheckBanners({ missingName, onDismissMissing }: { missingName: string | null; onDismissMissing: () => void }) { + const page = useLibraryPage(); + const unreachable = page.check.data?.servers.filter((server) => !server.reachable) ?? []; + const button = cn('text-primary underline', page.phone && 'inline-flex min-h-[44px] items-center'); + const box = 'flex flex-wrap items-center gap-x-3 gap-y-1 rounded-ui border border-warning bg-card px-3 py-2 text-[13px]'; + return ( + <> + {unreachable.map((server) => ( + <div key={server.serverType} role="status" className={box}> + <Warning size={15} aria-hidden="true" className="text-warning" /> + <span className="min-w-0 flex-1">{`${server.name} couldn't be reached, so its libraries weren't checked: `}{server.error}</span> + <button type="button" onClick={() => { void page.check.refetch(); }} className={button}>Check again</button> + <Link to={SETTINGS_PATH[server.serverType]} className={button}>{server.name} settings</Link> + </div> + ))} + {page.check.error && ( + <div role="status" className={box}> + <Warning size={15} aria-hidden="true" className="text-warning" /> + <span className="min-w-0 flex-1">Couldn't check the media server libraries: {page.check.error}</span> + <button type="button" onClick={() => { void page.check.refetch(); }} className={button}>Try again</button> + </div> + )} + {missingName !== null && ( + <div role="status" className="flex items-center gap-2 rounded-ui border border-border bg-card px-3 py-2 text-[13px]"> + <span className="min-w-0 flex-1">{`Library folder __${missingName} wasn't found. It may have been deleted.`}</span> + <button type="button" aria-label="Dismiss" onClick={onDismissMissing} className="inline-flex h-11 w-11 items-center justify-center text-muted-foreground"> + <XCircle size={16} aria-hidden="true" /> + </button> + </div> + )} + </> + ); +} diff --git a/client/src/components/LibraryFolders/components/CopyButton.tsx b/client/src/components/LibraryFolders/components/CopyButton.tsx new file mode 100644 index 000000000..c92aed651 --- /dev/null +++ b/client/src/components/LibraryFolders/components/CopyButton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Copy } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { COPY_FAILED_MESSAGE, copyText } from '../copyText'; + +export function CopyButton({ text, label = 'Copy path' }: { text: string; label?: string }) { + const { phone, notify } = useLibraryPage(); + const copy = async () => { + if (await copyText(text)) notify('Copied'); + else notify(COPY_FAILED_MESSAGE, 'error'); + }; + return ( + <button type="button" aria-label={label} onClick={() => { void copy(); }} + className={cn('inline-flex shrink-0 items-center justify-center rounded-ui text-muted-foreground hover:bg-muted/40 hover:text-foreground', + phone ? 'h-11 w-11' : 'h-[26px] w-[26px]')}> + <Copy size={14} aria-hidden="true" /> + </button> + ); +} diff --git a/client/src/components/LibraryFolders/components/DeleteSection.tsx b/client/src/components/LibraryFolders/components/DeleteSection.tsx new file mode 100644 index 000000000..823e881f9 --- /dev/null +++ b/client/src/components/LibraryFolders/components/DeleteSection.tsx @@ -0,0 +1,23 @@ +import React, { useId } from 'react'; +import { Trash2 } from '../../../lib/icons'; +import { Button } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { deleteReasonText } from '../folderText'; + +export function DeleteSection({ folder }: { folder: LibraryFolder }) { + const { phone, openDelete } = useLibraryPage(); + const reasonId = useId(); + const deletable = Boolean(folder.name) && Boolean(folder.deletable); + return ( + <section aria-label="Delete" className={cn('flex gap-3 px-4 pb-4 pt-3', phone ? 'flex-col' : 'items-center')}> + <Button variant="outlined" color={deletable ? 'error' : 'inherit'} disabled={!deletable} aria-describedby={reasonId} + startIcon={<Trash2 size={14} aria-hidden="true" />} onClick={() => openDelete(folder)} + className={phone ? 'min-h-[44px] w-full' : 'h-[30px] shrink-0'}> + Delete folder + </Button> + <p id={reasonId} className={cn('text-muted-foreground', phone ? 'text-[13px]' : 'text-[12.5px]')}>{deleteReasonText(folder)}</p> + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/DownloadsHereSection.tsx b/client/src/components/LibraryFolders/components/DownloadsHereSection.tsx new file mode 100644 index 000000000..8d614b813 --- /dev/null +++ b/client/src/components/LibraryFolders/components/DownloadsHereSection.tsx @@ -0,0 +1,105 @@ +import React, { useId } from 'react'; +import { Link } from 'react-router-dom'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder, LibraryFolderDetail } from '../../../types/tvShows'; +import { folderState } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { countOf, folderLabel, formatCount, units } from '../folderText'; +import { ChannelLinks } from './ChannelLinks'; +import { SectionHeading } from './SectionHeading'; + +const ROUTING_NOTE = 'A channel picks its folder in Channel Settings > Library folder, a playlist in its own settings, and a ' + + 'single download in its download options. Channel settings come first, then playlist defaults; a folder chosen for one ' + + 'download overrides both.'; +const FALLBACK = "Downloads with no more specific folder land here: videos from channels you don't subscribe to, unless a " + + 'playlist or a single download picks another folder.'; + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + const { phone } = useLibraryPage(); + return ( + <div className={cn('text-[13px]', phone ? 'flex flex-col gap-0.5' : 'grid grid-cols-[88px_1fr] gap-2.5')}> + <span className={cn('text-muted-foreground', phone ? 'text-[11px] uppercase' : 'text-xs')}>{label}</span> + <div>{children}</div> + </div> + ); +} + +/** What downloads into the folder (UI 5.7.4). */ +export function DownloadsHereSection({ folder, detail }: { folder: LibraryFolder; detail: LibraryFolderDetail | null }) { + const page = useLibraryPage(); + const headingId = useId(); + const state = folderState(folder); + const unit = units(folder.layout); + const count = folder.fileCount ?? 0; + const onDisk = count > 0 ? `${countOf(count, unit.one, unit.many)} on disk` : folder.hasFiles ? "Holds files Youtarr doesn't track" : 'Nothing on disk'; + const label = folderLabel(folder.name, true); + const followers = detail?.followers ?? { count: 0, sample: [] }; + const chosen = detail?.channels ?? []; + const inlineLink = cn('text-primary', page.phone && 'inline-flex min-h-[44px] items-center'); + + return ( + <section aria-labelledby={headingId} className="flex flex-col gap-1.5 px-4 pb-4 pt-3.5"> + <div className="flex items-baseline justify-between gap-2"> + <SectionHeading id={headingId}>Downloads here</SectionHeading> + <span className="text-xs text-muted-foreground">{onDisk}</span> + </div> + {state === 'holdsVideos' && ( + <p className="text-[13px]"> + Nothing downloads here now. {count > 0 ? `${countOf(count, unit.one, unit.many)} ${count === 1 ? 'is' : 'are'} on disk.` : "It holds files Youtarr doesn't track."} + </p> + )} + {state === 'unused' && ( + <p className="flex flex-wrap items-center gap-2 text-[13px]"> + Nothing downloads here yet. + <Link to="/subscriptions" className={cn('rounded-ui border border-primary px-2 text-primary', page.phone ? 'inline-flex min-h-[44px] items-center' : 'py-0.5')}> + Choose channels for {label} + </Link> + </p> + )} + {chosen.length > 0 && ( + <Row label="Channels"> + <p className="font-semibold">{formatCount(chosen.length)} chose this folder</p> + <ChannelLinks channels={chosen} unit={unit} /> + </Row> + )} + {folder.isDefault && followers.count > 0 && ( + <Row label={chosen.length > 0 ? '' : 'Channels'}> + <p className="font-semibold">{formatCount(followers.count)} {followers.count === 1 ? 'follows' : 'follow'} the default</p> + <p className="text-[12.5px] text-muted-foreground"> + Set to the default folder: {followers.sample.join(', ')} + {followers.count > followers.sample.length ? ` and ${formatCount(followers.count - followers.sample.length)} more` : ''} + </p> + </Row> + )} + {(detail?.playlists.length ?? 0) > 0 && detail && ( + <Row label="Playlists"> + <p className="font-semibold">{formatCount(detail.playlists.length)} {detail.playlists.length === 1 ? 'uses it as its default' : 'use it as their default'}</p> + <p>{detail.playlists.map((playlist, index) => ( + <span key={playlist.playlistId}> + {index > 0 ? ', ' : ''} + <Link to={`/playlist/${playlist.playlistId}`} className={inlineLink}>{playlist.name}</Link> + <span className="text-xs text-muted-foreground"> {countOf(playlist.videoCount, unit.one, unit.many)}</span> + </span> + ))}</p> + <p className="text-xs text-muted-foreground">A playlist's videos land here only when their channel has no folder of its own.</p> + </Row> + )} + {(detail?.titleShows.length ?? 0) > 0 && detail && ( + <Row label="Title shows"> + {detail.titleShows.map((show) => ( + <p key={show.id}> + <Link to={`/channel/${show.channelId}`} className={inlineLink}>{show.name}</Link> + <span className="text-muted-foreground"> from {show.channelName}, {countOf(show.episodeCount, 'episode', 'episodes')} (Channel Settings > TV Show)</span> + </p> + ))} + </Row> + )} + {folder.isDefault && ( + <Row label="Fallback"> + <p>{FALLBACK}{folder.layout === 'tv' ? ' Each of those channels becomes its own show.' : ''}</p> + </Row> + )} + <p className="mt-1 text-xs text-muted-foreground">{ROUTING_NOTE}</p> + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/FolderDetailScreen.tsx b/client/src/components/LibraryFolders/components/FolderDetailScreen.tsx new file mode 100644 index 000000000..a80159449 --- /dev/null +++ b/client/src/components/LibraryFolders/components/FolderDetailScreen.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { ArrowLeft } from '../../../lib/icons'; +import { Button } from '../../ui'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { folderKey } from '../../../utils/libraryLayouts'; +import { FolderInspector } from './FolderInspector'; + +/** The narrow layouts' folder screen: a back bar over the inspector at full column width (UI 6.3, 6.4). */ +export function FolderDetailScreen({ folder, onBack }: { folder: LibraryFolder; onBack: () => void }) { + return ( + <div className="mx-auto w-full max-w-[720px]"> + <div className="flex h-[52px] items-center border-b border-border"> + <Button variant="text" startIcon={<ArrowLeft size={16} aria-hidden="true" />} onClick={onBack} className="min-h-[44px]"> + Library folders + </Button> + </div> + <FolderInspector key={folderKey(folder.name) || '~main'} folder={folder} asPageTitle /> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/FolderInspector.tsx b/client/src/components/LibraryFolders/components/FolderInspector.tsx new file mode 100644 index 000000000..19b5a4559 --- /dev/null +++ b/client/src/components/LibraryFolders/components/FolderInspector.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useRef } from 'react'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { folderKey } from '../../../utils/libraryLayouts'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { useLibraryFolderDetail } from '../hooks/useLibraryFolderDetail'; +import { DeleteSection } from './DeleteSection'; +import { DownloadsHereSection } from './DownloadsHereSection'; +import { InspectorHeader } from './InspectorHeader'; +import { LayoutSection } from './LayoutSection'; +import { MediaServersSection } from './MediaServersSection'; + +/** + * The selected folder: Media servers, Layout, Downloads here, Delete (UI 5.7). Remounted per folder so its + * preview resets: the page keys the scrolling inspector aside by folder (a new folder starts at the top), the + * detail screen keys this component. + */ +export function FolderInspector({ folder, asPageTitle = false }: { folder: LibraryFolder; asPageTitle?: boolean }) { + const page = useLibraryPage(); + // The page already loads the main folder's detail. + const { detail: ownDetail } = useLibraryFolderDetail(page.token, folder.name ? folder.name : null); + const detail = folder.name ? ownDetail : page.mainDetail; + const headingRef = useRef<HTMLHeadingElement>(null); + const { focusTarget, clearFocusTarget } = page; + + useEffect(() => { + if (!focusTarget || folderKey(focusTarget.folder) !== folderKey(folder.name)) return; + headingRef.current?.focus({ preventScroll: true }); + if (focusTarget.serverType) { + // jsdom does not implement scrollIntoView + document.getElementById(`server-card-${focusTarget.serverType}`)?.scrollIntoView?.({ block: 'nearest' }); + } + clearFocusTarget(); + }, [focusTarget, clearFocusTarget, folder.name]); + + return ( + <div className="flex flex-col divide-y divide-border"> + <InspectorHeader folder={folder} headingRef={headingRef} asPageTitle={asPageTitle} /> + <MediaServersSection folder={folder} /> + <LayoutSection folder={folder} detail={detail} /> + <DownloadsHereSection folder={folder} detail={detail} /> + <DeleteSection folder={folder} /> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/FolderRow.tsx b/client/src/components/LibraryFolders/components/FolderRow.tsx new file mode 100644 index 000000000..1031a163f --- /dev/null +++ b/client/src/components/LibraryFolders/components/FolderRow.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useRef } from 'react'; +import { ChevronRight, Film, Star, Tv } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder } from '../../../types/tvShows'; +import type { ServerStatus } from '../../../utils/libraryAttention'; +import { libraryFolderLabel } from '../../../utils/libraryLayouts'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { rowSummary } from '../folderText'; +import { StatusCell, StatusLine } from './StatusCell'; + +export interface FolderRowProps { + folder: LibraryFolder; + statuses: ServerStatus[]; + selected: boolean; + /** Status cells in a right-hand column; else the status line under the name */ + wide: boolean; + moving: boolean; + autoFocus?: boolean; + onFocused?: () => void; + onSelect: (name: string) => void; +} + +/** An Active or Holds-videos folder (and the main folder) on its shelf (UI 5.6, 6.2). */ +export function FolderRow({ folder, statuses, selected, wide, moving, autoFocus = false, onFocused, onSelect }: FolderRowProps) { + const { phone, twoColumn } = useLibraryPage(); + const ref = useRef<HTMLButtonElement>(null); + useEffect(() => { + if (!autoFocus) return; + ref.current?.focus(); + onFocused?.(); + }, [autoFocus, onFocused]); + const Icon = folder.layout === 'tv' ? Tv : Film; + // In two columns a row selects into the inspector beside it; a chevron would suggest a new screen. + const chevron = !wide && !twoColumn; + return ( + <button + ref={ref} + type="button" + onClick={() => onSelect(folder.name)} + aria-current={selected ? 'true' : undefined} + aria-controls={twoColumn ? 'library-inspector' : undefined} + className={cn( + 'grid w-full items-start border-t border-border text-left transition-colors hover:bg-muted/40', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring', + wide ? 'grid-cols-[16px_minmax(0,1fr)_auto] gap-x-2.5 gap-y-[3px] px-3 pb-2.5 pt-[9px]' + : chevron ? 'grid-cols-[18px_minmax(0,1fr)_18px] gap-x-2.5 gap-y-1 py-2.5 pl-3 pr-1' + : 'grid-cols-[18px_minmax(0,1fr)] gap-x-2.5 gap-y-1 px-3 py-2.5', + phone && 'min-h-[64px]', + selected && 'bg-muted/40 ring-1 ring-inset ring-primary' + )} + > + <Icon size={wide ? 16 : 18} aria-hidden="true" className={cn('mt-0.5', selected ? 'text-primary' : 'text-muted-foreground')} /> + <span className="flex min-w-0 flex-wrap items-center gap-1.5"> + <span className="truncate text-sm font-semibold text-foreground">{libraryFolderLabel(folder.name)}</span> + {folder.isDefault && ( + <span className="inline-flex items-center gap-1 rounded-ui border border-primary px-1.5 text-[11.5px] text-primary"> + <Star size={11} aria-hidden="true" />Default + </span> + )} + {moving && <span className="rounded-ui border border-info px-1.5 text-[11.5px] text-info">Moving</span>} + </span> + {wide && <span className="flex gap-1.5">{statuses.map((status) => <StatusCell key={status.serverType} status={status} />)}</span>} + {chevron && <ChevronRight size={18} aria-hidden="true" data-testid="row-chevron" className="row-span-3 self-center text-muted-foreground" />} + <span className={cn('text-[12.5px] text-muted-foreground', wide ? 'col-span-2 col-start-2' : 'col-start-2')}> + {rowSummary(folder)} + </span> + {!wide && statuses.length > 0 && <span className="col-start-2"><StatusLine statuses={statuses} /></span>} + </button> + ); +} diff --git a/client/src/components/LibraryFolders/components/FolderShelf.tsx b/client/src/components/LibraryFolders/components/FolderShelf.tsx new file mode 100644 index 000000000..9c0245548 --- /dev/null +++ b/client/src/components/LibraryFolders/components/FolderShelf.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useId, useState } from 'react'; +import { ChevronDown, ChevronUp, Film, Plus, Tv } from '../../../lib/icons'; +import { Button } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder, LibraryLayout } from '../../../types/tvShows'; +import { useContainerWidth } from '../../../hooks/useContainerWidth'; +import { folderState, serverStatuses } from '../../../utils/libraryAttention'; +import { folderKey } from '../../../utils/libraryLayouts'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { GENERIC_LIBRARY_TYPES, libraryTypeName } from '../libraryTypes'; +import { SEP } from '../folderText'; +import { FolderRow } from './FolderRow'; +import { UnusedFolderRow } from './UnusedFolderRow'; + +const UNUSED_VISIBLE = 3; +const ROW_BASE_WIDTH = 300; +const STATUS_CELL_WIDTH = 120; + +const SHELF = { + tv: { + title: 'TV show folders', rule: 'Each channel is a show, each upload year a season, each video an episode.', + add: 'Add TV folder', addLabel: 'Add a TV show folder', Icon: Tv, + }, + videos: { + title: 'Video folders', rule: 'Each video is a movie, filed in its channel\'s folder.', + add: 'Add video folder', addLabel: 'Add a video folder', Icon: Film, + }, +} as const; + +const byName = (a: LibraryFolder, b: LibraryFolder) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + +/** Main folder, default folder, other Active A-Z, Holds videos A-Z; Unused A-Z separately (UI 5.6). */ +export function sortShelf(folders: LibraryFolder[]): { rows: LibraryFolder[]; unused: LibraryFolder[] } { + const main = folders.filter((folder) => !folder.name); + const rest = folders.filter((folder) => folder.name); + return { + rows: [ + ...main, + ...rest.filter((folder) => folder.isDefault), + ...rest.filter((folder) => !folder.isDefault && folderState(folder) === 'active').sort(byName), + ...rest.filter((folder) => folderState(folder) === 'holdsVideos').sort(byName), + ], + unused: rest.filter((folder) => folderState(folder) === 'unused').sort(byName), + }; +} + +export interface FolderShelfProps { + layout: LibraryLayout; + folders: LibraryFolder[]; + selectedName: string | null; + /** Focus this folder's row once (after Back on narrow layouts) */ + focusName: string | null; + onFocused: () => void; + headingRef?: React.Ref<HTMLHeadingElement>; +} + +export function FolderShelf({ layout, folders, selectedName, focusName, onFocused, headingRef }: FolderShelfProps) { + const page = useLibraryPage(); + const titleId = useId(); + const [measureRef, width] = useContainerWidth<HTMLDivElement>(); + const wide = !page.phone && width !== null && width >= ROW_BASE_WIDTH + page.servers.length * STATUS_CELL_WIDTH; + const { rows, unused } = sortShelf(folders); + const isSelected = (folder: LibraryFolder) => selectedName !== null && folderKey(folder.name) === folderKey(selectedName); + const isFocus = (folder: LibraryFolder) => focusName !== null && folderKey(folder.name) === folderKey(focusName); + const hiddenTarget = unused.slice(UNUSED_VISIBLE).some((folder) => isSelected(folder) || isFocus(folder)); + const [expanded, setExpanded] = useState(hiddenTarget); + useEffect(() => { + if (hiddenTarget) setExpanded(true); + }, [hiddenTarget]); + const visibleUnused = expanded ? unused : unused.slice(0, UNUSED_VISIBLE); + const shelf = SHELF[layout]; + const needs = page.servers.map((server) => ({ server: server.name, type: libraryTypeName(server.serverType, layout) })); + const touch = page.phone ? 'min-h-[44px]' : 'h-[30px]'; + + return ( + <section aria-labelledby={titleId} className="rounded-ui border border-border bg-card"> + <div ref={measureRef}> + <div className="px-3 pb-2.5 pt-3"> + <div className="flex items-center gap-2"> + <shelf.Icon size={page.phone ? 18 : 16} aria-hidden="true" className="text-muted-foreground" /> + <h2 id={titleId} ref={headingRef} tabIndex={-1} className={cn('font-display font-semibold', page.phone ? 'text-[17px]' : 'text-base')}> + {shelf.title} + </h2> + <span className="rounded-ui border border-border px-1.5 text-xs text-muted-foreground">{folders.length}</span> + <span className="flex-1" /> + <Button variant={page.phone ? 'text' : 'outlined'} size="sm" startIcon={<Plus size={14} />} aria-label={page.phone ? shelf.addLabel : undefined} + onClick={() => page.openAddFolder(layout)} className={touch}> + {page.phone ? 'Add' : shelf.add} + </Button> + </div> + <p className="mt-1 text-[13px] text-muted-foreground">{shelf.rule}</p> + {!wide && needs.length > 0 && ( + <p className="mt-1.5 flex flex-wrap gap-1.5"> + {needs.map((need) => ( + <span key={need.server} className="rounded-ui border border-border px-1.5 text-[11.5px]"> + <span className="text-muted-foreground">{need.server}:</span> {need.type} + </span> + ))} + </p> + )} + </div> + {wide && ( + <div className="grid grid-cols-[16px_minmax(0,1fr)_auto] gap-x-2.5 border-t border-border bg-background px-3 py-1.5"> + <span className="col-start-2 text-[11px] font-semibold uppercase tracking-[.06em] text-muted-foreground"> + Folder and what downloads here + {needs.length === 0 ? <span className="normal-case tracking-normal">{SEP}Library type: {GENERIC_LIBRARY_TYPES[layout]}</span> : null} + </span> + <span className="flex gap-1.5"> + {needs.map((need) => ( + <span key={need.server} className="inline-flex h-[22px] w-[120px] items-center gap-1 truncate rounded-ui border border-border px-1.5 text-[11.5px]"> + <span className="text-muted-foreground">{need.server}:</span>{need.type} + </span> + ))} + </span> + </div> + )} + {!wide && needs.length === 0 && ( + <p className="px-3 pb-2 text-xs text-muted-foreground">Library type: {GENERIC_LIBRARY_TYPES[layout]}</p> + )} + {rows.map((folder) => ( + <FolderRow + key={folderKey(folder.name) || '~main'} + folder={folder} + statuses={serverStatuses(folder, page.check, page.configuredServers)} + selected={isSelected(folder)} + wide={wide} + moving={page.movingFolders.includes(folderKey(folder.name))} + autoFocus={isFocus(folder)} + onFocused={onFocused} + onSelect={page.selectFolder} + /> + ))} + {visibleUnused.map((folder) => ( + <UnusedFolderRow key={folderKey(folder.name)} folder={folder} selected={isSelected(folder)} + autoFocus={isFocus(folder)} onFocused={onFocused} onSelect={page.selectFolder} /> + ))} + {unused.length > UNUSED_VISIBLE && ( + <button type="button" onClick={() => setExpanded((open) => !open)} + className={cn('flex w-full items-center gap-1.5 border-t border-border px-3 text-[13px] text-primary', page.phone ? 'min-h-[44px]' : 'h-9')}> + {expanded ? <ChevronUp size={16} aria-hidden="true" /> : <ChevronDown size={16} aria-hidden="true" />} + {expanded ? 'Show fewer' : `Show ${unused.length - UNUSED_VISIBLE} more unused`} + </button> + )} + {folders.length === 0 && layout === 'videos' && ( + <p className="border-t border-border px-3 py-2.5 text-[13px] text-muted-foreground"> + No video folders. Add one for channels you want filed as movies. + </p> + )} + </div> + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/InspectorHeader.tsx b/client/src/components/LibraryFolders/components/InspectorHeader.tsx new file mode 100644 index 000000000..77e7076c1 --- /dev/null +++ b/client/src/components/LibraryFolders/components/InspectorHeader.tsx @@ -0,0 +1,86 @@ +import React, { useId, useState } from 'react'; +import { Film, MoreHorizontal, Star, Tv } from '../../../lib/icons'; +import { Button, Menu, MenuItem } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { libraryFolderLabel } from '../../../utils/libraryLayouts'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { REORGANIZING_DEFAULT_TEXT } from '../folderText'; +import { COPY_FAILED_MESSAGE, copyText } from '../copyText'; +import { joinServerPath } from '../libraryTypes'; + +export interface InspectorHeaderProps { + folder: LibraryFolder; + headingRef: React.Ref<HTMLHeadingElement>; + /** h1 on a detail screen, h2 in the two-column inspector */ + asPageTitle: boolean; +} + +/** Name, path, Default chip, Make default and the overflow menu (UI 5.7.1, 6.3). */ +export function InspectorHeader({ folder, headingRef, asPageTitle }: InspectorHeaderProps) { + const page = useLibraryPage(); + const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null); + const reasonId = useId(); + const label = libraryFolderLabel(folder.name); + const base = page.config.youtubeOutputDirectory || ''; + const fullPath = base ? joinServerPath(base, folder.name) : (folder.name ? `__${folder.name}` : 'the downloads folder'); + const Icon = folder.layout === 'tv' ? Tv : Film; + const Heading = asPageTitle ? 'h1' : 'h2'; + + const copyPath = async () => { + setMenuAnchor(null); + if (await copyText(fullPath)) page.notify('Copied'); + else page.notify(COPY_FAILED_MESSAGE, 'error'); + }; + + return ( + <div className="px-4 pb-3.5 pt-4"> + <div className="flex items-start gap-2.5"> + <Icon size={asPageTitle ? 20 : 18} aria-hidden="true" className="mt-1 shrink-0 text-primary" /> + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-2"> + <Heading id="insp-title" ref={headingRef} tabIndex={-1} + className={cn('font-display font-semibold [overflow-wrap:anywhere] focus:outline-none', asPageTitle ? 'text-[22px]' : 'text-xl')}> + {label} + </Heading> + {folder.isDefault && ( + <span className="inline-flex items-center gap-1 rounded-ui border border-primary px-1.5 text-xs text-primary"> + <Star size={11} aria-hidden="true" />Default folder + </span> + )} + </div> + <p className="mt-0.5 font-mono text-xs text-muted-foreground [overflow-wrap:anywhere]">{fullPath}</p> + </div> + {!page.phone && !folder.isDefault && ( + <Button variant="outlined" size="sm" startIcon={<Star size={14} aria-hidden="true" />} + aria-disabled={page.reorganizing ? 'true' : undefined} aria-describedby={page.reorganizing ? reasonId : undefined} + onClick={() => { if (!page.reorganizing) page.openMakeDefault(folder); }} + className={cn('h-[30px] shrink-0', page.reorganizing && 'opacity-50')}> + Make default + </Button> + )} + <Button variant="outlined" size="sm" aria-label={`More actions for ${label}`} aria-haspopup="menu" aria-expanded={Boolean(menuAnchor)} + onClick={(event: React.MouseEvent<HTMLElement>) => setMenuAnchor(event.currentTarget)} + className={page.phone ? 'h-11 w-11 shrink-0 p-0' : 'h-[30px] w-[30px] shrink-0 p-0'}> + <MoreHorizontal size={16} aria-hidden="true" /> + </Button> + <Menu open={Boolean(menuAnchor)} anchorEl={menuAnchor} onClose={() => setMenuAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }}> + <MenuItem onClick={() => { void copyPath(); }} className={page.phone ? 'min-h-[44px]' : undefined}>Copy full path</MenuItem> + <MenuItem onClick={() => { setMenuAnchor(null); void page.check.refetch(); }} className={page.phone ? 'min-h-[44px]' : undefined}> + Check media servers again + </MenuItem> + </Menu> + </div> + {page.phone && !folder.isDefault && ( + <Button variant="outlined" startIcon={<Star size={14} aria-hidden="true" />} + aria-disabled={page.reorganizing ? 'true' : undefined} aria-describedby={page.reorganizing ? reasonId : undefined} + onClick={() => { if (!page.reorganizing) page.openMakeDefault(folder); }} + className={cn('mt-3 min-h-[44px] w-full', page.reorganizing && 'opacity-50')}> + Make default + </Button> + )} + {page.reorganizing && !folder.isDefault && <p id={reasonId} className="mt-1.5 text-xs text-muted-foreground">{REORGANIZING_DEFAULT_TEXT}</p>} + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/LayoutPreviewTree.tsx b/client/src/components/LibraryFolders/components/LayoutPreviewTree.tsx new file mode 100644 index 000000000..4dd3c0b48 --- /dev/null +++ b/client/src/components/LibraryFolders/components/LayoutPreviewTree.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Library } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; +import { DEFAULT_CONFIG } from '../../../config/configSchema'; +import type { LibraryFolder, LibraryFolderDetail, LibraryLayout } from '../../../types/tvShows'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { exampleFrom, previewRows, readsAs } from '../layoutPreview'; + +const NOTE_OWN_LINE_AFTER = 26; +const trimEnd = (value: string | null | undefined) => (value ?? '').replace(/\s+$/, ''); + +/** The layout preview: an example tree, labelled as an example (UI 5.7.3). */ +export function LayoutPreviewTree({ folder, layout, previewing, example }: { + folder: LibraryFolder; layout: LibraryLayout; previewing: boolean; example: LibraryFolderDetail['example']; +}) { + const { config, phone } = useLibraryPage(); + const sample = exampleFrom(example); + const baseName = (config.youtubeOutputDirectory || '').split(/[\\/]/).filter(Boolean).pop() || 'data'; + const rows = previewRows({ + folderName: folder.name, baseName, layout, currentLayout: folder.layout, example: sample, flat: Boolean(config.defaultSkipVideoFolder), + }); + const customTemplate = trimEnd(config.videoFilenamePrefix) !== trimEnd(DEFAULT_CONFIG.videoFilenamePrefix); + return ( + <div className="mt-2.5 rounded-ui border border-border bg-background"> + <p className="border-b border-border px-2.5 py-1.5 text-xs"> + Example structure, with {sample.channelName} + {previewing && <span className="text-primary"> (preview, not applied)</span>} + </p> + <ul aria-label="Example file structure" className={cn('flex flex-col gap-0.5 px-2.5 py-2 font-mono leading-[1.45]', phone ? 'text-[11px]' : 'text-[11.5px]')}> + {rows.map((row, index) => ( + <li key={`${index}:${row.name}`} className={cn('flex flex-wrap items-baseline gap-x-2', + row.kind === 'file' ? 'text-muted-foreground' : 'text-foreground', row.kind === 'media' && 'font-medium')}> + <span className="whitespace-pre [overflow-wrap:anywhere]">{row.prefix}{row.name}</span> + {row.note && ( + <span className={cn('font-sans text-[11px] font-normal text-muted-foreground', row.name.length > NOTE_OWN_LINE_AFTER && 'basis-full pl-[3ch]')}> + {row.note} + </span> + )} + </li> + ))} + </ul> + <p className="px-2.5 pb-2 text-xs text-muted-foreground"> + Example structure. Review the move for exact file names. + {layout === 'videos' && previewing && customTemplate ? ' Your filename template decides the real names.' : ''} + </p> + <p className="flex items-start gap-1.5 border-t border-border px-2.5 py-2 text-[12.5px]"> + <Library size={14} aria-hidden="true" className="mt-0.5 shrink-0" /> + <span> + {readsAs(layout, sample)} + {layout === 'videos' && ( + <> <Link to="/settings/core#naming" className={cn('text-primary underline', phone && 'inline-flex min-h-[44px] items-center')}> + Filename template: Settings > Core + </Link></> + )} + </span> + </p> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/LayoutSection.tsx b/client/src/components/LibraryFolders/components/LayoutSection.tsx new file mode 100644 index 000000000..48ce38e2e --- /dev/null +++ b/client/src/components/LibraryFolders/components/LayoutSection.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useId, useState } from 'react'; +import { Film, Info, CheckCircle, Tv, Warning } from '../../../lib/icons'; +import { Button, CircularProgress, SegmentedControl } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder, LibraryFolderDetail, LibraryLayout } from '../../../types/tvShows'; +import { folderKey } from '../../../utils/libraryLayouts'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { folderLabel, layoutConsequence, layoutName, otherLayout, verbLabel } from '../folderText'; +import { AfterwardsNote } from './AfterwardsNote'; +import { LayoutPreviewTree } from './LayoutPreviewTree'; +import { SectionHeading } from './SectionHeading'; + +const MAIN_TV_WARNING = 'Youtarr writes a .plexignore here so a Plex TV library skips your __subfolders. Jellyfin and Emby ' + + "can't skip them and would show each __subfolder as an extra show."; +const RESULT_ICONS = { success: CheckCircle, warning: Warning, info: Info }; +const RESULT_TONES = { success: 'text-success', warning: 'text-warning', info: 'text-info' }; + +/** Layout: the current layout, Preview as, the example tree, and the change (UI 5.7.3). */ +export function LayoutSection({ folder, detail }: { folder: LibraryFolder; detail: LibraryFolderDetail | null }) { + const page = useLibraryPage(); + const headingId = useId(); + const consequenceId = useId(); + const [preview, setPreview] = useState<LibraryLayout | null>(null); + useEffect(() => { + setPreview(null); + }, [folder.name, folder.layout]); + const target = otherLayout(folder.layout); + const previewing = preview === target; + const shown = previewing ? target : folder.layout; + const consequence = layoutConsequence({ folder, titleShows: detail?.titleShows ?? null, reorganizing: page.reorganizing }); + const busy = page.busyLayoutFolder !== null && folderKey(page.busyLayoutFolder) === folderKey(folder.name); + const result = page.layoutResult && folderKey(page.layoutResult.folder) === folderKey(folder.name) ? page.layoutResult : null; + const CurrentIcon = folder.layout === 'tv' ? Tv : Film; + const TargetIcon = target === 'tv' ? Tv : Film; + const ResultIcon = result ? RESULT_ICONS[result.tone] : null; + const full = page.phone ? 'min-h-[44px] w-full' : 'h-8'; + + const onVerb = () => { + if (!folder.name && target === 'tv') page.openMainFolderTv(); + else void page.changeLayout(folder.name, target); + }; + + return ( + <section aria-labelledby={headingId} className="flex flex-col px-4 pb-4 pt-3.5"> + <SectionHeading id={headingId}>Layout</SectionHeading> + <p className="mt-1 flex items-center gap-1.5 text-[13px]"><CurrentIcon size={14} aria-hidden="true" />Uses {layoutName(folder.layout)}</p> + <p className="mt-2.5 text-xs text-muted-foreground">Preview as</p> + <SegmentedControl<LibraryLayout> + aria-label={`Preview ${folderLabel(folder.name, true)} as`} + value={shown} + size={page.phone ? 'lg' : 'md'} + onChange={(value) => setPreview(value === folder.layout ? null : value)} + options={[ + { value: 'videos', label: 'Videos', icon: <Film size={14} aria-hidden="true" />, hint: folder.layout === 'videos' ? 'current' : undefined }, + { value: 'tv', label: 'TV shows', icon: <Tv size={14} aria-hidden="true" />, hint: folder.layout === 'tv' ? 'current' : undefined }, + ]} + className="mt-1" + /> + <LayoutPreviewTree folder={folder} layout={shown} previewing={previewing} example={detail?.example ?? null} /> + {previewing && <AfterwardsNote folder={folder} target={target} />} + <div className="mt-3 flex flex-col gap-2"> + <p id={consequenceId} className="text-[13px]">{consequence.text}</p> + {!folder.name && target === 'tv' && ( + <p className="flex items-start gap-1.5 text-[13px] text-warning"><Warning size={15} aria-hidden="true" className="mt-0.5 shrink-0" />{MAIN_TV_WARNING}</p> + )} + <div className={cn('flex gap-2', page.phone && 'flex-col')}> + <Button variant={previewing ? 'contained' : 'outlined'} disabled={consequence.blocked || busy} + aria-describedby={consequence.blocked ? consequenceId : undefined} onClick={onVerb} className={full} + startIcon={busy ? <CircularProgress size={14} /> : <TargetIcon size={14} aria-hidden="true" />}> + {verbLabel(folder)} + </Button> + {previewing && <Button variant="text" onClick={() => setPreview(null)} className={full}>Keep {layoutName(folder.layout)}</Button>} + </div> + {result && ResultIcon && ( + <p role="status" className={cn('flex items-start gap-1.5 text-[13px]', RESULT_TONES[result.tone])}> + <ResultIcon size={15} aria-hidden="true" className="mt-0.5 shrink-0" />{result.text} + </p> + )} + </div> + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/LibraryGuide.tsx b/client/src/components/LibraryFolders/components/LibraryGuide.tsx new file mode 100644 index 000000000..b500f99eb --- /dev/null +++ b/client/src/components/LibraryFolders/components/LibraryGuide.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { ArrowRight, ChevronDown, ChevronUp, Film, HelpCircle, Info, Tv } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; +import type { LibraryLayout } from '../../../types/tvShows'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { useGuideOpen } from '../hooks/useGuideOpen'; +import { LIBRARY_TYPE_NAMES, SETUP_SERVER_NAMES, SetupServer } from '../libraryTypes'; + +const SUMMARY = 'Channels pick a folder, its layout files them, a matching library shows it.'; +const STEPS = [ + { title: 'Channels pick a folder', text: 'In their settings, or the default folder' }, + { title: "The folder's layout files them", text: 'Videos or TV shows, set per folder' }, + { title: 'A matching library shows it', text: 'On each server, a library of the type the layout needs' }, +]; +const LAST = '\u2514\u2500 '; +const CARDS: Array<{ layout: LibraryLayout; title: string; subtitle: string; tree: string[]; phoneTree: string[] }> = [ + { + layout: 'videos', title: 'Videos', subtitle: "Youtarr's existing movie-style layout", + tree: ['__Kids/', `${LAST}Blippi/`, ` ${LAST}Blippi - Title - id/`, ` ${LAST}Blippi - Title [id].mp4`], + phoneTree: ['__Kids/', `${LAST}Blippi/`, ` ${LAST}Blippi - Title - id/Blippi - Title [id].mp4`], + }, + { + layout: 'tv', title: 'TV shows', subtitle: 'Each channel a show, each upload year a season', + tree: ['__TV Shows/', `${LAST}Veritasium/`, ` ${LAST}Season 2026/`, ` ${LAST}S2026E09281530 - Title [id].mp4`], + phoneTree: ['__TV Shows/', `${LAST}Veritasium/Season 2026/`, ` ${LAST}S2026E09281530 - Title [id].mp4`], + }, +]; +const ALL_SERVERS: SetupServer[] = ['plex', 'jellyfin', 'emby', 'kodi']; + +/** How library folders work (UI 5.4, 6.2). */ +export function LibraryGuide() { + const page = useLibraryPage(); + const hasTv = page.folders.some((folder) => folder.layout === 'tv'); + const [open, toggle] = useGuideOpen(page.foldersLoaded ? !page.phone && !hasTv : null); + const servers: SetupServer[] = page.servers.length > 0 ? page.servers.map((server) => server.serverType) : ALL_SERVERS; + + return ( + <section aria-labelledby="guide-title" className="rounded-ui border border-border bg-card"> + <div className={cn('flex items-center gap-2 py-1.5 pl-3.5 pr-1.5', open && 'border-b border-border')}> + <HelpCircle size={page.phone ? 18 : 16} aria-hidden="true" className="text-primary" /> + <h2 id="guide-title" className="font-display text-[15px] font-semibold">How library folders work</h2> + {!open && !page.phone && <span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground">{SUMMARY}</span>} + {(open || page.phone) && <span className="flex-1" />} + <button type="button" onClick={toggle} aria-expanded={open} aria-controls="guide-body" + className={cn('inline-flex items-center gap-1 text-[13px] text-primary', page.phone ? 'min-h-[48px]' : 'h-[30px]')}> + {open ? 'Hide the guide' : 'Show the guide'} + {open ? <ChevronUp size={16} aria-hidden="true" /> : <ChevronDown size={16} aria-hidden="true" />} + </button> + </div> + {page.phone && <p className="px-3.5 pb-2 text-[12.5px] text-muted-foreground">{SUMMARY}</p>} + <div id="guide-body" hidden={!open} className="px-3.5 pb-3.5 pt-3"> + <ol className={cn('gap-2.5', page.phone ? 'flex flex-col' : 'grid grid-cols-[1fr_16px_1fr_16px_1fr] items-start')}> + {STEPS.map((step, index) => ( + <React.Fragment key={step.title}> + {index > 0 && !page.phone && <li aria-hidden="true" className="pt-1 text-muted-foreground/70"><ArrowRight size={16} /></li>} + <li className="flex gap-2"> + <span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-ui border border-primary text-xs text-primary">{index + 1}</span> + <span> + <span className="block text-[13px] font-semibold">{step.title}</span> + <span className="block text-xs text-muted-foreground">{step.text}</span> + </span> + </li> + </React.Fragment> + ))} + </ol> + <div className={cn('mt-3 grid gap-3', page.phone ? 'grid-cols-1' : 'grid-cols-2')}> + {CARDS.map((card) => { + const Icon = card.layout === 'tv' ? Tv : Film; + return ( + <div key={card.layout} className="rounded-ui bg-background px-3 py-2.5"> + <p className="flex items-baseline gap-2"> + <Icon size={16} aria-hidden="true" className="self-center" /> + <span className="font-display text-sm font-semibold">{card.title}</span> + <span className="text-xs text-muted-foreground">{card.subtitle}</span> + </p> + <div className={cn('mt-1.5 gap-2', page.phone ? 'flex flex-col' : 'grid grid-cols-[1fr_140px]')}> + <pre aria-hidden="true" className="overflow-hidden whitespace-pre font-mono text-[10.5px] leading-4 text-muted-foreground"> + {(page.phone ? card.phoneTree : card.tree).join('\n')} + </pre> + {page.phone ? ( + <p className="text-xs text-muted-foreground"> + Library type: {servers.map((server) => `${SETUP_SERVER_NAMES[server]} ${LIBRARY_TYPE_NAMES[server][card.layout]}`).join(', ')} + </p> + ) : ( + <dl className="text-[12.5px]"> + <dt className="text-[11px] uppercase text-muted-foreground">Library type</dt> + {servers.map((server) => ( + <dd key={server}> + <span className="text-muted-foreground">{SETUP_SERVER_NAMES[server]}</span> <span>{LIBRARY_TYPE_NAMES[server][card.layout]}</span> + </dd> + ))} + </dl> + )} + </div> + </div> + ); + })} + </div> + <p className="mt-2.5 flex items-start gap-1.5 text-[12.5px] text-muted-foreground"> + <Info size={13} aria-hidden="true" className="mt-0.5 shrink-0" /> + <span> + Videos is Youtarr's existing layout, and your folders keep their organization: TV shows is optional. One library on + the whole downloads folder works while every folder uses Videos.{' '} + <button type="button" onClick={page.openStartTv} + className={cn('text-primary underline', page.phone && 'inline-flex min-h-[44px] items-center')}> + Start using TV shows + </button> + </span> + </p> + </div> + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/MediaServersSection.tsx b/client/src/components/LibraryFolders/components/MediaServersSection.tsx new file mode 100644 index 000000000..95e3fa02c --- /dev/null +++ b/client/src/components/LibraryFolders/components/MediaServersSection.tsx @@ -0,0 +1,89 @@ +import React, { useId } from 'react'; +import { Link } from 'react-router-dom'; +import { cn } from '../../../lib/cn'; +import { Info } from '../../../lib/icons'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { useNow } from '../../Configuration/hooks/useNow'; +import { serverStatuses, timeAgo } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { followersLine } from '../folderText'; +import { SETUP_SERVER_NAMES, folderServerPath } from '../libraryTypes'; +import { footerNote, inspectorIntro } from '../mediaServerText'; +import { libraryFolderLabel } from '../../../utils/libraryLayouts'; +import { ChannelLinks } from './ChannelLinks'; +import { PlexRefreshControl } from './PlexRefreshControl'; +import { SectionHeading } from './SectionHeading'; +import { ServerCard } from './ServerCard'; +import { rendersAsLine, showsPlexControl } from './serverCardRules'; +import { SetupBox } from './SetupBox'; + +const SETTINGS_LINKS = [ + { to: '/settings/plex', label: 'Plex settings' }, + { to: '/settings/jellyfin', label: 'Jellyfin settings' }, + { to: '/settings/emby', label: 'Emby settings' }, +]; +const ALL_SERVERS = ['plex', 'jellyfin', 'emby', 'kodi'] as const; + +/** The inspector's first section (UI 5.7.2). */ +export function MediaServersSection({ folder }: { folder: LibraryFolder }) { + const page = useLibraryPage(); + const { mainDetail } = page; + const headingId = useId(); + const now = useNow(); + const statuses = serverStatuses(folder, page.check, page.configuredServers); + const intro = inspectorIntro({ folder, folders: page.folders, check: page.check.data, servers: page.servers }); + const followers = mainDetail ? followersLine(mainDetail.followers, mainDetail.channels.length > 0) : null; + const downloadsPathOf = (serverType: string) => page.check.data?.servers.find((server) => server.serverType === serverType)?.downloadsPath ?? null; + const checked = page.check.loading ? 'Checking...' + : page.check.lastCheckedAt !== null ? `Checked ${timeAgo(page.check.lastCheckedAt, now, page.timeZone)}` : 'Not checked'; + const plexStatus = statuses.find((status) => status.serverType === 'plex'); + const plexControl = Boolean(plexStatus && rendersAsLine(plexStatus) && showsPlexControl(folder)); + const introLink = cn('text-primary underline', page.phone && 'inline-flex min-h-[44px] items-center'); + + return ( + <section aria-labelledby={headingId} className="flex flex-col gap-2 px-4 pb-4 pt-3.5"> + <div className="flex items-baseline justify-between gap-2"> + <SectionHeading id={headingId}>Media servers</SectionHeading> + <span className="text-xs text-muted-foreground">{checked}</span> + </div> + {page.serversKnown && intro && ( + <div className="text-[13px] text-muted-foreground"> + {intro.text} + {intro.link?.kind === 'startTv' && <> <button type="button" onClick={page.openStartTv} className={introLink}>Start using TV shows</button></>} + {intro.link?.kind === 'folder' && ( + <><button type="button" onClick={() => page.selectFolder((intro.link as { folder: string }).folder)} className={introLink}> + {libraryFolderLabel((intro.link as { folder: string }).folder)} + </button>{intro.trailing}</> + )} + {intro.link?.kind === 'channels' && mainDetail && ( + <> + {' '}<ChannelLinks channels={mainDetail.channels} /> + {followers && <span className="mt-0.5 block">{followers}</span>} + </> + )} + </div> + )} + {plexControl && <PlexRefreshControl folder={folder} report={plexStatus?.report ?? null} />} + {!page.serversKnown ? null : page.servers.length === 0 ? ( + <> + <p className="flex flex-wrap gap-x-3 text-[13px]"> + {SETTINGS_LINKS.map((link) => <Link key={link.to} to={link.to} className={cn('text-primary underline', page.phone && 'inline-flex min-h-[44px] items-center')}>{link.label}</Link>)} + </p> + {ALL_SERVERS.map((server) => ( + <SetupBox key={server} server={server} layout={folder.layout} showCheckAgain={false} + path={folderServerPath(folder.name, null, SETUP_SERVER_NAMES[server])} /> + ))} + </> + ) : ( + statuses.map((status) => ( + <ServerCard key={status.serverType} folder={folder} status={status} downloadsPath={downloadsPathOf(status.serverType)} /> + )) + )} + {page.serversKnown && ( + <p className="flex items-start gap-1.5 text-xs text-muted-foreground"> + <Info size={13} aria-hidden="true" className="mt-0.5 shrink-0" />{footerNote(folder, page.servers)} + </p> + )} + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/NoTvFoldersRow.tsx b/client/src/components/LibraryFolders/components/NoTvFoldersRow.tsx new file mode 100644 index 000000000..fa7206c1c --- /dev/null +++ b/client/src/components/LibraryFolders/components/NoTvFoldersRow.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Plus, Tv } from '../../../lib/icons'; +import { Button } from '../../ui'; +import { cn } from '../../../lib/cn'; +import { useLibraryPage } from '../LibraryFoldersContext'; + +/** Stands in for the TV show folders shelf until one exists (UI 5.6). */ +export function NoTvFoldersRow() { + const { phone, openStartTv, openAddFolder } = useLibraryPage(); + const full = phone ? 'min-h-[44px] w-full' : undefined; + return ( + <section aria-labelledby="no-tv-folders-title" className="rounded-ui border border-border bg-card p-3"> + <div className="flex items-start gap-2.5"> + <Tv size={16} aria-hidden="true" className="mt-1 text-muted-foreground" /> + <div className="min-w-0 flex-1"> + <h2 id="no-tv-folders-title" className="font-display text-[15px] font-semibold">TV show folders</h2> + <p className="text-[13px] text-muted-foreground"> + None yet. Save channels as TV shows: each channel a show, each upload year a season. + </p> + <div className={cn('mt-2 flex gap-2', phone && 'flex-col')}> + <Button variant="outlined" onClick={openStartTv} className={full}>Start using TV shows</Button> + <Button variant="text" startIcon={<Plus size={14} />} onClick={() => openAddFolder('tv')} className={full}> + Add TV folder + </Button> + </div> + </div> + </div> + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/OverlapFixBlock.tsx b/client/src/components/LibraryFolders/components/OverlapFixBlock.tsx new file mode 100644 index 000000000..b1ccc4143 --- /dev/null +++ b/client/src/components/LibraryFolders/components/OverlapFixBlock.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Button } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryCheckIssue, LibraryCheckServerReport } from '../../../types/libraryCheck'; +import { ServerRef, folderState, needsLibrary } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { countOf, folderLabel, followersLine, units } from '../folderText'; +import { joinServerPath } from '../libraryTypes'; +import { libraryTypeLabel } from '../mediaServerText'; +import { ChannelLinks } from './ChannelLinks'; +import { CopyButton } from './CopyButton'; + +/** What an edit does to the library's items, from the Phase 2 verification (Task 2.0, EDIT_NOTE). */ +export const EDIT_NOTE = 'Their watch state stays.'; + +export interface OverlapFixBlockProps { + issue: LibraryCheckIssue; + server: ServerRef; + report: LibraryCheckServerReport; + downloadsPath: string | null; +} + +/** The exact library edit for a nestedLibrary / overlap issue (UI 5.7.2.2). */ +export function OverlapFixBlock({ issue, server, report, downloadsPath }: OverlapFixBlockProps) { + const page = useLibraryPage(); + const main = page.folders.find((entry) => !entry.name) ?? null; + const mainState = main ? folderState(main) : 'emptyMain'; + const { mainDetail } = page; + const followers = mainDetail ? followersLine(mainDetail.followers, mainDetail.channels.length > 0) : null; + const library = report.libraries.find((entry) => entry.id === issue.libraryId); + const name = library?.name ?? `library ${issue.libraryId}`; + const videoFolders = page.folders.filter((entry) => entry.name && entry.layout === 'videos' && needsLibrary(folderState(entry))); + const mainUnit = units(main?.layout ?? 'videos'); + + return ( + <div className="rounded-ui border border-border bg-card px-2.5 py-2 text-[13px]"> + <p className="font-semibold">Fix it in {server.name}</p> + <ol className="mt-1 list-decimal space-y-1.5 pl-5"> + <li>Edit {name} ({libraryTypeLabel(server.serverType, library?.type ?? 'videos')} library) and remove its folder {library?.location ?? ''}.</li> + {videoFolders.length > 0 && ( + <li> + Add your Video folders to {name} instead: + {downloadsPath ? ( + <ul className="mt-1 space-y-0.5"> + {videoFolders.map((entry) => { + const text = joinServerPath(downloadsPath, entry.name); + return ( + <li key={entry.name} className="flex items-center gap-1 font-mono text-xs"> + <span className="[overflow-wrap:anywhere]">{text}</span><CopyButton text={text} /> + </li> + ); + })} + </ul> + ) : ( + <> {videoFolders.map((entry) => folderLabel(entry.name)).join(', ')} inside your downloads folder, as {server.name} sees it</> + )} + </li> + )} + {main && mainState === 'active' && main.channels > 0 && ( + <li> + {countOf(main.channels, 'channel still downloads', 'channels still download')} straight into the main folder, which {name}{' '} + would stop showing. Give each a Video folder first (Channel Settings > Library folder moves its videos):{' '} + <ChannelLinks channels={mainDetail?.channels ?? []} /> + {followers && <span className="mt-0.5 block">{followers}</span>} + </li> + )} + {main && mainState === 'holdsVideos' && ( + <li>{countOf(main.fileCount ?? 0, mainUnit.one, mainUnit.many)} still sit in the main folder; {name} would stop showing them.</li> + )} + <li> + Then check again.{' '} + <Button variant="outlined" size="sm" onClick={() => { void page.check.refetch(); }} + className={page.phone ? 'mt-1 min-h-[44px] w-full' : 'h-[30px]'}> + Check again + </Button> + </li> + </ol> + <p className="mt-1.5 text-xs text-muted-foreground">{name} keeps its type, so editing its folders is enough. {EDIT_NOTE}</p> + <button type="button" onClick={page.openStartTv} className={cn('mt-1 text-xs text-primary underline', page.phone && 'inline-flex min-h-[44px] items-center')}>See all your options</button> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/PageHeader.tsx b/client/src/components/LibraryFolders/components/PageHeader.tsx new file mode 100644 index 000000000..6d022729c --- /dev/null +++ b/client/src/components/LibraryFolders/components/PageHeader.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Plus, RefreshCw, Server } from '../../../lib/icons'; +import { Button, CircularProgress } from '../../ui'; +import { cn } from '../../../lib/cn'; +import { useNow } from '../../Configuration/hooks/useNow'; +import { checkStatus } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { SEP } from '../folderText'; +import { checkStatusText } from '../mediaServerText'; + +/** Heading, downloads path, check status and the page actions (UI 5.1, 5.2, 6.2). */ +export function PageHeader() { + const page = useLibraryPage(); + const now = useNow(); + const path = page.config.youtubeOutputDirectory || 'Not set'; + const source = page.isPlatformManaged.youtubeOutputDirectory ? 'set by DATA_PATH' : 'set by YOUTUBE_OUTPUT_DIR'; + const status = checkStatus(page.check, page.configuredServers); + const statusText = checkStatusText(status, page.servers, now, page.timeZone); + const phoneLink = page.phone && 'inline-flex min-h-[44px] items-center'; + const checkButton = page.servers.length > 0 && ( + <Button variant="outlined" disabled={page.check.loading} onClick={() => { void page.check.refetch(); }} + startIcon={page.check.loading ? <CircularProgress size={14} /> : <RefreshCw size={14} />} className="h-[34px]"> + {page.check.loading ? 'Checking...' : 'Check media servers'} + </Button> + ); + const addButton = ( + <Button variant="contained" startIcon={<Plus size={14} />} onClick={() => page.openAddFolder('videos')} + className={page.phone ? 'min-h-[44px] w-full' : 'h-[34px]'}> + Add folder + </Button> + ); + const serverStatus = !page.serversKnown ? null : page.servers.length === 0 + ? <>No media server connected. <Link to="/settings/plex" className={cn('text-primary underline', phoneLink)}>Connect one</Link></> + : statusText; + + return ( + <header className="flex flex-col gap-1.5"> + <div className={cn('flex gap-2', page.phone ? 'flex-col' : 'flex-wrap items-center')}> + <div className={cn('flex items-baseline gap-1.5 font-display font-semibold', page.phone ? 'text-[22px]' : 'text-xl')}> + <Link to="/settings" className={cn('font-normal text-muted-foreground', page.phone && 'text-[13px]', phoneLink)}>Settings</Link> + <span aria-hidden="true" className="text-muted-foreground/70">/</span> + <h1 className="font-semibold">Library folders</h1> + </div> + {!page.phone && <span className="flex-1" />} + {!page.phone && <span className="flex gap-2">{checkButton}{addButton}</span>} + </div> + <div className={cn('flex gap-x-3 gap-y-1 text-[13px] text-muted-foreground', page.phone ? 'flex-col' : 'flex-wrap items-center')}> + <span> + {page.phone ? null : 'Downloads folder '} + <code className="rounded-ui bg-muted px-1 font-mono text-xs text-foreground">{path}</code>{' '} + {page.phone ? `downloads folder, ${source}` : source} + {page.phone ? null : <>{SEP}Changes on this page save right away.</>} + </span> + {page.phone && <span>Changes on this page save right away.</span>} + {!page.phone && <span className="flex-1" />} + {serverStatus && <span className="inline-flex items-center gap-1.5"><Server size={14} aria-hidden="true" />{serverStatus}</span>} + </div> + {page.phone && <div className="mt-1.5">{addButton}</div>} + </header> + ); +} diff --git a/client/src/components/LibraryFolders/components/PlexRefreshControl.tsx b/client/src/components/LibraryFolders/components/PlexRefreshControl.tsx new file mode 100644 index 000000000..d652df5a1 --- /dev/null +++ b/client/src/components/LibraryFolders/components/PlexRefreshControl.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useId, useState } from 'react'; +import { Button, MenuItem, Select } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder } from '../../../types/tvShows'; +import type { LibraryCheckServerReport } from '../../../types/libraryCheck'; +import { holdingLibraries } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { PLEX_MAPPING_SAVE_ERROR, usePlexRefreshMapping } from '../hooks/usePlexRefreshMapping'; + +const REFRESHABLE_TYPES = new Set(['movie', 'show']); + +/** "After downloads here, Plex refreshes [library]" (UI 5.7.2.5); replaces the Settings > Plex table. */ +export function PlexRefreshControl({ folder, report }: { folder: LibraryFolder; report: LibraryCheckServerReport | null }) { + const { token, phone, config, plexLibraries, plexConnectionStatus } = useLibraryPage(); + const { saving, setMapping, removeMapping } = usePlexRefreshMapping(token); + const [error, setError] = useState<string | null>(null); + const [pending, setPending] = useState<string | null>(null); + const labelId = useId(); + const mapping = folder.plexMapping ?? { choice: 'none' as const, libraryId: null }; + const savedKey = `${mapping.choice}:${mapping.libraryId ?? ''}`; + const defaultId = config.plexYoutubeLibraryId ? String(config.plexYoutubeLibraryId) : null; + const titleOf = (id: string | null) => (id ? plexLibraries.find((library) => String(library.id) === id)?.title ?? null : null); + const listed = plexConnectionStatus === 'connected' && plexLibraries.length > 0; + // Until the connection test answers, Plex is neither listed nor unreachable. + const plexKnown = plexConnectionStatus !== 'testing' && plexConnectionStatus !== 'not_tested'; + + // The chosen value stays on screen after a save until the reloaded folder carries it. + useEffect(() => { setPending(null); }, [savedKey]); + + const run = async (action: () => Promise<void>, value: string | null) => { + setError(null); + setPending(value); + try { + await action(); + } catch (err: unknown) { + setError(err instanceof Error && err.message ? err.message : PLEX_MAPPING_SAVE_ERROR); + setPending(null); + } + }; + const remove = mapping.choice !== 'none' ? ( + <Button variant="text" size="sm" disabled={saving} onClick={() => { void run(() => removeMapping(folder.name), null); }} + className={phone ? 'min-h-[44px]' : undefined}> + Remove setting + </Button> + ) : null; + + if (!listed || (mapping.libraryId !== null && titleOf(mapping.libraryId) === null)) { + const shownId = mapping.libraryId ?? defaultId; + const libraryText = titleOf(shownId) ?? (shownId ? `library ${shownId}` : 'no library'); + return ( + <div className="border-t border-border pt-2.5 text-[13px]"> + <span>After downloads here, Plex refreshes {libraryText}</span>{' '} + {plexKnown && ( + <span className="text-muted-foreground">{listed ? '(Plex no longer lists this library)' : "(Plex couldn't be reached)"}</span> + )} + {remove} + {error && <p className="text-xs text-destructive">{error}</p>} + </div> + ); + } + + const options = plexLibraries + .filter((library) => !library.type || REFRESHABLE_TYPES.has(library.type) || String(library.id) === mapping.libraryId) + .sort((a, b) => a.title.localeCompare(b.title)); + const value = pending ?? (mapping.choice === 'library' && mapping.libraryId ? mapping.libraryId : ''); + const selectedId = value || defaultId; + const holders = new Set((report ? holdingLibraries(report) : []).map((library) => library.id)); + const caption = selectedId && holders.has(selectedId) + ? { className: 'text-success', text: 'Matches the library that holds this folder.' } + : mapping.choice === 'default' + ? { className: 'text-muted-foreground', text: "You chose the default library for this folder, so Youtarr won't change it." } + : { className: 'text-muted-foreground', text: 'Folders without their own choice refresh the default library.' }; + + return ( + <div className="border-t border-border pt-2.5"> + <div className={cn('flex gap-2', phone ? 'flex-col' : 'flex-wrap items-center')}> + <span id={labelId} className="text-[13px]">After downloads here, Plex refreshes</span> + <Select value={value} labelId={labelId} triggerRole="combobox" size="small" disabled={saving} fullWidth={phone} + onChange={(event) => { const next = String(event.target.value); void run(() => setMapping(folder.name, next || null), next); }} + className={phone ? 'min-h-[44px] text-[13px]' : 'h-[30px] min-h-0 text-[13px]'}> + <MenuItem value="">{defaultId ? `Default library (${titleOf(defaultId) ?? `library ${defaultId}`})` : 'No default library (nothing refreshes)'}</MenuItem> + {options.map((library) => <MenuItem key={library.id} value={String(library.id)}>{library.title}</MenuItem>)} + </Select> + {remove} + </div> + <p className={cn('mt-1 text-xs', error ? 'text-destructive' : caption.className)}>{error ?? caption.text}</p> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/SectionHeading.tsx b/client/src/components/LibraryFolders/components/SectionHeading.tsx new file mode 100644 index 000000000..0be3c8bc0 --- /dev/null +++ b/client/src/components/LibraryFolders/components/SectionHeading.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { useLibraryPage } from '../LibraryFoldersContext'; + +/** h3 inside the two-column inspector (its title is the h2), h2 on a detail screen (its title is the h1). */ +export function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) { + const { twoColumn } = useLibraryPage(); + const Tag = twoColumn ? 'h3' : 'h2'; + return <Tag id={id} className={twoColumn ? 'font-display text-[15px] font-semibold' : 'font-display text-[17px] font-semibold'}>{children}</Tag>; +} diff --git a/client/src/components/LibraryFolders/components/ServerCard.tsx b/client/src/components/LibraryFolders/components/ServerCard.tsx new file mode 100644 index 000000000..957d4e03c --- /dev/null +++ b/client/src/components/LibraryFolders/components/ServerCard.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Server, XCircle } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { ServerStatus, listedIssues } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { folderServerPath } from '../libraryTypes'; +import { holdersText, libraryTypeLabel, noLibraryLine } from '../mediaServerText'; +import { PlexRefreshControl } from './PlexRefreshControl'; +import { ServerIssue } from './ServerIssue'; +import { ServerLine } from './ServerLine'; +import { SetupBox } from './SetupBox'; +import { rendersAsLine, showsPlexControl } from './serverCardRules'; +import { STATUS_TONE } from './StatusCell'; + +export interface ServerCardProps { + folder: LibraryFolder; + status: ServerStatus; + downloadsPath: string | null; + /** Add folder step 2: "Already shown by ..." for ok, no Plex refresh control */ + compact?: boolean; +} + +/** One server's report for a folder (UI 5.7.2.1). */ +export function ServerCard({ folder, status, downloadsPath, compact = false }: ServerCardProps) { + const page = useLibraryPage(); + const { report } = status; + if (!report || rendersAsLine(status)) { + return <ServerLine folder={folder} status={status} />; + } + const tone = STATUS_TONE[status.display]; + const holders = holdersText(status.serverType, report); + const tvExists = page.folders.some((entry) => entry.layout === 'tv'); + const setupHere = status.display === 'noLibrary' && !(folder.name === '' && tvExists); + const showPlex = !compact && status.serverType === 'plex' && showsPlexControl(folder); + const firstHolder = report.libraries.find((library) => library.relation !== 'inside'); + const issues = listedIssues(report); + const hasBody = Boolean(holders && page.phone && !compact) || (compact && status.display === 'ok' && Boolean(firstHolder)) + || issues.length > 0 || status.display === 'noLibrary' || showPlex; + + return ( + <section id={compact ? undefined : `server-card-${status.serverType}`} aria-label={`${status.name}`} + className={cn('rounded-ui border bg-background', status.display === 'ok' ? 'border-border' : tone.border)}> + <div className="flex flex-wrap items-center gap-2 px-3 py-2.5"> + <Server size={15} aria-hidden="true" className="text-muted-foreground" /> + <span className="font-semibold">{status.name}</span> + <span className={cn('inline-flex h-[22px] items-center rounded-ui border px-1.5 text-xs', tone.text, tone.border)}>{status.display === 'ok' ? 'OK' : status.word}</span> + {holders && !page.phone && !compact && <span className="ml-auto truncate text-[12.5px] text-muted-foreground">{holders}</span>} + </div> + {hasBody && <div className="flex flex-col gap-2.5 px-3 pb-3"> + {holders && page.phone && !compact && <p className="text-[12.5px] text-muted-foreground">Library: {holders}</p>} + {compact && status.display === 'ok' && firstHolder && ( + <p className="text-[13px]"> + Already shown by {firstHolder.name} ({libraryTypeLabel(status.serverType, firstHolder.type)}) + {firstHolder.relation === 'covers' ? ' through the whole downloads folder' : ''} + </p> + )} + {issues.map((issue) => ( + <ServerIssue key={`${issue.code}:${issue.libraryId ?? ''}`} folder={folder} issue={issue} status={status} downloadsPath={downloadsPath} /> + ))} + {status.display === 'noLibrary' && <p className="flex items-start gap-2 text-[13px]"> + <XCircle size={15} aria-hidden="true" className="mt-0.5 shrink-0 text-destructive" />{noLibraryLine(status, folder)} + </p>} + {setupHere && <SetupBox server={status.serverType} layout={folder.layout} path={folderServerPath(folder.name, downloadsPath, status.name)} />} + {showPlex && <PlexRefreshControl folder={folder} report={report} />} + </div>} + </section> + ); +} diff --git a/client/src/components/LibraryFolders/components/ServerIssue.tsx b/client/src/components/LibraryFolders/components/ServerIssue.tsx new file mode 100644 index 000000000..1877b015b --- /dev/null +++ b/client/src/components/LibraryFolders/components/ServerIssue.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { Info, Warning } from '../../../lib/icons'; +import { Button, CircularProgress } from '../../ui'; +import type { LibraryFolder } from '../../../types/tvShows'; +import type { LibraryCheckIssue } from '../../../types/libraryCheck'; +import { NOTE_CODES, ServerStatus, isLibraryWide, isOverlapIssue } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { PLEX_MAPPING_SAVE_ERROR, usePlexRefreshMapping } from '../hooks/usePlexRefreshMapping'; +import { issueHint } from '../mediaServerText'; +import { OverlapFixBlock } from './OverlapFixBlock'; + +/** One issue on a server card: the server's message, a hint, and its fix (UI 5.7.2.1). */ +export function ServerIssue({ folder, issue, status, downloadsPath }: { + folder: LibraryFolder; issue: LibraryCheckIssue; status: ServerStatus; downloadsPath: string | null; +}) { + const page = useLibraryPage(); + const { setMapping } = usePlexRefreshMapping(page.token); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const report = status.report; + const libraryName = report?.libraries.find((library) => library.id === issue.libraryId)?.name ?? `library ${issue.libraryId ?? ''}`.trim(); + const hint = issueHint(issue.code, status.name, libraryName, folder.layout); + const fix = async (action: () => Promise<void>) => { + setBusy(true); + setError(null); + try { + await action(); + } catch (err: unknown) { + setError(err instanceof Error && err.message ? err.message : PLEX_MAPPING_SAVE_ERROR); + } finally { + setBusy(false); + } + }; + const fullWidth = page.phone ? 'mt-1 min-h-[44px] w-full' : 'mt-1 h-[30px]'; + const Icon = NOTE_CODES.has(issue.code) ? Info : Warning; + + return ( + <div className="flex items-start gap-2 text-[13px]"> + <Icon size={15} aria-hidden="true" className={NOTE_CODES.has(issue.code) ? 'mt-0.5 text-info' : 'mt-0.5 text-warning'} /> + <div className="min-w-0 flex-1"> + <p> + {issue.message} + {isLibraryWide(issue.code) && <span className="ml-1.5 rounded-ui border border-border px-1.5 text-[11px] text-muted-foreground">Library setting</span>} + </p> + {hint && <p className="text-xs text-muted-foreground">{hint}</p>} + {issue.code === 'plexMappingMissing' && issue.libraryId && ( + <Button variant="outlined" size="sm" disabled={busy} className={fullWidth} + startIcon={busy ? <CircularProgress size={14} /> : undefined} + onClick={() => { void fix(() => page.check.applyPlexMapping(folder.name, issue.libraryId as string)); }}> + Refresh this library + </Button> + )} + {issue.code === 'plexMappingMismatch' && issue.libraryId && ( + <Button variant="outlined" size="sm" disabled={busy} className={fullWidth} + onClick={() => { void fix(() => setMapping(folder.name, issue.libraryId as string)); }}> + Refresh {libraryName} instead + </Button> + )} + {isOverlapIssue(issue.code) && report && ( + <div className="mt-1.5"> + <OverlapFixBlock issue={issue} server={status} report={report} downloadsPath={downloadsPath} /> + </div> + )} + {error && <p className="mt-1 text-xs text-destructive">{error}</p>} + </div> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/ServerLine.tsx b/client/src/components/LibraryFolders/components/ServerLine.tsx new file mode 100644 index 000000000..48e7b42e5 --- /dev/null +++ b/client/src/components/LibraryFolders/components/ServerLine.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Loader2, Server } from '../../../lib/icons'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { ServerStatus, folderState } from '../../../utils/libraryAttention'; +import { useLibraryPage } from '../LibraryFoldersContext'; + +/** The single-line server states: fine, not checked, checking (UI 5.7.2.3). */ +export function ServerLine({ folder, status }: { folder: LibraryFolder; status: ServerStatus }) { + const page = useLibraryPage(); + let text: string; + let action: string | null = null; + if (status.display === 'checking') { + text = ': checking...'; + } else if (status.display === 'fine') { + text = folderState(folder) === 'emptyMain' + ? ": not in a library, and doesn't need one while you use subfolders." + : ': not in a library. Fine while nothing downloads here.'; + } else if (status.report?.status === 'unreachable') { + text = ": couldn't be reached, so this folder wasn't checked."; + action = 'Check again'; + } else if (page.check.error) { + text = ': not checked. The library check failed.'; + action = 'Try again'; + } else { + text = ': not checked yet.'; + action = 'Check again'; + } + return ( + <div className="flex flex-wrap items-center gap-x-1 rounded-ui border border-border px-3 py-2.5 text-[13px]"> + <Server size={15} aria-hidden="true" className="text-muted-foreground" /> + <span className="font-semibold">{status.name}</span> + <span className="text-muted-foreground">{text}</span> + {status.display === 'checking' && <Loader2 size={14} aria-hidden="true" className="animate-spin text-muted-foreground" />} + {action && ( + <button type="button" onClick={() => { void page.check.refetch(); }} + className={page.phone ? 'min-h-[44px] text-primary underline' : 'text-primary underline'}> + {action} + </button> + )} + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/SetupBox.tsx b/client/src/components/LibraryFolders/components/SetupBox.tsx new file mode 100644 index 000000000..9baba139e --- /dev/null +++ b/client/src/components/LibraryFolders/components/SetupBox.tsx @@ -0,0 +1,47 @@ +import React, { Fragment } from 'react'; +import { RefreshCw } from '../../../lib/icons'; +import { Button, CircularProgress } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryLayout } from '../../../types/tvShows'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { SETUP_SERVER_NAMES, ServerPath, SetupServer, setupRows } from '../libraryTypes'; +import { CopyButton } from './CopyButton'; + +export interface SetupBoxProps { + server: SetupServer; + layout: LibraryLayout; + path: ServerPath; + /** "I added it, check again" (not for the no-server and panel variants) */ + showCheckAgain?: boolean; +} + +/** "Create it in {Server}": the new library's settings (UI 5.7.2.4). */ +export function SetupBox({ server, layout, path, showCheckAgain = true }: SetupBoxProps) { + const { phone, check } = useLibraryPage(); + const name = SETUP_SERVER_NAMES[server]; + return ( + <div className="rounded-ui border border-border bg-card"> + <p className="px-2.5 py-2 text-[13px] font-semibold">Create it in {name}</p> + <dl className={cn('grid gap-x-2.5 gap-y-1.5 px-2.5 pb-2 text-[12.5px] max-[340px]:grid-cols-1', phone ? 'grid-cols-[112px_1fr]' : 'grid-cols-[150px_1fr]')}> + {setupRows(server, layout, path).map((row) => ( + <Fragment key={row.key}> + <dt className="text-muted-foreground">{row.key}</dt> + <dd className={cn('flex items-start gap-1 font-medium', row.path && 'font-mono text-xs [overflow-wrap:anywhere]')}> + <span>{row.value}</span> + {row.path?.copyable && <CopyButton text={row.path.text} />} + </dd> + </Fragment> + ))} + </dl> + {showCheckAgain && ( + <div className="px-2.5 pb-2.5"> + <Button variant="outlined" size="sm" disabled={check.loading} onClick={() => { void check.refetch(); }} + startIcon={check.loading ? <CircularProgress size={14} /> : <RefreshCw size={14} />} + className={phone ? 'min-h-[44px] w-full' : 'h-[30px]'}> + I added it, check again + </Button> + </div> + )} + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/StatusCell.tsx b/client/src/components/LibraryFolders/components/StatusCell.tsx new file mode 100644 index 000000000..425315067 --- /dev/null +++ b/client/src/components/LibraryFolders/components/StatusCell.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { CheckCircle, Loader2, MinusCircle, Warning, XCircle } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; +import type { ServerDisplay, ServerStatus } from '../../../utils/libraryAttention'; +import { statusDescription } from '../mediaServerText'; + +const HOLLOW_DOT = 'border-[1.5px] border-muted-foreground'; + +export const STATUS_TONE: Record<ServerDisplay, { text: string; border: string; dot: string; Icon: LucideIcon }> = { + ok: { text: 'text-success', border: 'border-success', dot: 'bg-success', Icon: CheckCircle }, + issues: { text: 'text-warning', border: 'border-warning', dot: 'bg-warning', Icon: Warning }, + noLibrary: { text: 'text-destructive', border: 'border-destructive', dot: 'bg-destructive', Icon: XCircle }, + fine: { text: 'text-muted-foreground', border: 'border-border', dot: HOLLOW_DOT, Icon: MinusCircle }, + unchecked: { text: 'text-muted-foreground', border: 'border-border', dot: HOLLOW_DOT, Icon: Warning }, + checking: { text: 'text-muted-foreground', border: 'border-border', dot: HOLLOW_DOT, Icon: Loader2 }, +}; + +/** A wide row's 120px status cell (UI 4.4). */ +export function StatusCell({ status }: { status: ServerStatus }) { + const tone = STATUS_TONE[status.display]; + const description = statusDescription(status); + return ( + <span title={description} className={cn('inline-flex h-6 w-[120px] items-center gap-1 rounded-ui border px-1.5 text-xs', tone.text, tone.border)}> + <tone.Icon size={13} aria-hidden="true" className={cn('shrink-0', status.display === 'checking' && 'animate-spin')} /> + <span aria-hidden="true" className="truncate">{status.word}</span> + <span className="sr-only">{description}</span> + </span> + ); +} + +/** A stacked row's status line: a dot, the server and its word per server (UI 6.2). */ +export function StatusLine({ statuses }: { statuses: ServerStatus[] }) { + return ( + <span className="flex flex-wrap gap-x-3.5 gap-y-1 text-xs"> + {statuses.map((status) => { + const tone = STATUS_TONE[status.display]; + return ( + <span key={status.serverType} className="inline-flex items-center gap-1.5"> + <span aria-hidden="true" className={cn('inline-block h-2 w-2 rounded-full', tone.dot)} /> + <span className="text-muted-foreground">{status.name}</span> + <span className={tone.text}>{status.display === 'ok' ? 'OK' : status.word}</span> + </span> + ); + })} + </span> + ); +} diff --git a/client/src/components/LibraryFolders/components/UnusedFolderRow.tsx b/client/src/components/LibraryFolders/components/UnusedFolderRow.tsx new file mode 100644 index 000000000..87fdd6dca --- /dev/null +++ b/client/src/components/LibraryFolders/components/UnusedFolderRow.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useId, useRef } from 'react'; +import { Film, Trash2, Tv } from '../../../lib/icons'; +import { Button, IconButton } from '../../ui'; +import { cn } from '../../../lib/cn'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { libraryFolderLabel } from '../../../utils/libraryLayouts'; +import { useLibraryPage } from '../LibraryFoldersContext'; +import { deleteReasons, layoutName, SEP } from '../folderText'; + +export interface UnusedFolderRowProps { + folder: LibraryFolder; + selected: boolean; + autoFocus?: boolean; + onFocused?: () => void; + onSelect: (name: string) => void; +} + +/** An empty folder nothing downloads to: muted, last in its shelf, with Delete (UI 5.6.2). */ +export function UnusedFolderRow({ folder, selected, autoFocus = false, onFocused, onSelect }: UnusedFolderRowProps) { + const { phone, twoColumn, openDelete } = useLibraryPage(); + const ref = useRef<HTMLButtonElement>(null); + const reasonId = useId(); + useEffect(() => { + if (!autoFocus) return; + ref.current?.focus(); + onFocused?.(); + }, [autoFocus, onFocused]); + const label = libraryFolderLabel(folder.name); + const reason = folder.deletable ? null : deleteReasons(folder)[0] ?? null; + const Icon = folder.layout === 'tv' ? Tv : Film; + return ( + <div className={cn('flex items-center border-t border-border', selected && 'bg-muted/40 ring-1 ring-inset ring-primary')}> + <button + ref={ref} + type="button" + onClick={() => onSelect(folder.name)} + aria-current={selected ? 'true' : undefined} + aria-controls={twoColumn ? 'library-inspector' : undefined} + className={cn( + 'grid min-w-0 flex-1 grid-cols-[16px_minmax(0,1fr)] gap-x-2.5 gap-y-[3px] px-3 pb-2.5 pt-[9px] text-left text-muted-foreground hover:bg-muted/40', + phone && 'min-h-[52px]' + )} + > + <Icon size={16} aria-hidden="true" data-testid="unused-folder-icon" className={cn('mt-0.5', selected && 'text-primary')} /> + <span className="truncate text-sm font-semibold">{label}</span> + <span className="col-start-2 text-[12.5px]"> + {phone ? `${layoutName(folder.layout)}${SEP}Empty` : `Unused${SEP}Empty`} + {!phone && reason ? <>{SEP}<span id={reasonId}>{reason}</span></> : null} + </span> + {phone && reason ? <span id={reasonId} className="col-start-2 text-[12.5px]">{reason}</span> : null} + </button> + <span className="shrink-0 pr-3"> + {phone ? ( + <IconButton + color={folder.deletable ? 'error' : 'inherit'} + aria-label={`Delete ${label}`} + aria-describedby={reason ? reasonId : undefined} + disabled={!folder.deletable} + onClick={() => openDelete(folder)} + className="h-11 w-11 border border-border p-0 [&>svg]:!h-[18px] [&>svg]:!w-[18px]" + > + <Trash2 size={18} aria-hidden="true" data-testid="delete-icon" className="!h-[18px] !w-[18px]" /> + </IconButton> + ) : ( + <Button + variant="outlined" + color={folder.deletable ? 'error' : 'inherit'} + size="sm" + aria-label={`Delete ${label}`} + aria-describedby={reason ? reasonId : undefined} + disabled={!folder.deletable} + onClick={() => openDelete(folder)} + startIcon={<Trash2 size={14} aria-hidden="true" />} + className="h-[30px]" + > + Delete + </Button> + )} + </span> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/__tests__/AddFolderDialog.test.tsx b/client/src/components/LibraryFolders/components/__tests__/AddFolderDialog.test.tsx new file mode 100644 index 000000000..ea41636c2 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/AddFolderDialog.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AddFolderDialog } from '../dialogs/AddFolderDialog'; +import { folderNameError } from '../dialogs/AddFolderNameStep'; +import { folder, inPage, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import { useCreateLibraryFolder } from '../../hooks/useCreateLibraryFolder'; +import { useLibraryCheck } from '../../../../hooks/useLibraryCheck'; +import { ReorganizeRequiredError } from '../../../shared/Reorganize'; + +jest.mock('../../hooks/useCreateLibraryFolder', () => ({ useCreateLibraryFolder: jest.fn() })); +jest.mock('../../../../hooks/useLibraryCheck', () => ({ useLibraryCheck: jest.fn() })); + +describe('folderNameError', () => { + test('mirrors the server rules', () => { + expect(folderNameError('__Kids', [])).toBe('Leave out the underscores: Youtarr adds __ for you.'); + expect(folderNameError('Kids!', [])).toBe('Use letters, numbers, spaces, hyphens and underscores only.'); + expect(folderNameError('x'.repeat(101), [])).toBe('Use 100 characters or fewer.'); + expect(folderNameError('Playlists', [])).toBe('"playlists" is reserved for Youtarr\'s playlist files.'); + expect(folderNameError('kids', ['Kids'])).toBe('There is already a folder named __kids.'); + expect(folderNameError('Science Shows', ['Kids'])).toBeNull(); + }); +}); + +describe('AddFolderDialog', () => { + let createFolder: jest.Mock; + beforeEach(() => { + createFolder = jest.fn().mockResolvedValue({ name: 'Science', layout: 'tv', created: true, existingContent: false }); + (useCreateLibraryFolder as jest.Mock).mockReturnValue({ creating: false, createFolder }); + (useLibraryCheck as jest.Mock).mockReturnValue({ data: null, loading: false, error: null, lastCheckedAt: null, refetch: jest.fn(), applyPlexMapping: jest.fn() }); + }); + + test('creates with the preset layout, selects it and goes to step 2', async () => { + const onCreated = jest.fn(); + renderInPage(<AddFolderDialog open initialLayout="tv" onClose={jest.fn()} onCreated={onCreated} onNeedsReview={jest.fn()} />, + { value: makePageValue({ folders: [folder('', { isDefault: true })] }) }); + expect(screen.getByRole('radio', { name: /TV shows/ })).toBeChecked(); + await userEvent.type(screen.getByLabelText('Folder name'), 'Science'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Science' })); + expect(createFolder).toHaveBeenCalledWith('Science', 'tv'); + expect(onCreated).toHaveBeenCalledWith('Science'); + expect(await screen.findByRole('heading', { name: 'Set up your media servers' })).toBeInTheDocument(); + expect(screen.getByText(/Next: choose channels for __Science/)).toBeInTheDocument(); + }); + + test('shows a server error on step 1', async () => { + createFolder.mockRejectedValue(new Error("Couldn't create the folder on disk: EACCES")); + renderInPage(<AddFolderDialog open initialLayout="videos" onClose={jest.fn()} onCreated={jest.fn()} onNeedsReview={jest.fn()} />); + await userEvent.type(screen.getByLabelText('Folder name'), 'Science'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Science' })); + expect(await screen.findByText("Couldn't create the folder on disk: EACCES")).toBeInTheDocument(); + expect(screen.getByLabelText('Folder name')).toHaveAttribute('aria-invalid', 'true'); + }); + + test('hands a directory with files over to the review', async () => { + const change = { type: 'folderLayout' as const, folder: 'Old', layout: 'tv' as const }; + createFolder.mockRejectedValue(new ReorganizeRequiredError('Review the move', change)); + const onNeedsReview = jest.fn(); + const onCreated = jest.fn(); + renderInPage(<AddFolderDialog open initialLayout="tv" onClose={jest.fn()} onCreated={onCreated} onNeedsReview={onNeedsReview} />); + await userEvent.type(screen.getByLabelText('Folder name'), 'Old'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Old' })); + expect(onNeedsReview).toHaveBeenCalledWith(change, 'Old'); + expect(onCreated).not.toHaveBeenCalled(); + }); + + test('step 2 auto-applies the one Plex TV library when nothing was chosen', async () => { + const applyPlexMapping = jest.fn().mockResolvedValue(undefined); + (useLibraryCheck as jest.Mock).mockReturnValue({ loading: false, error: null, lastCheckedAt: null, refetch: jest.fn(), applyPlexMapping, data: { + servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null, downloadsPath: 'Q:\\Y' }], + folders: [{ name: 'Science', layout: 'tv', hasFiles: false, channels: 0, servers: [{ + serverType: 'plex', status: 'ok', issues: [], plexMapping: { mappedLibraryId: null, suggestedLibraryId: '41', choice: 'none' }, + libraries: [{ id: '41', name: 'YouTube TV', type: 'tv', location: 'Q:\\Y\\__Science', relation: 'exact' }], + }] }], + } }); + renderInPage(<AddFolderDialog open initialLayout="tv" onClose={jest.fn()} onCreated={jest.fn()} onNeedsReview={jest.fn()} />, + { value: makePageValue({ servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'] }) }); + await userEvent.type(screen.getByLabelText('Folder name'), 'Science'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Science' })); + expect(await screen.findByText('New episodes in __Science now refresh YouTube TV.')).toBeInTheDocument(); + expect(applyPlexMapping).toHaveBeenCalledWith('Science', '41'); + }); + + test('step 2 moves focus to its heading when no media server is connected', async () => { + renderInPage(<AddFolderDialog open initialLayout="videos" onClose={jest.fn()} onCreated={jest.fn()} onNeedsReview={jest.fn()} />); + await userEvent.type(screen.getByLabelText('Folder name'), 'Science'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Science' })); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Set up your media servers' })).toHaveFocus()); + }); + + test('step 2 moves focus to the first server section, which stays put when the check answers', async () => { + const checking = { data: null, loading: true, error: null, lastCheckedAt: null, refetch: jest.fn(), applyPlexMapping: jest.fn() }; + (useLibraryCheck as jest.Mock).mockReturnValue(checking); + const value = makePageValue({ + servers: [{ serverType: 'plex', name: 'Plex' }, { serverType: 'jellyfin', name: 'Jellyfin' }], configuredServers: ['plex', 'jellyfin'], + }); + const { rerender } = renderInPage(<AddFolderDialog open initialLayout="videos" onClose={jest.fn()} onCreated={jest.fn()} onNeedsReview={jest.fn()} />, { value }); + await userEvent.type(screen.getByLabelText('Folder name'), 'Science'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Science' })); + await waitFor(() => expect(screen.getByTestId('add-folder-server-plex')).toHaveFocus()); + expect(within(screen.getByTestId('add-folder-server-plex')).getByText('Checking Plex...')).toBeInTheDocument(); + + (useLibraryCheck as jest.Mock).mockReturnValue({ ...checking, loading: false, data: { + servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null, downloadsPath: '/data' }, + { serverType: 'jellyfin', name: 'Jellyfin', reachable: true, error: null, downloadsPath: '/data' }], + folders: [], + } }); + rerender(inPage(<AddFolderDialog open initialLayout="videos" onClose={jest.fn()} onCreated={jest.fn()} onNeedsReview={jest.fn()} />, value)); + expect(screen.queryByText('Checking Plex...')).not.toBeInTheDocument(); + expect(screen.getByTestId('add-folder-server-plex')).toHaveFocus(); + }); + + test('step 2 gives See your options a 44px target on phones', async () => { + (useLibraryCheck as jest.Mock).mockReturnValue({ loading: false, error: null, lastCheckedAt: null, refetch: jest.fn(), applyPlexMapping: jest.fn(), data: { + servers: [{ serverType: 'jellyfin', name: 'Jellyfin', reachable: true, error: null, downloadsPath: '/data' }], + folders: [{ name: 'Science', layout: 'tv', hasFiles: false, channels: 0, servers: [{ + serverType: 'jellyfin', status: 'missing', issues: [{ code: 'nestedLibrary', message: 'Nested', libraryId: '1' }], + libraries: [{ id: '1', name: 'YouTube', type: 'videos', location: '/data', relation: 'covers' }], + }] }], + } }); + renderInPage(<AddFolderDialog open initialLayout="tv" onClose={jest.fn()} onCreated={jest.fn()} onNeedsReview={jest.fn()} />, + { value: makePageValue({ phone: true, servers: [{ serverType: 'jellyfin', name: 'Jellyfin' }], configuredServers: ['jellyfin'] }) }); + await userEvent.type(screen.getByLabelText('Folder name'), 'Science'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Science' })); + expect(await screen.findByRole('button', { name: 'See your options' })).toHaveClass('inline-flex', 'min-h-[44px]', 'items-center'); + }); + + const stepTwoNote = async (configured: Array<'plex' | 'jellyfin' | 'emby'>) => { + const names = { plex: 'Plex', jellyfin: 'Jellyfin', emby: 'Emby' }; + (useLibraryCheck as jest.Mock).mockReturnValue({ data: { servers: [], folders: [] }, loading: false, error: null, lastCheckedAt: null, refetch: jest.fn(), applyPlexMapping: jest.fn() }); + renderInPage(<AddFolderDialog open initialLayout="videos" onClose={jest.fn()} onCreated={jest.fn()} onNeedsReview={jest.fn()} />, + { value: makePageValue({ servers: configured.map((serverType) => ({ serverType, name: names[serverType] })), configuredServers: configured }) }); + await userEvent.type(screen.getByLabelText('Folder name'), 'Science'); + await userEvent.click(screen.getByRole('button', { name: 'Create __Science' })); + await screen.findByRole('heading', { name: 'Set up your media servers' }); + }; + + test('step 2 mentions only Kodi when every server is connected', async () => { + await stepTwoNote(['plex', 'jellyfin', 'emby']); + expect(screen.getByText("Kodi isn't checked. Kodi: a TV shows source set to Local information only.")).toBeInTheDocument(); + }); + + test('step 2 names each server that is not connected', async () => { + await stepTwoNote(['plex']); + expect(screen.getByText("Jellyfin and Emby aren't connected and Kodi isn't checked. Emby: a TV shows library, NFO reader on, NFO saver and downloaders off. Kodi: a TV shows source set to Local information only.")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/AfterwardsNote.test.tsx b/client/src/components/LibraryFolders/components/__tests__/AfterwardsNote.test.tsx new file mode 100644 index 000000000..4b6393dca --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/AfterwardsNote.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AfterwardsNote } from '../AfterwardsNote'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import { useLibraryCheck } from '../../../../hooks/useLibraryCheck'; + +jest.mock('../../../../hooks/useLibraryCheck', () => ({ useLibraryCheck: jest.fn() })); + +describe('AfterwardsNote', () => { + test('checks the folder as the target layout', () => { + (useLibraryCheck as jest.Mock).mockReturnValue({ data: { servers: [], folders: [{ name: 'Kids', layout: 'tv', hasFiles: false, channels: 1, servers: [ + { serverType: 'plex', status: 'missing', libraries: [], issues: [] }, + ] }] }, loading: false, error: null }); + renderInPage(<AfterwardsNote folder={folder('Kids')} target="tv" />, { value: makePageValue({ servers: [{ serverType: 'plex', name: 'Plex' }] }) }); + expect(useLibraryCheck).toHaveBeenCalledWith('token', { folders: ['Kids'], layout: 'tv', enabled: true }); + expect(screen.getByText('Plex: needs a TV Shows library on __Kids.')).toBeInTheDocument(); + }); + + test('without servers says what the libraries must become', () => { + (useLibraryCheck as jest.Mock).mockReturnValue({ data: null, loading: false, error: null }); + renderInPage(<AfterwardsNote folder={folder('Kids')} target="tv" />); + expect(screen.getByText('Afterwards its media server libraries must be the TV shows type.')).toBeInTheDocument(); + }); + + describe('overlap issue', () => { + const overlapCheck = () => (useLibraryCheck as jest.Mock).mockReturnValue({ data: { servers: [], folders: [{ name: 'Kids', layout: 'tv', hasFiles: false, channels: 1, servers: [ + { serverType: 'plex', status: 'issues', libraries: [], issues: [{ code: 'overlap', message: 'overlaps' }] }, + ] }] }, loading: false, error: null }); + const servers = [{ serverType: 'plex' as const, name: 'Plex' }]; + + test('offers all options and opens the start dialog', async () => { + overlapCheck(); + const value = makePageValue({ servers }); + renderInPage(<AfterwardsNote folder={folder('Kids')} target="tv" />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'See all your options' })); + expect(value.openStartTv).toHaveBeenCalled(); + }); + + test('gives the options button a 44px target on phones', () => { + overlapCheck(); + renderInPage(<AfterwardsNote folder={folder('Kids')} target="tv" />, { value: makePageValue({ servers, phone: true }) }); + expect(screen.getByRole('button', { name: 'See all your options' })).toHaveClass('min-h-[44px]'); + }); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/AttentionStrip.test.tsx b/client/src/components/LibraryFolders/components/__tests__/AttentionStrip.test.tsx new file mode 100644 index 000000000..df1c59b6e --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/AttentionStrip.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AttentionStrip } from '../AttentionStrip'; +import { makePageValue, renderInPage } from '../../__tests__/renderPage'; +import type { AttentionItem } from '../../../../utils/libraryAttention'; + +const items: AttentionItem[] = [ + { kind: 'library', key: 'l', serverType: 'jellyfin', serverName: 'Jellyfin', libraryId: '1', libraryName: 'YouTube', code: 'nfoSaver', + short: 'saves NFO files', folders: ['Kids', 'Music'], text: 'Jellyfin library YouTube saves NFO files (affects 2 folders)' }, + { kind: 'folder', key: 'f', folder: 'Docs', label: '__Docs', layout: 'videos', + servers: [{ serverType: 'plex', name: 'Plex', display: 'noLibrary', issueCount: 0, firstMessage: null }], text: '__Docs (Plex)' }, +]; + +describe('AttentionStrip', () => { + test('renders nothing without items', () => { + renderInPage(<AttentionStrip items={[]} />); + expect(screen.queryByText(/Needs attention/)).not.toBeInTheDocument(); + }); + + test('jumps to a folder, and to the first affected folder of a library-wide item', async () => { + const value = makePageValue(); + renderInPage(<AttentionStrip items={items} />, { value }); + await userEvent.click(screen.getByRole('button', { name: '__Docs (Plex)' })); + await userEvent.click(screen.getByRole('button', { name: /Jellyfin library YouTube/ })); + expect(value.jumpTo).toHaveBeenNthCalledWith(1, 'Docs'); + expect(value.jumpTo).toHaveBeenNthCalledWith(2, 'Kids', 'jellyfin'); + }); + + test('on phones keeps the check status and a refresh button', async () => { + const value = makePageValue({ phone: true, check: { ...makePageValue().check, refetch: jest.fn() } }); + renderInPage(<AttentionStrip items={items} />, { value }); + expect(screen.getByText('Needs attention')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Check media servers again' })); + expect(value.check.refetch).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/ChannelLinks.test.tsx b/client/src/components/LibraryFolders/components/__tests__/ChannelLinks.test.tsx new file mode 100644 index 000000000..d8d7652e6 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/ChannelLinks.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChannelLinks } from '../ChannelLinks'; +import { renderInPage } from '../../__tests__/renderPage'; + +describe('ChannelLinks', () => { + test('shows eight, then expands the rest', async () => { + const channels = Array.from({ length: 10 }, (_, i) => ({ channelId: `UC${i}`, name: `Channel ${i}` })); + renderInPage(<ChannelLinks channels={channels} />); + expect(screen.getAllByRole('link')).toHaveLength(8); + await userEvent.click(screen.getByRole('button', { name: 'and 2 more' })); + expect(screen.getAllByRole('link')).toHaveLength(10); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/CheckBanners.test.tsx b/client/src/components/LibraryFolders/components/__tests__/CheckBanners.test.tsx new file mode 100644 index 000000000..3a83b63d8 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/CheckBanners.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CheckBanners } from '../CheckBanners'; +import { makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('CheckBanners', () => { + test('one banner per unreachable server, with Check again', async () => { + const value = makePageValue({ check: { ...makePageValue().check, data: { + servers: [{ serverType: 'jellyfin', name: 'Jellyfin', reachable: false, error: 'connect ECONNREFUSED' }], folders: [], + } } }); + renderInPage(<CheckBanners missingName={null} onDismissMissing={jest.fn()} />, { value }); + expect(screen.getByText("Jellyfin couldn't be reached, so its libraries weren't checked: connect ECONNREFUSED")).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Jellyfin settings' })).toHaveAttribute('href', '/settings/jellyfin'); + await userEvent.click(screen.getByRole('button', { name: 'Check again' })); + expect(value.check.refetch).toHaveBeenCalled(); + }); + + test('a failed check and a missing folder', async () => { + const onDismiss = jest.fn(); + renderInPage(<CheckBanners missingName="Gone" onDismissMissing={onDismiss} />, { value: makePageValue({ + check: { ...makePageValue().check, error: 'boom' }, + }) }); + expect(screen.getByText("Couldn't check the media server libraries: boom")).toBeInTheDocument(); + expect(screen.getByText("Library folder __Gone wasn't found. It may have been deleted.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(onDismiss).toHaveBeenCalled(); + }); + + test('phone: the banner buttons and links are 44px targets', () => { + const value = makePageValue({ phone: true, check: { ...makePageValue().check, data: { + servers: [{ serverType: 'plex', name: 'Plex', reachable: false, error: 'timeout' }], folders: [], + } } }); + renderInPage(<CheckBanners missingName={null} onDismissMissing={jest.fn()} />, { value }); + expect(screen.getByRole('button', { name: 'Check again' })).toHaveClass('min-h-[44px]'); + expect(screen.getByRole('link', { name: 'Plex settings' })).toHaveClass('inline-flex', 'min-h-[44px]'); + }); + + test('nothing to report renders no banner', () => { + renderInPage(<CheckBanners missingName={null} onDismissMissing={jest.fn()} />); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/CopyButton.test.tsx b/client/src/components/LibraryFolders/components/__tests__/CopyButton.test.tsx new file mode 100644 index 000000000..138cb0dfa --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/CopyButton.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CopyButton } from '../CopyButton'; +import { makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('CopyButton', () => { + test('copies its text and confirms', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) } }); + renderInPage(<CopyButton text="/yt/__Kids" label="Copy folder" />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Copy folder' })); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/yt/__Kids'); + expect(value.notify).toHaveBeenCalledWith('Copied'); + }); + + test('says so, as an error, when the clipboard refuses', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) } }); + renderInPage(<CopyButton text="/yt" />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Copy path' })); + expect(value.notify).toHaveBeenCalledWith("Couldn't copy to the clipboard", 'error'); + }); + + test('falls back to a hidden textarea copy where the clipboard API is missing', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: undefined }); + const exec = jest.fn().mockReturnValue(true); + Object.assign(document, { execCommand: exec }); + renderInPage(<CopyButton text="/yt/__Kids" />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Copy path' })); + expect(exec).toHaveBeenCalledWith('copy'); + expect(value.notify).toHaveBeenCalledWith('Copied'); + }); + + test('reports a failure, as an error, when the fallback copy fails too', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: undefined }); + Object.assign(document, { execCommand: jest.fn().mockReturnValue(false) }); + renderInPage(<CopyButton text="/yt" />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Copy path' })); + expect(value.notify).toHaveBeenCalledWith("Couldn't copy to the clipboard", 'error'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/DeleteFolderDialog.test.tsx b/client/src/components/LibraryFolders/components/__tests__/DeleteFolderDialog.test.tsx new file mode 100644 index 000000000..cd6b57c9a --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/DeleteFolderDialog.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'; +import { folder, renderInPage } from '../../__tests__/renderPage'; +import { useSubfolders } from '../../../../hooks/useSubfolders'; + +jest.mock('../../../../hooks/useSubfolders', () => ({ useSubfolders: jest.fn(), SUBFOLDERS_UPDATED_EVENT: 'subfolders-updated' })); + +describe('DeleteFolderDialog', () => { + test('deletes and reports back', async () => { + const deleteSubfolder = jest.fn().mockResolvedValue(undefined); + (useSubfolders as jest.Mock).mockReturnValue({ deleteSubfolder }); + const onDeleted = jest.fn(); + const target = folder('Empty', { plexMapping: { choice: 'library', libraryId: '3' } }); + renderInPage(<DeleteFolderDialog folder={target} onClose={jest.fn()} onDeleted={onDeleted} />); + expect(screen.getByText(/Its Plex refresh setting is removed too\./)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Delete folder' })); + expect(deleteSubfolder).toHaveBeenCalledWith('Empty'); + expect(onDeleted).toHaveBeenCalledWith(target); + }); + + test('a refusal stays in the dialog and disables confirm', async () => { + (useSubfolders as jest.Mock).mockReturnValue({ deleteSubfolder: jest.fn().mockRejectedValue(new Error('Subfolder is in use by 1 channel(s)')) }); + renderInPage(<DeleteFolderDialog folder={folder('Empty')} onClose={jest.fn()} onDeleted={jest.fn()} />); + await userEvent.click(screen.getByRole('button', { name: 'Delete folder' })); + expect(await screen.findByText('Subfolder is in use by 1 channel(s)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete folder' })).toBeDisabled(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/DeleteSection.test.tsx b/client/src/components/LibraryFolders/components/__tests__/DeleteSection.test.tsx new file mode 100644 index 000000000..5715e7028 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/DeleteSection.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DeleteSection } from '../DeleteSection'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('DeleteSection', () => { + test('the main folder can never be deleted', () => { + renderInPage(<DeleteSection folder={folder('', { deletable: false, deleteBlockers: [{ code: 'main' }] })} />); + const button = screen.getByRole('button', { name: 'Delete folder' }); + expect(button).toBeDisabled(); + expect(button).toHaveAccessibleDescription("The main folder is the downloads folder itself, so it can't be deleted."); + }); + + test('a deletable folder opens the confirm', async () => { + const value = makePageValue(); + const empty = folder('Empty'); + renderInPage(<DeleteSection folder={empty} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Delete folder' })); + expect(value.openDelete).toHaveBeenCalledWith(empty); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/DownloadsHereSection.test.tsx b/client/src/components/LibraryFolders/components/__tests__/DownloadsHereSection.test.tsx new file mode 100644 index 000000000..c3e6e7380 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/DownloadsHereSection.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { DownloadsHereSection } from '../DownloadsHereSection'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import type { LibraryFolderDetail } from '../../../../types/tvShows'; + +const detail = (extra: Partial<LibraryFolderDetail> = {}): LibraryFolderDetail => ({ + name: 'Kids', layout: 'videos', channels: [], followers: { count: 0, sample: [] }, playlists: [], titleShows: [], example: null, ...extra, +}); + +describe('DownloadsHereSection', () => { + test('the default folder lists choosers, followers and the fallback', () => { + renderInPage(<DownloadsHereSection folder={folder('Kids', { isDefault: true, fileCount: 12 })} detail={detail({ + channels: [{ channelId: 'UC1', name: 'Blippi', videoCount: 12 }], + followers: { count: 5, sample: ['Abe', 'Bea', 'Cy'] }, + })} />); + expect(screen.getByText('12 videos on disk')).toBeInTheDocument(); + expect(screen.getByText('1 chose this folder')).toBeInTheDocument(); + expect(screen.getByText('5 follow the default')).toBeInTheDocument(); + expect(screen.getByText('Set to the default folder: Abe, Bea, Cy and 2 more')).toBeInTheDocument(); + expect(screen.getByText(/Downloads with no more specific folder land here/)).toBeInTheDocument(); + }); + + test('a holds-videos folder says nothing downloads here now', () => { + renderInPage(<DownloadsHereSection folder={folder('Old', { fileCount: 1, hasFiles: true })} detail={detail()} />); + expect(screen.getByText('Nothing downloads here now. 1 video is on disk.')).toBeInTheDocument(); + }); + + test('gives the playlist and title show links 44px targets on phones', () => { + renderInPage(<DownloadsHereSection folder={folder('Kids', { layout: 'tv', channels: 1, playlists: 1, titleShows: 1 })} detail={detail({ + layout: 'tv', + playlists: [{ playlistId: 'PL1', name: 'Songs', videoCount: 4 }], + titleShows: [{ id: 7, name: 'Lessons', channelId: 'UC1', channelName: 'Blippi', episodeCount: 9 }], + })} />, { value: makePageValue({ phone: true }) }); + expect(screen.getByRole('link', { name: 'Songs' })).toHaveClass('inline-flex', 'min-h-[44px]', 'items-center'); + expect(screen.getByRole('link', { name: 'Lessons' })).toHaveClass('inline-flex', 'min-h-[44px]', 'items-center'); + }); + + test('an unused folder offers choosing channels', () => { + renderInPage(<DownloadsHereSection folder={folder('New')} detail={detail()} />); + expect(screen.getByRole('link', { name: 'Choose channels for __New' })).toHaveAttribute('href', '/subscriptions'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/FolderDetailScreen.test.tsx b/client/src/components/LibraryFolders/components/__tests__/FolderDetailScreen.test.tsx new file mode 100644 index 000000000..fc80c73e8 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/FolderDetailScreen.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FolderDetailScreen } from '../FolderDetailScreen'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +jest.mock('../FolderInspector', () => ({ FolderInspector: () => null })); + +describe('FolderDetailScreen', () => { + test('goes back to the list', async () => { + const onBack = jest.fn(); + renderInPage(<FolderDetailScreen folder={folder('Kids')} onBack={onBack} />, { value: makePageValue({ twoColumn: false }) }); + await userEvent.click(screen.getByRole('button', { name: 'Library folders' })); + expect(onBack).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/FolderInspector.test.tsx b/client/src/components/LibraryFolders/components/__tests__/FolderInspector.test.tsx new file mode 100644 index 000000000..82f5c3ca8 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/FolderInspector.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { FolderInspector } from '../FolderInspector'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import { useLibraryFolderDetail } from '../../hooks/useLibraryFolderDetail'; + +jest.mock('../../hooks/useLibraryFolderDetail', () => ({ useLibraryFolderDetail: jest.fn() })); +jest.mock('../MediaServersSection', () => ({ MediaServersSection: () => { + const React = require('react'); + return React.createElement('p', null, 'media servers'); +} })); +jest.mock('../LayoutSection', () => ({ LayoutSection: () => null })); +jest.mock('../DownloadsHereSection', () => ({ DownloadsHereSection: ({ detail }: { detail: { channels: Array<{ name: string }> } | null }) => { + const React = require('react'); + return React.createElement('p', null, `downloads here${detail ? `: ${detail.channels.map((channel) => channel.name).join(', ')}` : ''}`); +} })); + +describe('FolderInspector', () => { + beforeEach(() => (useLibraryFolderDetail as jest.Mock).mockReturnValue({ detail: null })); + + test('renders the sections in order and loads the detail', () => { + renderInPage(<FolderInspector folder={folder('Kids')} />); + expect(screen.getByRole('heading', { name: '__Kids' })).toBeInTheDocument(); + expect(screen.getByText('media servers')).toBeInTheDocument(); + expect(useLibraryFolderDetail).toHaveBeenCalledWith('token', 'Kids'); + }); + + test('the main folder takes the page\'s main folder detail instead of loading it again', () => { + const value = makePageValue({ mainDetail: { + name: '', layout: 'videos', channels: [{ channelId: 'UC1', name: 'Blippi', videoCount: 3 }], + followers: { count: 0, sample: [] }, playlists: [], titleShows: [], example: null, + } }); + renderInPage(<FolderInspector folder={folder('', { isDefault: true })} />, { value }); + expect(screen.getByText('downloads here: Blippi')).toBeInTheDocument(); + expect(useLibraryFolderDetail).toHaveBeenCalledWith('token', null); + }); + + test('takes focus after an attention jump and clears the target', () => { + const value = makePageValue({ focusTarget: { folder: 'kids' } }); + renderInPage(<FolderInspector folder={folder('Kids')} />, { value }); + expect(screen.getByRole('heading', { name: '__Kids' })).toHaveFocus(); + expect(value.clearFocusTarget).toHaveBeenCalled(); + }); + + test('an attention jump to a server focuses the heading without scrolling and scrolls the card', () => { + const card = document.createElement('div'); + card.id = 'server-card-plex'; + card.scrollIntoView = jest.fn(); + document.body.appendChild(card); + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + const value = makePageValue({ focusTarget: { folder: 'kids', serverType: 'plex' } }); + renderInPage(<FolderInspector folder={folder('Kids')} />, { value }); + expect(card.scrollIntoView).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + expect(screen.getByRole('heading', { name: '__Kids' })).toHaveFocus(); + expect(value.clearFocusTarget).toHaveBeenCalled(); + focusSpy.mockRestore(); + card.remove(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/FolderRow.test.tsx b/client/src/components/LibraryFolders/components/__tests__/FolderRow.test.tsx new file mode 100644 index 000000000..ed23ba92c --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/FolderRow.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FolderRow } from '../FolderRow'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import type { ServerStatus } from '../../../../utils/libraryAttention'; + +const ok: ServerStatus = { + serverType: 'plex', name: 'Plex', display: 'ok', word: 'YouTube', + report: { serverType: 'plex', status: 'ok', issues: [], libraries: [{ id: '1', name: 'YouTube', type: 'videos', location: '/yt', relation: 'exact' }] }, +}; +const kids = folder('Kids', { isDefault: true, channels: 24, channelsChosen: 3, channelsFollowing: 21, fileCount: 1208 }); + +describe('FolderRow', () => { + test('names the folder, its default chip, summary and server status', () => { + renderInPage(<FolderRow folder={kids} statuses={[ok]} selected={false} wide moving={false} onSelect={jest.fn()} />); + const row = screen.getByRole('button', { name: /__Kids/ }); + expect(row).toHaveTextContent('Default'); + expect(row).toHaveTextContent('3 chose this folder, 21 follow the default'); + expect(screen.getByText('Plex: OK, YouTube.')).toBeInTheDocument(); + }); + + test('marks the selected row and selects on click', async () => { + const onSelect = jest.fn(); + renderInPage(<FolderRow folder={kids} statuses={[ok]} selected wide moving onSelect={onSelect} />); + const row = screen.getByRole('button', { name: /__Kids/ }); + expect(row).toHaveAttribute('aria-current', 'true'); + expect(row).toHaveTextContent('Moving'); + await userEvent.click(row); + expect(onSelect).toHaveBeenCalledWith('Kids'); + }); + + test('stacked rows show the status line with words', () => { + renderInPage(<FolderRow folder={kids} statuses={[ok]} selected={false} wide={false} moving={false} onSelect={jest.fn()} />); + expect(screen.getByRole('button', { name: /__Kids/ })).toHaveTextContent('PlexOK'); + }); + + test('stacked rows show the chevron only where a row opens a detail screen', () => { + const { unmount } = renderInPage(<FolderRow folder={kids} statuses={[ok]} selected={false} wide={false} moving={false} onSelect={jest.fn()} />, + { value: makePageValue({ twoColumn: false }) }); + expect(screen.getByTestId('row-chevron')).toBeInTheDocument(); + unmount(); + renderInPage(<FolderRow folder={kids} statuses={[ok]} selected={false} wide={false} moving={false} onSelect={jest.fn()} />, + { value: makePageValue({ twoColumn: true }) }); + expect(screen.queryByTestId('row-chevron')).not.toBeInTheDocument(); + }); + + test('points aria-controls at the inspector only in two columns', () => { + const { unmount } = renderInPage(<FolderRow folder={kids} statuses={[ok]} selected={false} wide moving={false} onSelect={jest.fn()} />, { value: makePageValue({ twoColumn: true }) }); + expect(screen.getByRole('button', { name: /__Kids/ })).toHaveAttribute('aria-controls', 'library-inspector'); + unmount(); + renderInPage(<FolderRow folder={kids} statuses={[ok]} selected={false} wide moving={false} onSelect={jest.fn()} />, { value: makePageValue({ twoColumn: false }) }); + expect(screen.getByRole('button', { name: /__Kids/ })).not.toHaveAttribute('aria-controls'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/FolderShelf.test.tsx b/client/src/components/LibraryFolders/components/__tests__/FolderShelf.test.tsx new file mode 100644 index 000000000..42c198f00 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/FolderShelf.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FolderShelf } from '../FolderShelf'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +let mockWidth = 900; +jest.mock('../../../../hooks/useContainerWidth', () => ({ useContainerWidth: () => [jest.fn(), mockWidth] })); + +const unused = ['A', 'B', 'C', 'D', 'E'].map((name) => folder(name)); +const folders = [folder('Zoo', { channels: 1 }), folder('', { channels: 2 }), folder('Kids', { isDefault: true }), folder('Old', { fileCount: 3 }), ...unused]; + +describe('FolderShelf', () => { + beforeEach(() => { mockWidth = 900; }); + + test('orders main, default, active, holds videos, then the first three unused', () => { + renderInPage(<FolderShelf layout="videos" folders={folders} selectedName={null} focusName={null} onFocused={jest.fn()} />); + const shelf = screen.getByRole('region', { name: /Video folders/ }); + expect(within(shelf).getAllByText(/^(Main folder|__\w+)$/).map((el) => el.textContent)).toEqual([ + 'Main folder', '__Kids', '__Zoo', '__Old', '__A', '__B', '__C', + ]); + expect(screen.getByRole('button', { name: 'Show 2 more unused' })).toBeInTheDocument(); + }); + + test('starts expanded when the selected folder is a hidden unused one', () => { + renderInPage(<FolderShelf layout="videos" folders={folders} selectedName="E" focusName={null} onFocused={jest.fn()} />); + expect(screen.getByRole('button', { name: /^__E/ })).toHaveAttribute('aria-current', 'true'); + expect(screen.getByRole('button', { name: 'Show fewer' })).toBeInTheDocument(); + }); + + test('expands and focuses a hidden unused folder that is the focus target', () => { + const onFocused = jest.fn(); + renderInPage(<FolderShelf layout="videos" folders={folders} selectedName={null} focusName="E" onFocused={onFocused} />); + expect(screen.getByRole('button', { name: /^__E/ })).toHaveFocus(); + expect(onFocused).toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Show fewer' })).toBeInTheDocument(); + }); + + test('on phones the Add button is labelled in full', () => { + renderInPage(<FolderShelf layout="tv" folders={[folder('TV', { layout: 'tv', channels: 1 })]} selectedName={null} focusName={null} onFocused={jest.fn()} />, + { value: makePageValue({ phone: true }) }); + expect(screen.getByRole('button', { name: 'Add a TV show folder' })).toHaveTextContent('Add'); + }); + + test('its Add button presets the layout', async () => { + const value = makePageValue(); + renderInPage(<FolderShelf layout="tv" folders={[folder('TV', { layout: 'tv', channels: 1 })]} selectedName={null} focusName={null} onFocused={jest.fn()} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Add TV folder' })); + expect(value.openAddFolder).toHaveBeenCalledWith('tv'); + }); + + test('without servers the header names every library type', () => { + renderInPage(<FolderShelf layout="tv" folders={[folder('TV', { layout: 'tv', channels: 1 })]} selectedName={null} focusName={null} onFocused={jest.fn()} />); + expect(screen.getByText(/Library type: Plex TV Shows, Jellyfin Shows, Emby TV shows, Kodi TV shows source/)).toBeInTheDocument(); + }); + + test('with three servers on a narrow shelf, rows stack their status', () => { + mockWidth = 600; + const three = [ + { serverType: 'plex' as const, name: 'Plex' }, { serverType: 'jellyfin' as const, name: 'Jellyfin' }, { serverType: 'emby' as const, name: 'Emby' }, + ]; + const base = makePageValue(); + const value = makePageValue({ + servers: three, + check: { ...base.check, data: { servers: three.map((server) => ({ ...server, reachable: true, error: null })), folders: [] } }, + }); + renderInPage(<FolderShelf layout="videos" folders={[folder('Kids', { channels: 1 })]} selectedName={null} focusName={null} onFocused={jest.fn()} />, { value }); + expect(screen.getByRole('button', { name: /__Kids/ })).toHaveTextContent('PlexNot checked'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/InspectorHeader.test.tsx b/client/src/components/LibraryFolders/components/__tests__/InspectorHeader.test.tsx new file mode 100644 index 000000000..8bf4b6203 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/InspectorHeader.test.tsx @@ -0,0 +1,61 @@ +import React, { createRef } from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { InspectorHeader } from '../InspectorHeader'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('InspectorHeader', () => { + test('shows the full path and offers Make default', async () => { + const value = makePageValue(); + const kids = folder('Kids'); + renderInPage(<InspectorHeader folder={kids} headingRef={createRef()} asPageTitle={false} />, { value }); + expect(screen.getByRole('heading', { level: 2, name: '__Kids' })).toBeInTheDocument(); + expect(screen.getByText('/usr/src/app/data/__Kids')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Make default' })); + expect(value.openMakeDefault).toHaveBeenCalledWith(kids); + }); + + test('Make default waits for a running reorganize', () => { + renderInPage(<InspectorHeader folder={folder('Kids')} headingRef={createRef()} asPageTitle={false} />, { value: makePageValue({ reorganizing: true }) }); + expect(screen.getByRole('button', { name: 'Make default' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Make default' })).toHaveAccessibleDescription('Downloads are being reorganized. Change the default folder when that finishes.'); + }); + + test('the overflow menu copies the path', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) } }); + renderInPage(<InspectorHeader folder={folder('', { isDefault: true })} headingRef={createRef()} asPageTitle />, { value }); + expect(screen.getByRole('heading', { level: 1, name: 'Main folder' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Make default' })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'More actions for Main folder' })); + await userEvent.click(screen.getByText('Copy full path')); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/usr/src/app/data'); + }); + + test('Copy full path works on plain HTTP, where the clipboard API is missing', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: undefined }); + const exec = jest.fn().mockReturnValue(true); + Object.assign(document, { execCommand: exec }); + renderInPage(<InspectorHeader folder={folder('Kids')} headingRef={createRef()} asPageTitle={false} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'More actions for __Kids' })); + await userEvent.click(screen.getByText('Copy full path')); + expect(exec).toHaveBeenCalledWith('copy'); + expect(value.notify).toHaveBeenCalledWith('Copied'); + }); + + test('a failed Copy full path is reported as an error', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: undefined }); + Object.assign(document, { execCommand: jest.fn().mockReturnValue(false) }); + renderInPage(<InspectorHeader folder={folder('Kids')} headingRef={createRef()} asPageTitle={false} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'More actions for __Kids' })); + await userEvent.click(screen.getByText('Copy full path')); + expect(value.notify).toHaveBeenCalledWith("Couldn't copy to the clipboard", 'error'); + }); + + test('the overflow button announces a menu', () => { + renderInPage(<InspectorHeader folder={folder('Kids')} headingRef={createRef()} asPageTitle={false} />); + expect(screen.getByRole('button', { name: 'More actions for __Kids' })).toHaveAttribute('aria-haspopup', 'menu'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/LayoutPreviewTree.test.tsx b/client/src/components/LibraryFolders/components/__tests__/LayoutPreviewTree.test.tsx new file mode 100644 index 000000000..e267604ea --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/LayoutPreviewTree.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { LayoutPreviewTree } from '../LayoutPreviewTree'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('LayoutPreviewTree', () => { + test('labels the tree as an example and links the template for Videos', () => { + renderInPage(<LayoutPreviewTree folder={folder('Kids')} layout="videos" previewing={false} example={null} />); + expect(screen.getByText('Example structure, with Channel Name')).toBeInTheDocument(); + expect(screen.getByText(/Example structure\. Review the move for exact file names\./)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Filename template: Settings > Core' })).toHaveAttribute('href', '/settings/core#naming'); + }); + + test('gives the template link a 44px target on phones', () => { + renderInPage(<LayoutPreviewTree folder={folder('Kids')} layout="videos" previewing={false} example={null} />, { value: makePageValue({ phone: true }) }); + expect(screen.getByRole('link', { name: 'Filename template: Settings > Core' })).toHaveClass('inline-flex', 'min-h-[44px]', 'items-center'); + }); + + test('mentions a custom template when previewing Videos', () => { + const value = makePageValue({ config: { ...makePageValue().config, videoFilenamePrefix: '%(title)s' } }); + renderInPage(<LayoutPreviewTree folder={folder('TV', { layout: 'tv' })} layout="videos" previewing example={null} />, { value }); + expect(screen.getByText(/Your filename template decides the real names\./)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/LayoutSection.test.tsx b/client/src/components/LibraryFolders/components/__tests__/LayoutSection.test.tsx new file mode 100644 index 000000000..ea875a8ff --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/LayoutSection.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LayoutSection } from '../LayoutSection'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +jest.mock('../AfterwardsNote', () => ({ AfterwardsNote: () => { + const React = require('react'); + return React.createElement('p', null, 'afterwards note'); +} })); + +describe('LayoutSection', () => { + test('a folder that switches at once', async () => { + const value = makePageValue(); + renderInPage(<LayoutSection folder={folder('Kids', { channels: 1 })} detail={null} />, { value }); + expect(screen.getByText('Nothing needs to move, so it switches at once.')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Use for TV shows' })); + expect(value.changeLayout).toHaveBeenCalledWith('Kids', 'tv'); + }); + + test('previewing shows the target tree and the afterwards note without saving', async () => { + const value = makePageValue(); + renderInPage(<LayoutSection folder={folder('Kids', { channels: 1, layoutChangeNeedsReview: true })} detail={null} />, { value }); + await userEvent.click(screen.getByRole('radio', { name: /TV shows/ })); + expect(screen.getByText(/\(preview, not applied\)/)).toBeInTheDocument(); + expect(screen.getByText('afterwards note')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Move to TV shows' })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Keep Videos' })); + expect(screen.queryByText('afterwards note')).not.toBeInTheDocument(); + expect(value.changeLayout).not.toHaveBeenCalled(); + }); + + test('the main folder to TV opens the confirm and warns about Jellyfin and Emby', async () => { + const value = makePageValue(); + renderInPage(<LayoutSection folder={folder('', { isDefault: true })} detail={null} />, { value }); + expect(screen.getByText(/Jellyfin and Emby can't skip them/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Use for TV shows' })); + expect(value.openMainFolderTv).toHaveBeenCalled(); + }); + + test('a title show blocks switching a TV folder to Videos', () => { + renderInPage(<LayoutSection folder={folder('TV', { layout: 'tv', titleShows: 1 })} detail={{ + name: 'TV', layout: 'tv', channels: [], followers: { count: 0, sample: [] }, playlists: [], example: null, + titleShows: [{ id: 1, name: 'Lessons', channelId: 'UC1', channelName: 'Prof', episodeCount: 2 }], + }} />); + expect(screen.getByRole('button', { name: 'Use for Videos' })).toBeDisabled(); + expect(screen.getByText(/Lessons is a title show/)).toBeInTheDocument(); + }); + + test('a running reorganize disables the change', () => { + renderInPage(<LayoutSection folder={folder('Kids')} detail={null} />, { value: makePageValue({ reorganizing: true }) }); + expect(screen.getByRole('button', { name: 'Use for TV shows' })).toBeDisabled(); + }); + + test('shows the result line for this folder', () => { + renderInPage(<LayoutSection folder={folder('Kids')} detail={null} />, { + value: makePageValue({ layoutResult: { folder: 'kids', tone: 'success', text: 'Now a TV shows folder.' } }), + }); + expect(screen.getByText('Now a TV shows folder.')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx b/client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx new file mode 100644 index 000000000..b5dd1e5b9 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LibraryGuide } from '../LibraryGuide'; +import { folder, inPage, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('LibraryGuide', () => { + beforeEach(() => window.localStorage.clear()); + + test('is open without TV folders and toggles', async () => { + renderInPage(<LibraryGuide />); + const toggle = screen.getByRole('button', { name: 'Hide the guide' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Channels pick a folder')).toBeInTheDocument(); + await userEvent.click(toggle); + expect(screen.getByRole('button', { name: 'Show the guide' })).toHaveAttribute('aria-expanded', 'false'); + }); + + test('is collapsed once a TV folder exists, with the one-line summary', () => { + renderInPage(<LibraryGuide />, { value: makePageValue({ folders: [folder(''), folder('TV', { layout: 'tv' })] }) }); + expect(screen.getByText('Channels pick a folder, its layout files them, a matching library shows it.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show the guide' })).toBeInTheDocument(); + }); + + test('stays folded while the folders load, then opens when there are no TV folders', () => { + const { rerender } = renderInPage(<LibraryGuide />, { value: makePageValue({ folders: [], foldersLoaded: false }) }); + expect(screen.getByRole('button', { name: 'Show the guide' })).toBeInTheDocument(); + rerender(inPage(<LibraryGuide />, makePageValue({ folders: [folder('')], foldersLoaded: true }))); + expect(screen.getByRole('button', { name: 'Hide the guide' })).toBeInTheDocument(); + }); + + test('stays open when the first TV folder is created', () => { + const { rerender } = renderInPage(<LibraryGuide />); + rerender(inPage(<LibraryGuide />, makePageValue({ folders: [folder(''), folder('TV', { layout: 'tv' })] }))); + expect(screen.getByRole('button', { name: 'Hide the guide' })).toBeInTheDocument(); + }); + + test('lists the connected servers\' library types', () => { + renderInPage(<LibraryGuide />, { value: makePageValue({ servers: [{ serverType: 'jellyfin', name: 'Jellyfin' }] }) }); + expect(screen.getAllByText('Jellyfin')).toHaveLength(2); + expect(screen.getByText('Shows')).toBeInTheDocument(); + }); + + test('Start using TV shows opens the panel', async () => { + const value = makePageValue(); + renderInPage(<LibraryGuide />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Start using TV shows' })); + expect(value.openStartTv).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/MakeDefaultDialog.test.tsx b/client/src/components/LibraryFolders/components/__tests__/MakeDefaultDialog.test.tsx new file mode 100644 index 000000000..413b6ed21 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/MakeDefaultDialog.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MakeDefaultDialog } from '../dialogs/MakeDefaultDialog'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import { useDefaultFolder } from '../../hooks/useDefaultFolder'; +import { ReorganizeRequiredError } from '../../../shared/Reorganize'; + +jest.mock('../../hooks/useDefaultFolder', () => ({ useDefaultFolder: jest.fn() })); + +const folders = [folder('Kids', { isDefault: true, channelsFollowing: 3 }), folder('TV', { layout: 'tv', makeDefaultNeedsReview: true })]; + +describe('MakeDefaultDialog', () => { + test('a cross-layout switch says it moves videos and hands the change to the review', async () => { + const change = { type: 'defaultSubfolder' as const, value: 'TV' }; + const setDefaultFolder = jest.fn().mockRejectedValue(new ReorganizeRequiredError('Review the move', change)); + (useDefaultFolder as jest.Mock).mockReturnValue({ saving: false, setDefaultFolder }); + const value = makePageValue({ folders }); + const onClose = jest.fn(); + renderInPage(<MakeDefaultDialog folder={folders[1]} onClose={onClose} />, { value }); + expect(screen.getByText(/channels that follow the default become TV shows/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Review the move' })); + expect(onClose).toHaveBeenCalled(); + expect(value.reviewChange).toHaveBeenCalledWith(change, { kind: 'default', folder: 'TV' }); + }); + + test('a same-layout switch saves and says existing videos stay', async () => { + const setDefaultFolder = jest.fn().mockResolvedValue(undefined); + (useDefaultFolder as jest.Mock).mockReturnValue({ saving: false, setDefaultFolder }); + const onClose = jest.fn(); + renderInPage(<MakeDefaultDialog folder={folder('Music')} onClose={onClose} />, { value: makePageValue({ folders }) }); + expect(screen.getByText('Videos they already downloaded stay where they are. Keep a library on __Kids to watch them.')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Make default' })); + expect(setDefaultFolder).toHaveBeenCalledWith('Music'); + expect(onClose).toHaveBeenCalled(); + }); + + test('a refusal stays in the dialog', async () => { + (useDefaultFolder as jest.Mock).mockReturnValue({ saving: false, setDefaultFolder: jest.fn().mockRejectedValue(new Error('Wait for the current download')) }); + renderInPage(<MakeDefaultDialog folder={folder('Music')} onClose={jest.fn()} />, { value: makePageValue({ folders }) }); + await userEvent.click(screen.getByRole('button', { name: 'Make default' })); + expect(await screen.findByText('Wait for the current download')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/MediaServersSection.test.tsx b/client/src/components/LibraryFolders/components/__tests__/MediaServersSection.test.tsx new file mode 100644 index 000000000..2675097e8 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/MediaServersSection.test.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { MediaServersSection } from '../MediaServersSection'; +import { emptyCheck, folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import type { LibraryFolderDetail } from '../../../../types/tvShows'; + +const mainDetail = (extra: Partial<LibraryFolderDetail> = {}): LibraryFolderDetail => ({ + name: '', layout: 'videos', channels: [{ channelId: 'UC1', name: 'Blippi', videoCount: 3 }], + followers: { count: 0, sample: [] }, playlists: [], titleShows: [], example: null, ...extra, +}); + +jest.mock('../ServerCard', () => ({ ServerCard: ({ status }: { status: { name: string } }) => { + const React = require('react'); + return React.createElement('div', null, `card ${status.name}`); +} })); +jest.mock('../PlexRefreshControl', () => ({ PlexRefreshControl: () => { + const React = require('react'); + return React.createElement('div', null, 'plex control'); +} })); + +describe('MediaServersSection', () => { + test('without servers: the intro, settings links and a setup box per server type', () => { + renderInPage(<MediaServersSection folder={folder('Kids', { channels: 1 })} />); + expect(screen.getByText(/No media server is connected, so Youtarr can't check your libraries/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Jellyfin settings' })).toHaveAttribute('href', '/settings/jellyfin'); + expect(screen.getAllByText(/^Create it in /)).toHaveLength(4); + }); + + test('shows no no-server intro, links or setup boxes until the servers are known', () => { + renderInPage(<MediaServersSection folder={folder('Kids', { channels: 1 })} />, { value: makePageValue({ serversKnown: false }) }); + expect(screen.queryByText(/No media server is connected/)).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Plex settings' })).not.toBeInTheDocument(); + expect(screen.queryByText(/^Create it in /)).not.toBeInTheDocument(); + expect(screen.queryByText(/aren't connected/)).not.toBeInTheDocument(); + }); + + test('one card per server and the footer note', () => { + const check = { ...makePageValue().check, data: { ...emptyCheck, servers: [{ serverType: 'plex' as const, name: 'Plex', reachable: true, error: null, downloadsPath: null }] } }; + const value = makePageValue({ servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'], check }); + renderInPage(<MediaServersSection folder={folder('Kids', { channels: 1 })} />, { value }); + expect(screen.getByText('card Plex')).toBeInTheDocument(); + expect(screen.getByText("Jellyfin and Emby aren't connected. Kodi isn't checked: add __Kids as a Movies source.")).toBeInTheDocument(); + }); + + test('gives the settings links 44px targets on phones', () => { + renderInPage(<MediaServersSection folder={folder('Kids', { channels: 1 })} />, { value: makePageValue({ phone: true }) }); + expect(screen.getByRole('link', { name: 'Plex settings' })).toHaveClass('min-h-[44px]'); + }); + + test('lists channels on a phone without nesting a list in a paragraph', () => { + const errors = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const value = makePageValue({ + phone: true, servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'], + folders: [folder('', { isDefault: true, channels: 1 }), folder('TV', { layout: 'tv', channels: 1 })], + mainDetail: mainDetail(), + }); + renderInPage(<MediaServersSection folder={value.folders[0]} />, { value }); + expect(screen.getByRole('link', { name: 'Blippi' })).toBeInTheDocument(); + expect(errors).not.toHaveBeenCalled(); + errors.mockRestore(); + }); + + test('the main folder intro names the channels that follow the default folder after its links', () => { + const value = makePageValue({ + servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'], + folders: [folder('', { isDefault: true, channels: 3, channelsChosen: 1, channelsFollowing: 2 }), folder('TV', { layout: 'tv', channels: 1 })], + mainDetail: mainDetail({ followers: { count: 2, sample: ['Cocomelon', 'Peppa'] } }), + }); + renderInPage(<MediaServersSection folder={value.folders[0]} />, { value }); + expect(screen.getByRole('link', { name: 'Blippi' })).toBeInTheDocument(); + expect(screen.getByText('2 more follow the default folder (Cocomelon and Peppa): make another folder the default, ' + + 'or set their Library folder in Channel Settings.')).toBeInTheDocument(); + }); + + test('gives the intro\'s Start using TV shows button a 44px target on phones', () => { + const value = makePageValue({ + phone: true, servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'], + folders: [folder('', { isDefault: true, channels: 2 }), folder('Kids', { channels: 1 })], + }); + renderInPage(<MediaServersSection folder={value.folders[0]} />, { value }); + expect(screen.getByRole('button', { name: 'Start using TV shows' })).toHaveClass('inline-flex', 'min-h-[44px]', 'items-center'); + }); + + test('gives the intro\'s folder link a 44px target on phones', () => { + const plex = { serverType: 'plex' as const, name: 'Plex', reachable: true, error: null, downloadsPath: '/data' }; + const data = { servers: [plex], folders: [ + { name: '', layout: 'videos' as const, hasFiles: false, channels: 0, servers: [{ + serverType: 'plex' as const, status: 'ok' as const, issues: [], + libraries: [{ id: '1', name: 'YouTube', type: 'videos' as const, location: '/data', relation: 'exact' as const }], + }] }, + { name: 'Shows', layout: 'tv' as const, hasFiles: false, channels: 1, servers: [{ + serverType: 'plex' as const, status: 'warning' as const, issues: [{ code: 'overlap', message: 'Shown twice', libraryId: '1' }], + libraries: [{ id: '1', name: 'YouTube', type: 'videos' as const, location: '/data', relation: 'covers' as const }], + }] }, + ] }; + const value = makePageValue({ + phone: true, servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'], + check: { ...makePageValue().check, data }, + folders: [folder(''), folder('Shows', { layout: 'tv', isDefault: true, channels: 1 })], + }); + renderInPage(<MediaServersSection folder={value.folders[0]} />, { value }); + expect(screen.getByRole('button', { name: '__Shows' })).toHaveClass('inline-flex', 'min-h-[44px]', 'items-center'); + }); + + test('puts the Plex refresh line right after the intro, before the other servers', () => { + const servers = [{ serverType: 'plex' as const, name: 'Plex' }, { serverType: 'jellyfin' as const, name: 'Jellyfin' }]; + const check = { ...makePageValue().check, data: { ...emptyCheck, servers: servers.map((server) => ({ ...server, reachable: true, error: null, downloadsPath: null })) } }; + const value = makePageValue({ servers, configuredServers: ['plex', 'jellyfin'], check }); + const { container } = renderInPage(<MediaServersSection folder={folder('Kids', { channels: 1 })} />, { value }); + const text = container.textContent ?? ''; + expect(text.indexOf('plex control')).toBeGreaterThan(-1); + expect(text.indexOf('plex control')).toBeLessThan(text.indexOf('card Jellyfin')); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/NoTvFoldersRow.test.tsx b/client/src/components/LibraryFolders/components/__tests__/NoTvFoldersRow.test.tsx new file mode 100644 index 000000000..415aab520 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/NoTvFoldersRow.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NoTvFoldersRow } from '../NoTvFoldersRow'; +import { makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('NoTvFoldersRow', () => { + test('offers the panel and a TV folder', async () => { + const value = makePageValue(); + renderInPage(<NoTvFoldersRow />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Start using TV shows' })); + await userEvent.click(screen.getByRole('button', { name: 'Add TV folder' })); + expect(value.openStartTv).toHaveBeenCalled(); + expect(value.openAddFolder).toHaveBeenCalledWith('tv'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/OverlapFixBlock.test.tsx b/client/src/components/LibraryFolders/components/__tests__/OverlapFixBlock.test.tsx new file mode 100644 index 000000000..f7a27bbe1 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/OverlapFixBlock.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { OverlapFixBlock } from '../OverlapFixBlock'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import type { LibraryFolderDetail } from '../../../../types/tvShows'; + +const report = { serverType: 'jellyfin' as const, status: 'missing' as const, issues: [], + libraries: [{ id: '1', name: 'YouTube', type: 'videos' as const, location: 'Q:\\Y', relation: 'covers' as const }] }; +const issue = { code: 'nestedLibrary', message: 'YouTube (at Q:\\Y) includes __TV...', libraryId: '1' }; +const mainDetail = (extra: Partial<LibraryFolderDetail> = {}): LibraryFolderDetail => ({ + name: '', layout: 'videos', channels: [{ channelId: 'UC1', name: 'Blippi', videoCount: 3 }], + followers: { count: 0, sample: [] }, playlists: [], titleShows: [], example: null, ...extra, +}); + +describe('OverlapFixBlock', () => { + test('lists the library edit with the Video folders as the server sees them', () => { + const value = makePageValue({ folders: [folder(''), folder('Kids', { channels: 2 }), folder('TV', { layout: 'tv', channels: 1 })] }); + renderInPage(<OverlapFixBlock issue={issue} server={{ serverType: 'jellyfin', name: 'Jellyfin' }} report={report} downloadsPath={'Q:\\Y'} />, { value }); + expect(screen.getByText('Fix it in Jellyfin')).toBeInTheDocument(); + expect(screen.getByText('Edit YouTube (Movies library) and remove its folder Q:\\Y.')).toBeInTheDocument(); + expect(screen.getByText('Q:\\Y\\__Kids')).toBeInTheDocument(); + }); + + test('names the channels still in the main folder from the page\'s main folder detail', () => { + const value = makePageValue({ + folders: [folder('', { channels: 1, channelsChosen: 1 }), folder('TV', { layout: 'tv', channels: 1 })], mainDetail: mainDetail(), + }); + renderInPage(<OverlapFixBlock issue={issue} server={{ serverType: 'jellyfin', name: 'Jellyfin' }} report={report} downloadsPath={null} />, { value }); + expect(screen.getByText(/1 channel still downloads straight into the main folder, which YouTube would stop showing/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Blippi' })).toHaveAttribute('href', '/channel/UC1'); + }); + + test('after the links, says which channels follow the default folder and how to move them', () => { + const value = makePageValue({ + folders: [folder('', { isDefault: true, channels: 4, channelsChosen: 1, channelsFollowing: 3 }), folder('TV', { layout: 'tv', channels: 1 })], + mainDetail: mainDetail({ followers: { count: 3, sample: ['Cocomelon', 'Numberblocks', 'Peppa'] } }), + }); + renderInPage(<OverlapFixBlock issue={issue} server={{ serverType: 'jellyfin', name: 'Jellyfin' }} report={report} downloadsPath={null} />, { value }); + expect(screen.getByText('3 more follow the default folder (Cocomelon, Numberblocks and Peppa): make another folder the default, ' + + 'or set their Library folder in Channel Settings.')).toBeInTheDocument(); + }); + + test('opens the panel', async () => { + const value = makePageValue(); + renderInPage(<OverlapFixBlock issue={issue} server={{ serverType: 'jellyfin', name: 'Jellyfin' }} report={report} downloadsPath={null} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'See all your options' })); + expect(value.openStartTv).toHaveBeenCalled(); + }); + + test('gives the options button a 44px target on phones', () => { + renderInPage(<OverlapFixBlock issue={issue} server={{ serverType: 'jellyfin', name: 'Jellyfin' }} report={report} downloadsPath={null} />, { value: makePageValue({ phone: true }) }); + expect(screen.getByRole('button', { name: 'See all your options' })).toHaveClass('min-h-[44px]'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/PageHeader.test.tsx b/client/src/components/LibraryFolders/components/__tests__/PageHeader.test.tsx new file mode 100644 index 000000000..1d4faadc2 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/PageHeader.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PageHeader } from '../PageHeader'; +import { makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('PageHeader', () => { + test('the heading, downloads path and saves-at-once note', () => { + renderInPage(<PageHeader />); + expect(screen.getByRole('heading', { level: 1, name: 'Library folders' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings'); + expect(screen.getByText('/usr/src/app/data')).toBeInTheDocument(); + expect(screen.getByText(/set by YOUTUBE_OUTPUT_DIR/)).toBeInTheDocument(); + expect(screen.getByText(/Changes on this page save right away\./)).toBeInTheDocument(); + }); + + test('no servers: no check button and a link to connect one', () => { + renderInPage(<PageHeader />); + expect(screen.queryByRole('button', { name: 'Check media servers' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Connect one' })).toHaveAttribute('href', '/settings/plex'); + }); + + test('says nothing about media servers until they are known', () => { + renderInPage(<PageHeader />, { value: makePageValue({ serversKnown: false }) }); + expect(screen.queryByText(/No media server connected/)).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Connect one' })).not.toBeInTheDocument(); + }); + + test('runs the check and opens Add folder with Videos', async () => { + const value = makePageValue({ servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'] }); + renderInPage(<PageHeader />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Check media servers' })); + await userEvent.click(screen.getByRole('button', { name: 'Add folder' })); + expect(value.check.refetch).toHaveBeenCalled(); + expect(value.openAddFolder).toHaveBeenCalledWith('videos'); + }); + + test('a DATA_PATH platform says so', () => { + renderInPage(<PageHeader />, { value: makePageValue({ isPlatformManaged: { ...makePageValue().isPlatformManaged, youtubeOutputDirectory: true } }) }); + expect(screen.getByText(/set by DATA_PATH/)).toBeInTheDocument(); + }); + + test('a running check disables the button and says Checking...', () => { + const value = makePageValue({ + servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'], + check: { ...makePageValue().check, loading: true }, + }); + renderInPage(<PageHeader />, { value }); + expect(screen.getByRole('button', { name: 'Checking...' })).toBeDisabled(); + }); + + test('phone: the downloads line and a full-width Add folder', () => { + renderInPage(<PageHeader />, { value: makePageValue({ phone: true }) }); + expect(screen.getByText(/downloads folder, set by YOUTUBE_OUTPUT_DIR/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add folder' })).toHaveClass('min-h-[44px]'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/PlexRefreshControl.test.tsx b/client/src/components/LibraryFolders/components/__tests__/PlexRefreshControl.test.tsx new file mode 100644 index 000000000..da7ec7461 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/PlexRefreshControl.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PlexRefreshControl } from '../PlexRefreshControl'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import { usePlexRefreshMapping } from '../../hooks/usePlexRefreshMapping'; + +jest.mock('../../hooks/usePlexRefreshMapping', () => ({ + PLEX_MAPPING_SAVE_ERROR: 'Could not save the Plex library mapping.', + usePlexRefreshMapping: jest.fn(), +})); + +const libraries = [{ id: '37', title: 'YouTube', type: 'movie' }, { id: '41', title: 'YouTube TV', type: 'show' }, { id: '40', title: 'Music', type: 'artist' }]; +const connected = () => makePageValue({ + plexLibraries: libraries, plexConnectionStatus: 'connected', + config: { ...makePageValue().config, plexYoutubeLibraryId: '37' }, +}); + +describe('PlexRefreshControl', () => { + let setMapping: jest.Mock; + let removeMapping: jest.Mock; + beforeEach(() => { + setMapping = jest.fn().mockResolvedValue(undefined); + removeMapping = jest.fn().mockResolvedValue(undefined); + (usePlexRefreshMapping as jest.Mock).mockReturnValue({ saving: false, setMapping, removeMapping }); + }); + + test('lists the default and movie/show libraries and saves a choice', async () => { + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', channels: 1 })} report={null} />, { value: connected() }); + await userEvent.click(screen.getByRole('combobox', { name: 'After downloads here, Plex refreshes' })); + expect(await screen.findByRole('option', { name: 'Default library (YouTube)' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Music' })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('option', { name: 'YouTube TV' })); + expect(setMapping).toHaveBeenCalledWith('TV', '41'); + }); + + test('an explicit default reads as the user\'s choice and can be removed', async () => { + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', plexMapping: { choice: 'default', libraryId: null } })} report={null} />, { value: connected() }); + expect(screen.getByText("You chose the default library for this folder, so Youtarr won't change it.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Remove setting' })); + expect(removeMapping).toHaveBeenCalledWith('TV'); + }); + + test('shows a read-only line when Plex is unreachable, still removable', async () => { + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', plexMapping: { choice: 'library', libraryId: '41' } })} report={null} />, + { value: makePageValue({ plexConnectionStatus: 'not_connected' }) }); + expect(screen.getByText(/After downloads here, Plex refreshes library 41/)).toBeInTheDocument(); + expect(screen.getByText("(Plex couldn't be reached)")).toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Remove setting' })); + expect(removeMapping).toHaveBeenCalledWith('TV'); + }); + + test.each(['testing', 'not_tested'] as const)('does not call Plex unreachable while its connection is %s', (status) => { + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', plexMapping: { choice: 'library', libraryId: '41' } })} report={null} />, + { value: makePageValue({ plexConnectionStatus: status }) }); + expect(screen.getByText(/After downloads here, Plex refreshes library 41/)).toBeInTheDocument(); + expect(screen.queryByText("(Plex couldn't be reached)")).not.toBeInTheDocument(); + }); + + test('reports a failed save', async () => { + setMapping.mockRejectedValue(new Error('Plex doesn\'t list library 41.')); + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', channels: 1 })} report={null} />, { value: connected() }); + await userEvent.click(screen.getByRole('combobox', { name: 'After downloads here, Plex refreshes' })); + await userEvent.click(await screen.findByRole('option', { name: 'YouTube TV' })); + expect(await screen.findByText("Plex doesn't list library 41.")).toBeInTheDocument(); + }); + + test('keeps a mapped library of another type in the options', async () => { + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', plexMapping: { choice: 'library', libraryId: '40' } })} report={null} />, { value: connected() }); + expect(screen.getByRole('combobox', { name: 'After downloads here, Plex refreshes' })).toHaveTextContent('Music'); + }); + + test('choosing the default library saves an explicit default', async () => { + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', plexMapping: { choice: 'library', libraryId: '41' } })} report={null} />, { value: connected() }); + await userEvent.click(screen.getByRole('combobox', { name: 'After downloads here, Plex refreshes' })); + await userEvent.click(await screen.findByRole('option', { name: 'Default library (YouTube)' })); + expect(setMapping).toHaveBeenCalledWith('TV', null); + }); + + test('keeps the chosen library showing after a successful save until the folder reloads', async () => { + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', channels: 1 })} report={null} />, { value: connected() }); + const select = screen.getByRole('combobox', { name: 'After downloads here, Plex refreshes' }); + await userEvent.click(select); + await userEvent.click(await screen.findByRole('option', { name: 'YouTube TV' })); + await waitFor(() => expect(setMapping).toHaveBeenCalled()); + expect(select).toHaveTextContent('YouTube TV'); + }); + + test('goes back to the saved library when the save fails', async () => { + setMapping.mockRejectedValue(new Error('nope')); + renderInPage(<PlexRefreshControl folder={folder('TV', { layout: 'tv', channels: 1 })} report={null} />, { value: connected() }); + const select = screen.getByRole('combobox', { name: 'After downloads here, Plex refreshes' }); + await userEvent.click(select); + await userEvent.click(await screen.findByRole('option', { name: 'YouTube TV' })); + expect(await screen.findByText('nope')).toBeInTheDocument(); + expect(select).toHaveTextContent('Default library (YouTube)'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/ServerCard.test.tsx b/client/src/components/LibraryFolders/components/__tests__/ServerCard.test.tsx new file mode 100644 index 000000000..75884279b --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/ServerCard.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ServerCard } from '../ServerCard'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import type { ServerStatus } from '../../../../utils/libraryAttention'; + +jest.mock('../PlexRefreshControl', () => ({ PlexRefreshControl: () => null })); +jest.mock('../OverlapFixBlock', () => ({ OverlapFixBlock: () => { + const React = require('react'); + return React.createElement('div', null, 'overlap fix'); +} })); + +const statusOf = (extra: Partial<ServerStatus>): ServerStatus => ({ + serverType: 'jellyfin', name: 'Jellyfin', display: 'issues', word: '1 issue', + report: { serverType: 'jellyfin', status: 'warning', libraries: [{ id: '9', name: 'YouTube', type: 'videos', location: '/yt', relation: 'exact' }], issues: [] }, + ...extra, +}); + +describe('ServerCard', () => { + test('marks a library-wide issue and gives its hint', () => { + const status = statusOf({ report: { ...statusOf({}).report!, issues: [{ code: 'nfoSaver', message: 'YouTube saves NFO files.', libraryId: '9' }] } }); + renderInPage(<ServerCard folder={folder('Kids', { channels: 1 })} status={status} downloadsPath="/yt" />); + expect(screen.getByText('Library setting')).toBeInTheDocument(); + expect(screen.getByText('In Jellyfin: Dashboard, Libraries, YouTube, Metadata savers.')).toBeInTheDocument(); + }); + + test('no library: the line and a setup box for the folder', () => { + const status = statusOf({ display: 'noLibrary', word: 'No library', report: { serverType: 'jellyfin', status: 'missing', libraries: [], issues: [{ code: 'noLibrary', message: 'x' }] } }); + renderInPage(<ServerCard folder={folder('TV', { layout: 'tv', channels: 1 })} status={status} downloadsPath="/yt" />); + expect(screen.getByText('No Jellyfin Shows library holds __TV.')).toBeInTheDocument(); + expect(screen.getByText('Create it in Jellyfin')).toBeInTheDocument(); + }); + + test('an overlap issue gets the fix block', () => { + const status = statusOf({ report: { ...statusOf({}).report!, issues: [{ code: 'nestedLibrary', message: 'nested', libraryId: '9' }] } }); + renderInPage(<ServerCard folder={folder('TV', { layout: 'tv', channels: 1 })} status={status} downloadsPath="/yt" />); + expect(screen.getByText('overlap fix')).toBeInTheDocument(); + }); + + test('Refresh this library applies the suggested Plex mapping', async () => { + const value = makePageValue(); + const status: ServerStatus = { serverType: 'plex', name: 'Plex', display: 'issues', word: '1 issue', report: { + serverType: 'plex', status: 'warning', libraries: [{ id: '41', name: 'YouTube TV', type: 'tv', location: 'Q:\\Y\\__TV', relation: 'exact' }], + issues: [{ code: 'plexMappingMissing', message: "New episodes in __TV don't refresh YouTube TV.", libraryId: '41' }], + } }; + renderInPage(<ServerCard folder={folder('TV', { layout: 'tv', channels: 1 })} status={status} downloadsPath={'Q:\\Y'} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Refresh this library' })); + expect(value.check.applyPlexMapping).toHaveBeenCalledWith('TV', '41'); + }); + + test('leaves the section id off a compact card', () => { + const status = statusOf({ report: { ...statusOf({}).report!, issues: [{ code: 'nfoSaver', message: 'x', libraryId: '9' }] } }); + const { unmount } = renderInPage(<ServerCard folder={folder('Kids', { channels: 1 })} status={status} downloadsPath="/yt" />); + expect(screen.getByRole('region', { name: 'Jellyfin' })).toHaveAttribute('id', 'server-card-jellyfin'); + unmount(); + renderInPage(<ServerCard folder={folder('Kids', { channels: 1 })} status={status} downloadsPath="/yt" compact />); + expect(screen.getByRole('region', { name: 'Jellyfin' })).not.toHaveAttribute('id'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/ServerIssue.test.tsx b/client/src/components/LibraryFolders/components/__tests__/ServerIssue.test.tsx new file mode 100644 index 000000000..85aa7b900 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/ServerIssue.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { ServerIssue } from '../ServerIssue'; +import { folder, renderInPage } from '../../__tests__/renderPage'; +import type { ServerStatus } from '../../../../utils/libraryAttention'; + +jest.mock('../OverlapFixBlock', () => ({ OverlapFixBlock: () => { + const React = require('react'); + return React.createElement('div', null, 'overlap fix'); +} })); + +const status: ServerStatus = { + serverType: 'jellyfin', name: 'Jellyfin', display: 'issues', word: '1 issue', + report: { serverType: 'jellyfin', status: 'warning', libraries: [{ id: '9', name: 'YouTube', type: 'videos', location: '/yt', relation: 'exact' }], issues: [] }, +}; + +describe('ServerIssue', () => { + test('shows the message and the hint of a library-wide issue', () => { + renderInPage(<ServerIssue folder={folder('Kids')} issue={{ code: 'nfoSaver', message: 'YouTube saves NFO files.', libraryId: '9' }} status={status} downloadsPath="/yt" />); + expect(screen.getByText(/YouTube saves NFO files\./)).toBeInTheDocument(); + expect(screen.getByText('In Jellyfin: Dashboard, Libraries, YouTube, Metadata savers.')).toBeInTheDocument(); + }); + + test('an overlap issue gets the fix block', () => { + renderInPage(<ServerIssue folder={folder('TV', { layout: 'tv' })} issue={{ code: 'overlap', message: 'overlaps', libraryId: '9' }} status={status} downloadsPath="/yt" />); + expect(screen.getByText('overlap fix')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/ServerLine.test.tsx b/client/src/components/LibraryFolders/components/__tests__/ServerLine.test.tsx new file mode 100644 index 000000000..6d8e3e69b --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/ServerLine.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ServerLine } from '../ServerLine'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('ServerLine', () => { + test('an unreachable server offers Check again', async () => { + const value = makePageValue(); + renderInPage(<ServerLine folder={folder('Kids', { channels: 1 })} status={{ + serverType: 'plex', name: 'Plex', display: 'unchecked', word: 'Not checked', + report: { serverType: 'plex', status: 'unreachable', libraries: [], issues: [] }, + }} />, { value }); + expect(screen.getByText(": couldn't be reached, so this folder wasn't checked.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Check again' })); + expect(value.check.refetch).toHaveBeenCalled(); + }); + + test('an unused folder is fine without a library', () => { + renderInPage(<ServerLine folder={folder('Empty')} status={{ serverType: 'emby', name: 'Emby', display: 'fine', word: 'No library', report: null }} />); + expect(screen.getByText(': not in a library. Fine while nothing downloads here.')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/SetupBox.test.tsx b/client/src/components/LibraryFolders/components/__tests__/SetupBox.test.tsx new file mode 100644 index 000000000..6808b8736 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/SetupBox.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SetupBox } from '../SetupBox'; +import { makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('SetupBox', () => { + test('lists the rows, copies a real path and re-checks', async () => { + const value = makePageValue(); + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) } }); + renderInPage(<SetupBox server="plex" layout="tv" path={{ text: 'Q:\\Y\\__TV', copyable: true }} />, { value }); + + expect(screen.getByText('Create it in Plex')).toBeInTheDocument(); + expect(screen.getByText('Plex NFO Series or Personal Media')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Copy path' })); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Q:\\Y\\__TV'); + expect(value.notify).toHaveBeenCalledWith('Copied'); + await userEvent.click(screen.getByRole('button', { name: 'I added it, check again' })); + expect(value.check.refetch).toHaveBeenCalled(); + }); + + test('describes an unknown path without a copy button', () => { + renderInPage(<SetupBox server="emby" layout="videos" path={{ text: '__Kids in your downloads folder, as Emby sees it', copyable: false }} />); + expect(screen.queryByRole('button', { name: 'Copy path' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx b/client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx new file mode 100644 index 000000000..71831c885 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StartTvShowsDialog } from '../dialogs/StartTvShowsDialog'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import type { LibraryCheckResponse } from '../../../../types/libraryCheck'; + +const wholeFolderCheck: LibraryCheckResponse = { + servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null, downloadsPath: '/data' }], + folders: [{ name: '', layout: 'videos', hasFiles: true, channels: 4, servers: [{ + serverType: 'plex', status: 'ok', issues: [], libraries: [{ id: '1', name: 'YouTube', type: 'videos', location: '/data', relation: 'exact' }], + }] }], +}; + +describe('StartTvShowsDialog', () => { + test('path A lists the main folder\'s channels, then the ones that follow the default folder', () => { + renderInPage(<StartTvShowsDialog open onClose={jest.fn()} />, { value: makePageValue({ + folders: [folder('', { isDefault: true, channels: 4, channelsChosen: 1, channelsFollowing: 3 })], + servers: [{ serverType: 'plex', name: 'Plex' }], configuredServers: ['plex'], + check: { ...makePageValue().check, data: wholeFolderCheck }, + mainDetail: { + name: '', layout: 'videos', channels: [{ channelId: 'UC1', name: 'Blippi', videoCount: 3 }], + followers: { count: 5, sample: ['Cocomelon', 'Numberblocks', 'Peppa'] }, playlists: [], titleShows: [], example: null, + }, + }) }); + expect(screen.getByRole('link', { name: 'Blippi' })).toHaveAttribute('href', '/channel/UC1'); + expect(screen.getByText('5 more follow the default folder (Cocomelon, Numberblocks, Peppa and 2 more): make another folder the default, ' + + 'or set their Library folder in Channel Settings.')).toBeInTheDocument(); + }); + + test('everything in the main folder: A preselected, B available, the steps and the guide link', () => { + renderInPage(<StartTvShowsDialog open onClose={jest.fn()} />, { + value: makePageValue({ folders: [folder('', { isDefault: true, channels: 3, fileCount: 10 })] }), + }); + expect(screen.getByText('Everything downloads into the main folder.')).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /Add a TV show folder next to your Video folders/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /Make the whole downloads folder TV shows/ })).toBeEnabled(); + expect(screen.getByRole('link', { name: 'Read the full guide' })).toHaveAttribute('href', 'https://dialmasterorg.github.io/Youtarr/docs/usage-guide/#move-an-existing-setup-to-tv-shows'); + }); + + test('a path action closes the panel and opens the flow', async () => { + const value = makePageValue({ folders: [folder('', { isDefault: true })] }); + const onClose = jest.fn(); + renderInPage(<StartTvShowsDialog open onClose={onClose} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Add TV folder' })); + expect(onClose).toHaveBeenCalled(); + expect(value.openAddFolder).toHaveBeenCalledWith('tv'); + }); + + test('path B selects the main folder and opens its confirm', async () => { + const value = makePageValue({ folders: [folder('', { isDefault: true })] }); + renderInPage(<StartTvShowsDialog open onClose={jest.fn()} />, { value }); + await userEvent.click(screen.getByRole('radio', { name: /Make the whole downloads folder TV shows/ })); + await userEvent.click(screen.getByRole('button', { name: 'Use the main folder for TV shows' })); + expect(value.selectFolder).toHaveBeenCalledWith(''); + expect(value.openMainFolderTv).toHaveBeenCalled(); + }); + + test('path C opens the chosen Video folder', async () => { + const value = makePageValue({ folders: [folder(''), folder('Kids', { channels: 2 }), folder('Docs', { fileCount: 3 })] }); + renderInPage(<StartTvShowsDialog open onClose={jest.fn()} />, { value }); + await userEvent.click(screen.getByRole('radio', { name: /Turn a Video folder into a TV show folder/ })); + await userEvent.click(screen.getByRole('combobox', { name: 'Folder' })); + await userEvent.click(await screen.findByRole('option', { name: '__Kids' })); + await userEvent.click(screen.getByRole('button', { name: 'Open __Kids' })); + expect(value.selectFolder).toHaveBeenCalledWith('Kids'); + }); + + test('B is unavailable while Video folders are in use on Jellyfin', () => { + renderInPage(<StartTvShowsDialog open onClose={jest.fn()} />, { value: makePageValue({ + folders: [folder(''), folder('Kids', { channels: 2 })], servers: [{ serverType: 'jellyfin', name: 'Jellyfin' }], + }) }); + expect(screen.getByRole('radio', { name: /Make the whole downloads folder TV shows/ })).toBeDisabled(); + expect(screen.getByText(/You use Video folders \(__Kids\)/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/StatusCell.test.tsx b/client/src/components/LibraryFolders/components/__tests__/StatusCell.test.tsx new file mode 100644 index 000000000..512ae0cf8 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/StatusCell.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { StatusCell, StatusLine } from '../StatusCell'; +import type { ServerDisplay, ServerStatus } from '../../../../utils/libraryAttention'; + +const status = (display: ServerDisplay, word: string, extra: Partial<ServerStatus> = {}): ServerStatus => ({ + serverType: 'plex', name: 'Plex', display, word, report: null, ...extra, +}); + +describe('StatusCell', () => { + test.each<[ServerDisplay, string, string]>([ + ['issues', '2 issues', 'Plex: 2 issues.'], + ['noLibrary', 'No library', 'Plex: No library.'], + ['unchecked', 'Not checked', 'Plex: Not checked.'], + ['checking', 'Checking...', 'Plex: Checking...'], + ])('shows the word and a full description for %s', (display, word, description) => { + render(<StatusCell status={status(display, word)} />); + expect(screen.getByText(word)).toBeInTheDocument(); + expect(screen.getByText(description)).toBeInTheDocument(); + }); +}); + +describe('StatusLine', () => { + test('lists each server with OK for a healthy one and the word otherwise', () => { + render(<StatusLine statuses={[status('ok', 'YouTube'), status('noLibrary', 'No library', { serverType: 'jellyfin', name: 'Jellyfin' })]} />); + expect(screen.getByText('OK')).toBeInTheDocument(); + expect(screen.getByText('No library')).toBeInTheDocument(); + expect(screen.getByText('Jellyfin')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/UnusedFolderRow.test.tsx b/client/src/components/LibraryFolders/components/__tests__/UnusedFolderRow.test.tsx new file mode 100644 index 000000000..9da232f5a --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/UnusedFolderRow.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { UnusedFolderRow } from '../UnusedFolderRow'; +import { folder, makePageValue, renderInPage } from '../../__tests__/renderPage'; + +describe('UnusedFolderRow', () => { + test('deletes a deletable folder', async () => { + const value = makePageValue(); + const empty = folder('Empty'); + renderInPage(<UnusedFolderRow folder={empty} selected={false} onSelect={jest.fn()} />, { value }); + await userEvent.click(screen.getByRole('button', { name: 'Delete __Empty' })); + expect(value.openDelete).toHaveBeenCalledWith(empty); + }); + + test('explains why a folder can\'t be deleted', () => { + renderInPage(<UnusedFolderRow + folder={folder('Old', { deletable: false, deleteBlockers: [{ code: 'disabledChannels', count: 1 }] })} + selected={false} + onSelect={jest.fn()} + />); + const button = screen.getByRole('button', { name: 'Delete __Old' }); + expect(button).toBeDisabled(); + expect(button).toHaveAccessibleDescription('1 unsubscribed channel still points here'); + }); + + test('sets aria-controls only when the inspector is a second column', () => { + renderInPage(<UnusedFolderRow folder={folder('A')} selected={false} onSelect={jest.fn()} />, { value: makePageValue({ twoColumn: false }) }); + expect(screen.getByRole('button', { name: /^__A/ })).not.toHaveAttribute('aria-controls'); + }); + + test('a selected row takes the link color on its icon', () => { + renderInPage(<UnusedFolderRow folder={folder('A')} selected onSelect={jest.fn()} />); + expect(within(screen.getByRole('button', { name: /^__A/ })).getByTestId('unused-folder-icon')).toHaveClass('text-primary'); + }); + + test('on phones the delete button is icon-only with an 18px icon and a 44px target', () => { + renderInPage(<UnusedFolderRow folder={folder('A')} selected={false} onSelect={jest.fn()} />, { value: makePageValue({ phone: true }) }); + const button = screen.getByRole('button', { name: 'Delete __A' }); + expect(button).toHaveClass('h-11', 'w-11'); + expect(within(button).getByTestId('delete-icon')).toHaveAttribute('width', '18'); + expect(within(button).getByTestId('delete-icon')).toHaveClass('!h-[18px]'); + }); +}); diff --git a/client/src/components/LibraryFolders/components/__tests__/serverCardRules.test.ts b/client/src/components/LibraryFolders/components/__tests__/serverCardRules.test.ts new file mode 100644 index 000000000..52a397b90 --- /dev/null +++ b/client/src/components/LibraryFolders/components/__tests__/serverCardRules.test.ts @@ -0,0 +1,30 @@ +import { folder } from '../../__tests__/renderPage'; +import { rendersAsLine, showsPlexControl } from '../serverCardRules'; +import type { ServerStatus } from '../../../../utils/libraryAttention'; + +const status = (display: ServerStatus['display'], report: ServerStatus['report']): ServerStatus => ({ + serverType: 'plex', name: 'Plex', display, word: '', report, +}); +const report = { serverType: 'plex' as const, status: 'ok' as const, libraries: [], issues: [] }; + +describe('serverCardRules', () => { + test('a server without a report renders as a line', () => { + expect(rendersAsLine(status('unchecked', null))).toBe(true); + }); + + test('a server with issues renders as a card', () => { + expect(rendersAsLine(status('issues', report))).toBe(false); + }); + + test('the Plex control shows for a folder that needs a library', () => { + expect(showsPlexControl(folder('Kids', { channels: 1 }))).toBe(true); + }); + + test('the Plex control shows for an unused folder that has a mapping', () => { + expect(showsPlexControl(folder('Empty', { plexMapping: { choice: 'default', libraryId: null } }))).toBe(true); + }); + + test('the Plex control is hidden for an unused folder without a mapping', () => { + expect(showsPlexControl(folder('Empty'))).toBe(false); + }); +}); diff --git a/client/src/components/LibraryFolders/components/dialogs/AddFolderDialog.tsx b/client/src/components/LibraryFolders/components/dialogs/AddFolderDialog.tsx new file mode 100644 index 000000000..da13a8c41 --- /dev/null +++ b/client/src/components/LibraryFolders/components/dialogs/AddFolderDialog.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useId, useState } from 'react'; +import { Check, RefreshCw } from '../../../../lib/icons'; +import { Button, CircularProgress, Dialog, DialogActions, DialogTitle } from '../../../ui'; +import { cn } from '../../../../lib/cn'; +import type { CreateLibraryFolderResult, LibraryLayout } from '../../../../types/tvShows'; +import type { ReorganizeChange } from '../../../../types/reorganize'; +import { useLibraryCheck } from '../../../../hooks/useLibraryCheck'; +import { isReorganizeRequired } from '../../../shared/Reorganize'; +import { checkStatus } from '../../../../utils/libraryAttention'; +import { useNow } from '../../../Configuration/hooks/useNow'; +import { useLibraryPage } from '../../LibraryFoldersContext'; +import { useCreateLibraryFolder } from '../../hooks/useCreateLibraryFolder'; +import { checkStatusText } from '../../mediaServerText'; +import { AddFolderNameStep, folderNameError } from './AddFolderNameStep'; +import { AddFolderServersStep } from './AddFolderServersStep'; + +export interface AddFolderDialogProps { + open: boolean; + initialLayout: LibraryLayout; + onClose: () => void; + /** The folder exists: select it */ + onCreated: (name: string) => void; + /** A directory with files asked for TV: close and open Review the move */ + onNeedsReview: (change: ReorganizeChange, name: string) => void; +} + +/** Add library folder: name and layout, then media servers (UI 7.1). */ +export function AddFolderDialog({ open, initialLayout, onClose, onCreated, onNeedsReview }: AddFolderDialogProps) { + const page = useLibraryPage(); + const titleId = useId(); + const now = useNow(); + const { creating, createFolder } = useCreateLibraryFolder(page.token); + const [name, setName] = useState(''); + const [layout, setLayout] = useState<LibraryLayout>(initialLayout); + const [serverError, setServerError] = useState<string | null>(null); + const [created, setCreated] = useState<CreateLibraryFolderResult | null>(null); + const check = useLibraryCheck(page.token, { folders: created ? [created.name] : [], enabled: Boolean(created) && page.servers.length > 0 }); + + useEffect(() => { + if (!open) return; + setName(''); + setLayout(initialLayout); + setServerError(null); + setCreated(null); + }, [open, initialLayout]); + + const ruleError = folderNameError(name, page.folders.map((folder) => folder.name)); + const nameError = serverError ?? ruleError; + const valid = name.trim() !== '' && ruleError === null; + const close = () => { if (!creating) onClose(); }; + + const submit = async () => { + if (!valid) return; + setServerError(null); + try { + const result = await createFolder(name.trim(), layout); + onCreated(result.name); + setCreated(result); + } catch (err: unknown) { + if (isReorganizeRequired(err)) { + onNeedsReview(err.change, name.trim()); + return; + } + setServerError(err instanceof Error && err.message ? err.message : 'Failed to create the folder'); + } + }; + + const status = checkStatus(check, page.configuredServers); + const stepClass = (active: boolean) => cn('flex items-center gap-1.5 text-[13px]', active && 'font-semibold'); + const circle = (step: number, done: boolean, active: boolean) => ( + <span className={cn('inline-flex h-5 w-5 items-center justify-center rounded-full border text-xs', + done ? 'border-primary bg-primary text-primary-foreground' : active ? 'border-primary' : 'border-border')}> + {done ? <Check size={12} aria-hidden="true" /> : step} + </span> + ); + const full = page.phone ? 'min-h-[44px] w-full' : undefined; + + return ( + <Dialog open={open} onClose={close} maxWidth="md" fullWidth fullScreen={page.phone} aria-labelledby={titleId}> + <DialogTitle id={titleId} onClose={close}>Add library folder</DialogTitle> + <ol className="flex items-center gap-3 border-b border-border px-5 py-2.5"> + <li aria-current={created ? undefined : 'step'} className={stepClass(!created)}>{circle(1, Boolean(created), !created)}Name and layout</li> + <li aria-hidden="true" className="h-px flex-1 bg-border" /> + <li aria-current={created ? 'step' : undefined} className={stepClass(Boolean(created))}>{circle(2, false, Boolean(created))}Media servers</li> + </ol> + {created ? ( + <AddFolderServersStep name={created.name} layout={created.layout} existingContent={Boolean(created.existingContent)} check={check} onClose={onClose} /> + ) : ( + <AddFolderNameStep name={name} layout={layout} error={nameError} + onNameChange={(next) => { setName(next); setServerError(null); }} onLayoutChange={setLayout} /> + )} + <DialogActions className={cn('border-t border-border px-5 py-3', page.phone && 'flex-col gap-2')}> + {created ? ( + <> + {page.servers.length > 0 && ( + <> + <Button variant="outlined" disabled={check.loading} onClick={() => { void check.refetch(); }} className={full} + startIcon={check.loading ? <CircularProgress size={14} /> : <RefreshCw size={14} />}> + {check.loading ? 'Checking...' : 'Check now'} + </Button> + <span className={cn('text-xs text-muted-foreground', page.phone && 'text-center')}> + {checkStatusText(status, page.servers, now, page.timeZone)} + </span> + </> + )} + {!page.phone && <span className="flex-1" />} + {page.phone ? ( + <> + <Button variant="contained" onClick={onClose} className={full}>Done</Button> + <Button variant="text" onClick={onClose} className={full}>Finish later</Button> + </> + ) : ( + <> + <Button variant="text" onClick={onClose}>Finish later</Button> + <Button variant="contained" onClick={onClose}>Done</Button> + </> + )} + </> + ) : ( + <div className={cn('flex gap-2', page.phone && 'w-full flex-col-reverse')}> + <Button variant="text" onClick={close} disabled={creating} className={full}>Cancel</Button> + <Button variant="contained" disabled={!valid || creating} onClick={() => { void submit(); }} className={full} + startIcon={creating ? <CircularProgress size={14} /> : undefined}> + Create __{name.trim() || 'Name'} + </Button> + </div> + )} + </DialogActions> + </Dialog> + ); +} diff --git a/client/src/components/LibraryFolders/components/dialogs/AddFolderNameStep.tsx b/client/src/components/LibraryFolders/components/dialogs/AddFolderNameStep.tsx new file mode 100644 index 000000000..0924a14b8 --- /dev/null +++ b/client/src/components/LibraryFolders/components/dialogs/AddFolderNameStep.tsx @@ -0,0 +1,112 @@ +import React, { useId } from 'react'; +import { Film, Tv } from '../../../../lib/icons'; +import { cn } from '../../../../lib/cn'; +import type { LibraryLayout } from '../../../../types/tvShows'; +import { folderKey } from '../../../../utils/libraryLayouts'; +import { useLibraryPage } from '../../LibraryFoldersContext'; +import { joinServerPath, serverLibraryType } from '../../libraryTypes'; + +const MAX_NAME_LENGTH = 100; +const VALID_NAME = /^[a-zA-Z0-9 _-]+$/; +const IDEAS = ['Comedy', 'News', 'Podcasts', 'Science']; +const GENERIC_LIBRARY = { videos: 'Plex Other Videos, Jellyfin or Emby Movies', tv: 'Plex TV Shows, Jellyfin Shows, Emby TV shows' }; + +/** The server's rules (subfolderValidation) in the dialog's words (UI 7.1). */ +export function folderNameError(raw: string, existingNames: string[]): string | null { + const name = raw.trim(); + if (!name) return null; + if (name.startsWith('_')) return 'Leave out the underscores: Youtarr adds __ for you.'; + if (!VALID_NAME.test(name)) return 'Use letters, numbers, spaces, hyphens and underscores only.'; + if (name.length > MAX_NAME_LENGTH) return 'Use 100 characters or fewer.'; + if (name.toLowerCase() === 'playlists') return '"playlists" is reserved for Youtarr\'s playlist files.'; + if (existingNames.some((existing) => folderKey(existing) === folderKey(name))) return `There is already a folder named __${name}.`; + return null; +} + +const CARDS = { + videos: { + title: 'Videos', Icon: Film, description: "Youtarr's existing movie-style layout: each video a movie in its channel's folder.", + tree: (name: string) => [`__${name}/`, '\u2514\u2500 Channel Name/', ' \u2514\u2500 Channel - Title - id/', ' \u2514\u2500 Channel - Title [id].mp4'], + }, + tv: { + title: 'TV shows', Icon: Tv, description: 'Each channel a show, each upload year a season. For series you watch in order.', + tree: (name: string) => [`__${name}/`, '\u2514\u2500 Channel Name/', ' \u2514\u2500 Season 2026/', ' \u2514\u2500 S2026E09281530 - Title [id].mp4'], + }, +} as const; + +export interface AddFolderNameStepProps { + name: string; + layout: LibraryLayout; + error: string | null; + onNameChange: (name: string) => void; + onLayoutChange: (layout: LibraryLayout) => void; +} + +export function AddFolderNameStep({ name, layout, error, onNameChange, onLayoutChange }: AddFolderNameStepProps) { + const page = useLibraryPage(); + const inputId = useId(); + const helpId = useId(); + const groupName = useId(); + const shown = name.trim() || 'Name'; + const base = page.config.youtubeOutputDirectory || ''; + const ideas = IDEAS.filter((idea) => !page.folders.some((folder) => folderKey(folder.name) === folderKey(idea))); + const libraryLine = (target: LibraryLayout) => (page.servers.length > 0 + ? page.servers.map((server) => serverLibraryType(server.serverType, target)).join(', ') + : GENERIC_LIBRARY[target]); + + return ( + <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-[18px]"> + <div> + <label htmlFor={inputId} className="text-[13px] font-semibold">Folder name</label> + <div className={cn('mt-1 flex items-center rounded-ui border', error ? 'border-destructive' : 'border-border', page.phone ? 'h-11' : 'h-[38px]')}> + <span aria-hidden="true" className="pl-2.5 font-mono text-muted-foreground">__</span> + <input id={inputId} autoFocus value={name} onChange={(event) => onNameChange(event.target.value)} + placeholder={layout === 'tv' ? 'Science Shows' : 'Cooking'} aria-invalid={error ? 'true' : undefined} aria-describedby={helpId} + className={cn('h-full min-w-0 flex-1 bg-transparent px-1 outline-none', page.phone && 'text-base')} /> + </div> + <p id={helpId} className={cn('mt-1 text-xs', error ? 'text-destructive' : 'text-muted-foreground')}> + {error ?? `Youtarr creates ${base ? joinServerPath(base, shown) : `__${shown}`}. Name it after the library you'll make from it, or what it will hold.`} + </p> + {ideas.length > 0 && ( + <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> + Ideas: + {ideas.map((idea) => ( + <button key={idea} type="button" onClick={() => onNameChange(idea)} + className={cn('rounded-ui border border-border px-2 text-foreground', page.phone ? 'min-h-[44px]' : 'h-[26px]')}> + {idea} + </button> + ))} + </p> + )} + </div> + <fieldset> + <legend className="text-[13px] font-semibold">Layout</legend> + <div className={cn('mt-1.5 grid gap-3', page.phone ? 'grid-cols-1' : 'grid-cols-2')}> + {(['videos', 'tv'] as const).map((value) => { + const card = CARDS[value]; + const selected = layout === value; + return ( + <label key={value} className={cn('flex cursor-pointer flex-col gap-1.5 rounded-ui border p-3', selected ? 'border-primary bg-primary/5' : 'border-border')}> + <span className="flex items-center gap-2"> + <input type="radio" name={groupName} value={value} checked={selected} onChange={() => onLayoutChange(value)} className="h-4 w-4 accent-[hsl(var(--primary-raw))]" /> + <card.Icon size={16} aria-hidden="true" /> + <span className="font-display text-[15px] font-semibold">{card.title}</span> + </span> + <span className="text-[12.5px] text-muted-foreground">{card.description}</span> + {!page.phone && ( + <span aria-hidden="true" className="whitespace-pre font-mono text-[10.5px] leading-4 text-muted-foreground"> + {card.tree(shown).join('\n')} + </span> + )} + <span className="border-t border-border pt-1.5 text-xs text-muted-foreground">Library: {libraryLine(value)}</span> + </label> + ); + })} + </div> + <p className="mt-2 text-[12.5px] text-muted-foreground"> + You can change it later. If the folder holds downloads by then, they move, and you review the move first. + </p> + </fieldset> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/dialogs/AddFolderServersStep.tsx b/client/src/components/LibraryFolders/components/dialogs/AddFolderServersStep.tsx new file mode 100644 index 000000000..d724f3db5 --- /dev/null +++ b/client/src/components/LibraryFolders/components/dialogs/AddFolderServersStep.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Info, Warning } from '../../../../lib/icons'; +import { CircularProgress } from '../../../ui'; +import { cn } from '../../../../lib/cn'; +import type { LibraryFolder, LibraryLayout } from '../../../../types/tvShows'; +import { useLibraryCheck } from '../../../../hooks/useLibraryCheck'; +import { + SERVER_NAMES, SERVER_ORDER, ServerRef, ServerStatus, isOverlapIssue, joinNames, plexMappingChoice, reportFor, serverStatuses, +} from '../../../../utils/libraryAttention'; +import { folderKey } from '../../../../utils/libraryLayouts'; +import { useLibraryPage } from '../../LibraryFoldersContext'; +import { folderLabel } from '../../folderText'; +import { folderServerPath } from '../../libraryTypes'; +import { ServerCard } from '../ServerCard'; +import { SetupBox } from '../SetupBox'; + +export interface AddFolderServersStepProps { + name: string; + layout: LibraryLayout; + existingContent: boolean; + /** The step's own check, so the footer can show its state */ + check: ReturnType<typeof useLibraryCheck>; + /** Close the dialog (Open Subscriptions navigates away) */ + onClose: () => void; +} + +/** Step 2: what each server needs for the new folder (UI 7.1). */ +export function AddFolderServersStep({ name, layout, existingContent, check, onClose }: AddFolderServersStepProps) { + const page = useLibraryPage(); + const label = folderLabel(name, true); + const [mappedTo, setMappedTo] = useState<string | null>(null); + const attempted = useRef<string | null>(null); + const folder: LibraryFolder = page.folders.find((entry) => folderKey(entry.name) === folderKey(name)) + ?? { name, layout, isDefault: false, hasFiles: existingContent, channels: 0 }; + const statuses = serverStatuses(folder, check, page.configuredServers); + const report = reportFor(check.data, name); + const plex = report?.servers.find((entry) => entry.serverType === 'plex'); + const suggestion = plex?.plexMapping?.suggestedLibraryId ?? null; + const overlap = layout === 'tv' + ? report?.servers.find((entry) => entry.issues.some((issue) => isOverlapIssue(issue.code))) : undefined; + const { applyPlexMapping } = check; + const sections: Array<{ server: ServerRef; status: ServerStatus | null }> = check.data + ? statuses.map((status) => ({ server: status, status })) + : page.servers.map((server) => ({ server, status: null })); + const headingRef = useRef<HTMLHeadingElement>(null); + const firstServerRef = useRef<HTMLDivElement>(null); + const focused = useRef(false); + + // Entering step 2 (UI 10): focus the first server section, or the heading when no server is connected. + useEffect(() => { + if (focused.current) return; + focused.current = true; + (page.servers.length > 0 ? firstServerRef.current : headingRef.current)?.focus(); + }, [page.servers.length]); + + useEffect(() => { + if (layout !== 'tv' || !suggestion || plexMappingChoice(plex?.plexMapping) !== 'none') return; + const key = `${name}:${suggestion}`; + if (attempted.current === key) return; + attempted.current = key; + const libraryName = plex?.libraries.find((library) => library.id === suggestion)?.name ?? null; + applyPlexMapping(name, suggestion).then(() => setMappedTo(libraryName)).catch(() => undefined); + }, [layout, suggestion, plex, name, applyPlexMapping]); + + const missing = SERVER_ORDER.filter((type) => !page.servers.some((server) => server.serverType === type)).map((type) => SERVER_NAMES[type]); + const unconnected = (() => { + const emby = missing.includes('Emby'); + const connectedNote = missing.length > 0 ? `${joinNames(missing)} ${missing.length > 1 ? "aren't" : "isn't"} connected and ` : ''; + const lead = `${connectedNote}Kodi isn't checked.`; + if (layout === 'tv') { + return `${lead}${emby ? ' Emby: a TV shows library, NFO reader on, NFO saver and downloaders off.' : ''} Kodi: a TV shows source set to Local information only.`; + } + return `${lead}${emby ? ' Emby: a Movies library.' : ''} Kodi: a Movies source.`; + })(); + + return ( + <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-5 py-[18px]"> + <h3 ref={headingRef} tabIndex={-1} className="font-display text-base font-semibold focus:outline-none">Set up your media servers</h3> + {existingContent && <p className="text-[13px]">__{name} already held files on disk.</p>} + {page.servers.length === 0 ? ( + <> + <p className="text-[13px] text-muted-foreground">No media server is connected, so Youtarr can't check. Create one library for {label} on the server you use:</p> + {(['plex', 'jellyfin', 'emby', 'kodi'] as const).map((server) => ( + <SetupBox key={server} server={server} layout={layout} showCheckAgain={false} path={folderServerPath(name, null, server === 'kodi' ? 'Kodi' : SERVER_NAMES[server])} /> + ))} + </> + ) : ( + <> + <p className="text-[13px] text-muted-foreground">Youtarr doesn't create libraries. Here is what each server needs for {label}:</p> + {overlap && ( + <p className="flex items-start gap-1.5 rounded-ui border border-warning p-2 text-[13px]"> + <Warning size={15} aria-hidden="true" className="mt-0.5 shrink-0 text-warning" /> + <span> + {SERVER_NAMES[overlap.serverType]} library {overlap.libraries.find((library) => library.relation === 'covers')?.name ?? ''} shows your whole + downloads folder, so a TV library for {label} {overlap.serverType === 'plex' ? 'would show its episodes a second time.' : 'would stay empty.'}{' '} + <button type="button" onClick={page.openStartTv} + className={cn('text-primary underline', page.phone && 'inline-flex min-h-[44px] items-center')}>See your options</button> + </span> + </p> + )} + {/* One section per server, kept across the check so focus stays on the first one. */} + {sections.map(({ server, status }, index) => ( + <div key={server.serverType} data-testid={`add-folder-server-${server.serverType}`} + ref={index === 0 ? firstServerRef : undefined} tabIndex={index === 0 ? -1 : undefined} className="focus:outline-none"> + {status ? ( + <ServerCard folder={{ ...folder, layout }} status={status} compact + downloadsPath={check.data?.servers.find((entry) => entry.serverType === server.serverType)?.downloadsPath ?? null} /> + ) : !check.error && ( + <p className="flex items-center gap-1.5 text-[13px]"><CircularProgress size={14} />Checking {server.name}...</p> + )} + </div> + ))} + {mappedTo && <p className="text-[13px] text-success">New episodes in {label} now refresh {mappedTo}.</p>} + </> + )} + {page.servers.length > 0 && ( + <p className="flex items-start gap-1.5 text-[12.5px] text-muted-foreground"><Info size={13} aria-hidden="true" className="mt-0.5 shrink-0" />{unconnected}</p> + )} + <div className="flex flex-wrap items-center gap-2 rounded-ui border border-info p-2.5 text-[13px]"> + <span className="min-w-0 flex-1"> + Next: choose channels for {label}. A channel downloads here once you pick {label} as its Library folder in Channel Settings. + </span> + <Link to="/subscriptions" onClick={onClose} + className={cn('rounded-ui border border-primary px-2 text-primary', page.phone ? 'inline-flex min-h-[44px] w-full items-center justify-center' : 'py-0.5')}> + Open Subscriptions + </Link> + </div> + </div> + ); +} diff --git a/client/src/components/LibraryFolders/components/dialogs/DeleteFolderDialog.tsx b/client/src/components/LibraryFolders/components/dialogs/DeleteFolderDialog.tsx new file mode 100644 index 000000000..afed041e9 --- /dev/null +++ b/client/src/components/LibraryFolders/components/dialogs/DeleteFolderDialog.tsx @@ -0,0 +1,60 @@ +import React, { useId, useState } from 'react'; +import { Trash2 } from '../../../../lib/icons'; +import { Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from '../../../ui'; +import type { LibraryFolder } from '../../../../types/tvShows'; +import { useSubfolders } from '../../../../hooks/useSubfolders'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../../hooks/useLibraryFolders'; +import { useLibraryPage } from '../../LibraryFoldersContext'; +import { folderLabel } from '../../folderText'; + +export interface DeleteFolderDialogProps { + folder: LibraryFolder; + onClose: () => void; + onDeleted: (folder: LibraryFolder) => void; +} + +/** Delete confirm (UI 7.4). Mounted only while open, so useSubfolders loads only then. */ +export function DeleteFolderDialog({ folder, onClose, onDeleted }: DeleteFolderDialogProps) { + const { token, phone } = useLibraryPage(); + const { deleteSubfolder } = useSubfolders(token); + const titleId = useId(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const label = folderLabel(folder.name); + const mapped = Boolean(folder.plexMapping && folder.plexMapping.choice !== 'none'); + + const confirm = async () => { + setBusy(true); + setError(null); + try { + await deleteSubfolder(folder.name); + window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); + onDeleted(folder); + } catch (err: unknown) { + setError(err instanceof Error && err.message ? err.message : 'Failed to delete the folder'); + } finally { + setBusy(false); + } + }; + + return ( + <Dialog open maxWidth="xs" fullWidth onClose={() => { if (!busy) onClose(); }} aria-labelledby={titleId}> + <DialogTitle id={titleId}>Delete {label}?</DialogTitle> + <DialogContent> + <p className="text-[13px]"> + It's empty and nothing downloads to it. Youtarr removes it from this list and deletes the empty folder from disk. + {mapped ? ' Its Plex refresh setting is removed too.' : ''} + </p> + {error && <p role="alert" className="mt-3 text-[13px] text-destructive">{error}</p>} + </DialogContent> + <DialogActions className={phone ? 'flex-col-reverse gap-2' : undefined}> + <Button variant="text" onClick={onClose} disabled={busy} className={phone ? 'min-h-[44px] w-full' : undefined}>Cancel</Button> + <Button variant="outlined" color="error" onClick={() => { void confirm(); }} disabled={busy || error !== null} + startIcon={busy ? <CircularProgress size={14} /> : <Trash2 size={14} aria-hidden="true" />} + className={phone ? 'min-h-[44px] w-full' : undefined}> + Delete folder + </Button> + </DialogActions> + </Dialog> + ); +} diff --git a/client/src/components/LibraryFolders/components/dialogs/MakeDefaultDialog.tsx b/client/src/components/LibraryFolders/components/dialogs/MakeDefaultDialog.tsx new file mode 100644 index 000000000..ae33a9cfb --- /dev/null +++ b/client/src/components/LibraryFolders/components/dialogs/MakeDefaultDialog.tsx @@ -0,0 +1,59 @@ +import React, { useId, useState } from 'react'; +import { Warning } from '../../../../lib/icons'; +import { Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from '../../../ui'; +import { cn } from '../../../../lib/cn'; +import type { LibraryFolder } from '../../../../types/tvShows'; +import { isReorganizeRequired } from '../../../shared/Reorganize'; +import { useLibraryPage } from '../../LibraryFoldersContext'; +import { useDefaultFolder } from '../../hooks/useDefaultFolder'; +import { makeDefaultText } from '../../folderText'; + +/** Make default confirm (UI 7.3). */ +export function MakeDefaultDialog({ folder, onClose }: { folder: LibraryFolder; onClose: () => void }) { + const page = useLibraryPage(); + const titleId = useId(); + const { saving, setDefaultFolder } = useDefaultFolder(page.token); + const [error, setError] = useState<string | null>(null); + const current = page.folders.find((entry) => entry.isDefault) ?? null; + const text = makeDefaultText({ folder, current }); + + const confirm = async () => { + setError(null); + try { + await setDefaultFolder(folder.name); + onClose(); + } catch (err: unknown) { + if (isReorganizeRequired(err)) { + onClose(); + page.reviewChange(err.change, { kind: 'default', folder: folder.name }); + return; + } + setError(err instanceof Error && err.message ? err.message : 'Failed to change the default folder'); + } + }; + const full = page.phone ? 'min-h-[44px] w-full' : undefined; + + return ( + <Dialog open maxWidth="sm" fullWidth onClose={() => { if (!saving) onClose(); }} aria-labelledby={titleId}> + <DialogTitle id={titleId}>{text.title}</DialogTitle> + <DialogContent className="flex flex-col gap-3 text-[13px]"> + <p>{text.body}</p> + {text.sameLayoutNote && <p className="text-muted-foreground">{text.sameLayoutNote}</p>} + {text.warning && ( + <p className="flex items-start gap-1.5 rounded-ui border border-warning p-2"> + <Warning size={15} aria-hidden="true" className="mt-0.5 shrink-0 text-warning" />{text.warning} + </p> + )} + {text.tvLine && <p>{text.tvLine}</p>} + {error && <p role="alert" className="text-destructive">{error}</p>} + </DialogContent> + <DialogActions className={cn(page.phone && 'flex-col-reverse gap-2')}> + <Button variant="text" onClick={onClose} disabled={saving} className={full}>Cancel</Button> + <Button variant="contained" onClick={() => { void confirm(); }} disabled={saving} className={full} + startIcon={saving ? <CircularProgress size={14} /> : undefined}> + {text.confirmLabel} + </Button> + </DialogActions> + </Dialog> + ); +} diff --git a/client/src/components/LibraryFolders/components/dialogs/StartTvShowsDialog.tsx b/client/src/components/LibraryFolders/components/dialogs/StartTvShowsDialog.tsx new file mode 100644 index 000000000..8907a367e --- /dev/null +++ b/client/src/components/LibraryFolders/components/dialogs/StartTvShowsDialog.tsx @@ -0,0 +1,138 @@ +import React, { useId, useMemo, useState } from 'react'; +import { Info } from '../../../../lib/icons'; +import { Button, Dialog, DialogActions, DialogTitle, MenuItem, Select } from '../../../ui'; +import { cn } from '../../../../lib/cn'; +import { useLibraryPage } from '../../LibraryFoldersContext'; +import { folderLabel, followersLine } from '../../folderText'; +import { + FULL_GUIDE_URL, PathKey, WATCH_STATE_FIRST, WATCH_STATE_REBUILD, YoutarrAction, detectSetup, pathOptions, pathSteps, setupLines, +} from '../../tvSetupPaths'; +import { ChannelLinks } from '../ChannelLinks'; +import { CopyButton } from '../CopyButton'; +import { SetupBox } from '../SetupBox'; + +/** Start using TV shows: the detected setup, the paths that apply and their steps (UI 7.6). */ +export function StartTvShowsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const page = useLibraryPage(); + const titleId = useId(); + const radioName = useId(); + const folderLabelId = useId(); + const setup = useMemo(() => detectSetup(page.folders, page.check.data, page.servers), [page.folders, page.check.data, page.servers]); + const options = pathOptions(setup, page.servers); + const firstAvailable = options.find((option) => !option.disabledReason)?.key ?? 'A'; + const [chosen, setChosen] = useState<PathKey>(firstAvailable); + // The page mounts the panel only while it is open, so these start fresh on every open. + const [cFolder, setCFolder] = useState(setup.videoSubfolders[0]?.name ?? ''); + const { mainDetail } = page; + const followers = mainDetail ? followersLine(mainDetail.followers, mainDetail.channels.length > 0) : null; + + const steps = pathSteps(chosen, setup, { folder: cFolder }); + const act = (action: YoutarrAction) => { + onClose(); + if (action.kind === 'addTvFolder') page.openAddFolder('tv'); + else if (action.kind === 'mainFolderTv') { page.selectFolder(''); page.openMainFolderTv(); } + else page.selectFolder(action.folder); + }; + const full = page.phone ? 'min-h-[44px] w-full' : undefined; + + return ( + <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth fullScreen={page.phone} aria-labelledby={titleId}> + <DialogTitle id={titleId} onClose={onClose}>Start using TV shows</DialogTitle> + <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4 text-[13px]"> + <p> + TV show folders save each channel as a show: Season folders by upload year, episodes numbered by upload time, an .nfo for every + episode. Your Video folders keep working as they are. + </p> + <div className="rounded-ui border border-border bg-background p-3"> + <h3 className="font-semibold">Your setup now</h3> + <ul className="mt-1 list-disc space-y-0.5 pl-5">{setupLines(setup).map((line) => <li key={line}>{line}</li>)}</ul> + </div> + <fieldset> + <legend className="font-semibold">Choose a path</legend> + <div className="mt-1.5 flex flex-col gap-2"> + {options.map((option) => ( + <div key={option.key} className={cn('flex flex-col gap-1 rounded-ui border p-3', + chosen === option.key ? 'border-primary bg-primary/5' : 'border-border', option.disabledReason && 'opacity-60')}> + <label className="flex flex-col gap-1"> + <span className="flex items-center gap-2"> + <input type="radio" name={radioName} value={option.key} checked={chosen === option.key} + disabled={Boolean(option.disabledReason)} onChange={() => setChosen(option.key)} /> + <span className="font-semibold">{option.title}</span> + {option.recommended && <span className="rounded-ui border border-primary px-1.5 text-[11px] text-primary">Recommended</span>} + </span> + <span className="text-muted-foreground">{option.disabledReason ?? option.description}</span> + {option.note && <span className="text-muted-foreground">{option.note}</span>} + </label> + {option.key === 'C' && chosen === 'C' && ( + <div className={cn('mt-1 flex gap-2', page.phone ? 'flex-col' : 'items-center')}> + <span id={folderLabelId}>Folder</span> + <Select value={cFolder} labelId={folderLabelId} triggerRole="combobox" size="small" fullWidth={page.phone} + onChange={(event) => setCFolder(String(event.target.value))} + className={page.phone ? 'min-h-[44px] text-[13px]' : 'h-[30px] min-h-0 text-[13px]'}> + {setup.videoSubfolders.map((entry) => <MenuItem key={entry.name} value={entry.name}>{folderLabel(entry.name)}</MenuItem>)} + </Select> + </div> + )} + </div> + ))} + </div> + </fieldset> + <div> + <h3 className="font-semibold">In Youtarr</h3> + <ol className="mt-1 list-decimal space-y-1.5 pl-5"> + {steps.youtarr.map((step) => ( + <li key={step.text}> + {step.text} + {step.channelLinks && mainDetail && <> <ChannelLinks channels={mainDetail.channels} /></>} + {step.channelLinks && followers && <span className="mt-0.5 block">{followers}</span>} + {step.action && ( + <div className="mt-1"> + <Button variant="outlined" size="sm" onClick={() => act(step.action as YoutarrAction)} className={full}>{step.actionLabel}</Button> + </div> + )} + </li> + ))} + </ol> + </div> + {steps.servers.map((group) => ( + <div key={group.serverType}> + <h3 className="font-semibold">On {group.name}</h3> + <ol className="mt-1 list-decimal space-y-1.5 pl-5"> + {group.steps.map((step) => ( + <li key={step.text}> + {step.text} + {step.paths && step.paths.length > 0 && ( + <ul className="mt-1 space-y-0.5"> + {step.paths.map((path) => ( + <li key={path.text} className={cn('flex items-center gap-1', path.copyable && 'font-mono text-xs')}> + <span className="[overflow-wrap:anywhere]">{path.text}</span>{path.copyable && <CopyButton text={path.text} />} + </li> + ))} + </ul> + )} + {step.after && <> {step.after}</>} + {step.setup && ( + <details className="mt-1"> + <summary className={cn('cursor-pointer text-primary', page.phone && 'min-h-[44px]')}>Library settings</summary> + <div className="mt-1.5"><SetupBox server={group.serverType} layout={step.setup.layout} path={step.setup.path} showCheckAgain={false} /></div> + </details> + )} + </li> + ))} + </ol> + </div> + ))} + <p className="flex items-start gap-1.5 text-[12.5px] text-muted-foreground"> + <Info size={13} aria-hidden="true" className="mt-0.5 shrink-0" />{WATCH_STATE_FIRST} {WATCH_STATE_REBUILD} + </p> + </div> + <DialogActions className={cn('border-t border-border px-5 py-3', page.phone && 'flex-col gap-2')}> + <a href={FULL_GUIDE_URL} target="_blank" rel="noopener noreferrer" className={cn('text-[13px] text-primary underline', page.phone && 'flex min-h-[44px] items-center')}> + Read the full guide + </a> + {!page.phone && <span className="flex-1" />} + <Button variant="text" onClick={onClose} className={full}>Close</Button> + </DialogActions> + </Dialog> + ); +} diff --git a/client/src/components/LibraryFolders/components/serverCardRules.ts b/client/src/components/LibraryFolders/components/serverCardRules.ts new file mode 100644 index 000000000..e9c90b580 --- /dev/null +++ b/client/src/components/LibraryFolders/components/serverCardRules.ts @@ -0,0 +1,12 @@ +import type { LibraryFolder } from '../../../types/tvShows'; +import { ServerStatus, folderState, needsLibrary } from '../../../utils/libraryAttention'; + +/** A server without a report, or one that is fine, unchecked or checking, shows as one line instead of a card. */ +export function rendersAsLine(status: ServerStatus): boolean { + return !status.report || status.display === 'fine' || status.display === 'unchecked' || status.display === 'checking'; +} + +/** The Plex refresh control belongs to folders that need a library, or that already have a mapping entry. */ +export function showsPlexControl(folder: LibraryFolder): boolean { + return needsLibrary(folderState(folder)) || (folder.plexMapping?.choice ?? 'none') !== 'none'; +} diff --git a/client/src/components/LibraryFolders/copyText.ts b/client/src/components/LibraryFolders/copyText.ts new file mode 100644 index 000000000..854d3469e --- /dev/null +++ b/client/src/components/LibraryFolders/copyText.ts @@ -0,0 +1,32 @@ +export const COPY_FAILED_MESSAGE = "Couldn't copy to the clipboard"; + +/** navigator.clipboard needs a secure context; plain-HTTP LAN access gets the textarea route. */ +function copyWithTextarea(text: string): boolean { + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + document.body.removeChild(area); + } +} + +/** Copy text to the clipboard; true when it worked. */ +export async function copyText(text: string): Promise<boolean> { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Refused (permissions, focus): try the textarea route. + } + return copyWithTextarea(text); +} diff --git a/client/src/components/LibraryFolders/folderText.ts b/client/src/components/LibraryFolders/folderText.ts new file mode 100644 index 000000000..61e0bb226 --- /dev/null +++ b/client/src/components/LibraryFolders/folderText.ts @@ -0,0 +1,215 @@ +import type { LibraryFolder, LibraryFolderDetail, LibraryLayout } from '../../types/tvShows'; +import { folderState } from '../../utils/libraryAttention'; + +/** Labels, plurals and the sentences built from a folder's usage (UI 5.6.1, 5.7.3, 5.7.5, 7.3). */ + +export const SEP = ' \u00b7 '; + +export function formatCount(count: number): string { + return count.toLocaleString('en-US'); +} + +export function countOf(count: number, one: string, many: string): string { + return `${formatCount(count)} ${count === 1 ? one : many}`; +} + +export function joinList(parts: string[]): string { + if (parts.length <= 1) return parts[0] ?? ''; + return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`; +} + +export function units(layout: LibraryLayout): { one: string; many: string } { + return layout === 'tv' ? { one: 'episode', many: 'episodes' } : { one: 'video', many: 'videos' }; +} + +export function layoutName(layout: LibraryLayout): string { + return layout === 'tv' ? 'TV shows' : 'Videos'; +} + +export function otherLayout(layout: LibraryLayout): LibraryLayout { + return layout === 'tv' ? 'videos' : 'tv'; +} + +/** "__Kids"; the main folder is "Main folder" alone or at a sentence start, "the main folder" mid-sentence. */ +export function folderLabel(name: string, midSentence = false): string { + if (name) return `__${name}`; + return midSentence ? 'the main folder' : 'Main folder'; +} + +export function filesSummary(folder: LibraryFolder): string | null { + const count = folder.fileCount ?? 0; + if (count > 0) { + const unit = units(folder.layout); + return `${countOf(count, unit.one, unit.many)} on disk`; + } + return folder.hasFiles ? "Holds files Youtarr doesn't track" : null; +} + +function channelsSummary(folder: LibraryFolder): string | null { + const chosen = folder.channelsChosen ?? 0; + const following = folder.channelsFollowing ?? 0; + if (folder.isDefault && following > 0) { + const follow = `${formatCount(following)} ${following === 1 ? 'follows' : 'follow'} the default`; + return chosen > 0 ? `${formatCount(chosen)} chose this folder, ${follow}` : follow; + } + return folder.channels > 0 ? countOf(folder.channels, 'channel', 'channels') : null; +} + +export function rowSummary(folder: LibraryFolder): string { + const state = folderState(folder); + if (state === 'emptyMain') return `Nothing saved here${SEP}Downloads go to subfolders`; + if (state === 'unused') return unusedSummary(folder); + const files = filesSummary(folder); + if (state === 'holdsVideos') return ['Nothing downloads here now', files].filter(Boolean).join(SEP); + const parts = [ + channelsSummary(folder), + (folder.playlists ?? 0) > 0 ? countOf(folder.playlists ?? 0, 'playlist', 'playlists') : null, + (folder.titleShows ?? 0) > 0 ? countOf(folder.titleShows ?? 0, 'title show', 'title shows') : null, + files, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(SEP) : 'Nothing downloaded yet'; +} + +export function unusedSummary(folder: LibraryFolder): string { + return [`Unused${SEP}Empty`, deleteReasons(folder)[0]].filter(Boolean).join(SEP); +} + +const blockerCount = (folder: LibraryFolder, code: string): number => ( + folder.deleteBlockers?.find((blocker) => blocker.code === code)?.count ?? 0 +); +const hasBlocker = (folder: LibraryFolder, code: string): boolean => Boolean(folder.deleteBlockers?.some((b) => b.code === code)); + +/** Every reason delete is refused, in guard order (UI 5.7.5). */ +export function deleteReasons(folder: LibraryFolder): string[] { + const reasons: string[] = []; + const channels = blockerCount(folder, 'channels'); + const playlists = blockerCount(folder, 'playlists'); + const titleShows = folder.titleShows ?? 0; + const users = [ + channels > 0 ? countOf(channels, 'channel', 'channels') : null, + playlists > 0 ? countOf(playlists, 'playlist', 'playlists') : null, + titleShows > 0 ? countOf(titleShows, 'title show', 'title shows') : null, + ].filter((part): part is string => Boolean(part)); + if (users.length > 0) { + reasons.push(`${joinList(users)} ${channels + playlists + titleShows === 1 ? 'downloads' : 'download'} here`); + } + const disabled = blockerCount(folder, 'disabledChannels'); + if (disabled > 0) { + reasons.push(disabled === 1 ? '1 unsubscribed channel still points here' : `${formatCount(disabled)} unsubscribed channels still point here`); + } + const shows = blockerCount(folder, 'shows'); + if (shows > 0) reasons.push(`it holds ${countOf(shows, 'TV show', 'TV shows')} with numbered episodes`); + if (hasBlocker(folder, 'default')) reasons.push("it's the default folder"); + if (hasBlocker(folder, 'files')) { + const count = folder.fileCount ?? 0; + const unit = units(folder.layout); + reasons.push(count > 0 ? `it holds ${countOf(count, `downloaded ${unit.one}`, `downloaded ${unit.many}`)}` : 'it still holds files on disk'); + } + return reasons; +} + +export function deleteReasonText(folder: LibraryFolder): string { + if (!folder.name) return "The main folder is the downloads folder itself, so it can't be deleted."; + if (folder.deletable) { + const mapped = folder.plexMapping && folder.plexMapping.choice !== 'none'; + return `Empty and unused, so you can delete it.${mapped ? ' Its Plex refresh setting is removed too.' : ''}`; + } + const reasons = deleteReasons(folder); + return reasons.length > 0 ? `Can't delete: ${reasons.join('; ')}.` : "Can't delete it right now."; +} + +export function verbLabel(folder: LibraryFolder): string { + return `${folder.layoutChangeNeedsReview ? 'Move to' : 'Use for'} ${layoutName(otherLayout(folder.layout))}`; +} + +export const REORGANIZING_LAYOUT_TEXT = 'Downloads are being reorganized. Change folder layouts when that finishes.'; +export const REORGANIZING_DEFAULT_TEXT = 'Downloads are being reorganized. Change the default folder when that finishes.'; + +export interface LayoutConsequence { + text: string; + blocked: boolean; +} + +export function layoutConsequence({ folder, titleShows, reorganizing }: { + folder: LibraryFolder; + /** From the folder detail; null while it loads */ + titleShows: LibraryFolderDetail['titleShows'] | null; + reorganizing: boolean; +}): LayoutConsequence { + const target = otherLayout(folder.layout); + if (reorganizing) return { text: REORGANIZING_LAYOUT_TEXT, blocked: true }; + if (folder.layout === 'tv') { + const shows = titleShows ?? []; + if (shows.length === 1) { + return { + text: `${shows[0].name} is a title show, and title shows need a TV folder. Move it to another TV folder first, ` + + `in ${shows[0].channelName}'s Channel Settings > TV Show.`, + blocked: true, + }; + } + if (shows.length > 1) { + return { + text: `${joinList(shows.map((show) => show.name))} are title shows, and title shows need a TV folder. ` + + 'Move them to another TV folder first, in Channel Settings > TV Show.', + blocked: true, + }; + } + if (!titleShows && (folder.titleShows ?? 0) > 0) { + return { text: 'Title shows need a TV folder. Move them to another TV folder first, in Channel Settings > TV Show.', blocked: true }; + } + } + if (folder.layoutChangeNeedsReview) { + return { text: `Its downloads move into the ${layoutName(target)} layout. You review every move first; downloads wait meanwhile.`, blocked: false }; + } + return { text: 'Nothing needs to move, so it switches at once.', blocked: false }; +} + +/** + * After the main folder's channel links: the channels that follow the default folder into it + * (named up to the server's sample), and what moves them out. + */ +export function followersLine(followers: LibraryFolderDetail['followers'], afterLinks: boolean): string | null { + if (followers.count <= 0) return null; + const rest = followers.count - followers.sample.length; + const names = joinList(rest > 0 ? [...followers.sample, `${formatCount(rest)} more`] : followers.sample); + const lead = `${formatCount(followers.count)}${afterLinks ? ' more' : ''} ${followers.count === 1 ? 'follows' : 'follow'} the default folder`; + return `${lead}${names ? ` (${names})` : ''}: make another folder the default, or set their Library folder in Channel Settings.`; +} + +export function movingNotice(name: string, target: LibraryLayout): string { + return `Moving ${folderLabel(name, true)}'s downloads to the ${layoutName(target)} layout. Downloads wait until it finishes.`; +} + +export interface MakeDefaultText { + title: string; + body: string; + sameLayoutNote: string | null; + warning: string | null; + tvLine: string | null; + confirmLabel: string; +} + +export function makeDefaultText({ folder, current }: { folder: LibraryFolder; current: LibraryFolder | null }): MakeDefaultText { + const label = folderLabel(folder.name, true); + const currentLabel = current ? folderLabel(current.name, true) : 'the main folder'; + const following = current?.channelsFollowing ?? 0; + const fallback = "It's also the fallback for downloads with no more specific folder: videos from channels you don't " + + 'subscribe to, unless a playlist or a single download picks another folder.'; + const body = following > 0 + ? `Channels set to the default folder (${formatCount(following)}) will download to ${label} instead of ${currentLabel}. ${fallback}` + : `Channels you set to the default folder later will download to ${label} instead of ${currentLabel}. ${fallback}`; + const sameLayout = !current || current.layout === folder.layout; + const target = folder.layout; + return { + title: `Make ${label} the default folder?`, + body, + sameLayoutNote: sameLayout ? `Videos they already downloaded stay where they are. Keep a library on ${currentLabel} to watch them.` : null, + warning: folder.makeDefaultNeedsReview && current + ? `${folderLabel(folder.name)} uses ${layoutName(folder.layout)} and ${currentLabel} uses ${layoutName(current.layout)}, ` + + `so channels that follow the default become ${target === 'tv' ? 'TV shows' : 'movie-style channels'} and their ` + + 'downloaded videos move. You review every move first.' + : null, + tvLine: target === 'tv' ? `Each channel you don't subscribe to becomes its own show in ${label}.` : null, + confirmLabel: folder.makeDefaultNeedsReview ? 'Review the move' : 'Make default', + }; +} diff --git a/client/src/components/LibraryFolders/hooks/__tests__/useCreateLibraryFolder.test.ts b/client/src/components/LibraryFolders/hooks/__tests__/useCreateLibraryFolder.test.ts new file mode 100644 index 000000000..da1043e17 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/useCreateLibraryFolder.test.ts @@ -0,0 +1,36 @@ +import { act, renderHook } from '@testing-library/react'; +jest.mock('axios', () => ({ post: jest.fn(), isAxiosError: jest.fn() })); +const axios = require('axios'); +import { useCreateLibraryFolder } from '../useCreateLibraryFolder'; +import { ReorganizeRequiredError } from '../../../shared/Reorganize'; + +describe('useCreateLibraryFolder', () => { + beforeEach(() => { + axios.isAxiosError.mockImplementation((err: { isAxiosError?: boolean }) => Boolean(err && err.isAxiosError)); + }); + + test('creates and announces the folder', async () => { + axios.post.mockResolvedValue({ data: { name: 'Science', layout: 'tv', created: true, existingContent: false } }); + const listener = jest.fn(); + window.addEventListener('library-folders-updated', listener); + const { result } = renderHook(() => useCreateLibraryFolder('token')); + + let created; + await act(async () => { created = await result.current.createFolder('Science', 'tv'); }); + + expect(created).toEqual({ name: 'Science', layout: 'tv', created: true, existingContent: false }); + expect(axios.post).toHaveBeenCalledWith('/api/subfolders', { name: 'Science', layout: 'tv' }, { headers: { 'x-access-token': 'token' } }); + expect(listener).toHaveBeenCalled(); + window.removeEventListener('library-folders-updated', listener); + }); + + test('turns a reorganize refusal into a ReorganizeRequiredError', async () => { + axios.post.mockRejectedValue({ isAxiosError: true, response: { status: 409, data: { + error: 'Review the move', reorganizeRequired: true, change: { type: 'folderLayout', folder: 'Old', layout: 'tv' }, + } } }); + const { result } = renderHook(() => useCreateLibraryFolder('token')); + await act(async () => { + await expect(result.current.createFolder('Old', 'tv')).rejects.toBeInstanceOf(ReorganizeRequiredError); + }); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/__tests__/useDefaultFolder.test.ts b/client/src/components/LibraryFolders/hooks/__tests__/useDefaultFolder.test.ts new file mode 100644 index 000000000..e91ddea24 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/useDefaultFolder.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; +jest.mock('axios', () => ({ put: jest.fn(), get: jest.fn(), isAxiosError: () => false })); +const axios = require('axios'); +import { useDefaultFolder } from '../useDefaultFolder'; + +describe('useDefaultFolder', () => { + test('saves and patches the config with the saved default', async () => { + axios.put.mockResolvedValue({ data: { changed: true, defaultSubfolder: 'TV' } }); + const patched = jest.fn(); + window.addEventListener('config-patched', patched); + const { result } = renderHook(() => useDefaultFolder('token')); + + await act(async () => { await result.current.setDefaultFolder('TV'); }); + + expect((patched.mock.calls[0][0] as CustomEvent).detail).toEqual({ defaultSubfolder: 'TV' }); + window.removeEventListener('config-patched', patched); + }); + + test('reads the default back from the folder list and patches the config with it', async () => { + axios.get.mockResolvedValue({ data: { folders: [ + { name: '', isDefault: false }, { name: 'Kids', isDefault: true }, + ] } }); + const patched = jest.fn(); + window.addEventListener('config-patched', patched); + const { result } = renderHook(() => useDefaultFolder('token')); + let value; + await act(async () => { value = await result.current.readBackDefault(); }); + expect(value).toBe('Kids'); + expect((patched.mock.calls[0][0] as CustomEvent).detail).toEqual({ defaultSubfolder: 'Kids' }); + window.removeEventListener('config-patched', patched); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/__tests__/useGuideOpen.test.ts b/client/src/components/LibraryFolders/hooks/__tests__/useGuideOpen.test.ts new file mode 100644 index 000000000..2b0aad4de --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/useGuideOpen.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook } from '@testing-library/react'; +import { useGuideOpen } from '../useGuideOpen'; + +describe('useGuideOpen', () => { + beforeEach(() => window.localStorage.clear()); + afterEach(() => jest.restoreAllMocks()); + + test('follows the default until toggled, then remembers the choice', () => { + const { result } = renderHook(() => useGuideOpen(true)); + expect(result.current[0]).toBe(true); + act(() => { result.current[1](); }); + expect(result.current[0]).toBe(false); + expect(renderHook(() => useGuideOpen(true)).result.current[0]).toBe(false); + }); + + test('decides the default once it is known, and keeps it', () => { + const { result, rerender } = renderHook(({ fallback }: { fallback: boolean | null }) => useGuideOpen(fallback), { + initialProps: { fallback: null as boolean | null }, + }); + expect(result.current[0]).toBe(false); + rerender({ fallback: true }); + expect(result.current[0]).toBe(true); + rerender({ fallback: false }); + expect(result.current[0]).toBe(true); + }); + + test('works when storage throws', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); }); + expect(renderHook(() => useGuideOpen(false)).result.current[0]).toBe(false); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/__tests__/useLayoutChange.test.ts b/client/src/components/LibraryFolders/hooks/__tests__/useLayoutChange.test.ts new file mode 100644 index 000000000..290830594 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/useLayoutChange.test.ts @@ -0,0 +1,64 @@ +import { act, renderHook } from '@testing-library/react'; +import { useLayoutChange } from '../useLayoutChange'; +import { ReorganizeRequiredError } from '../../../shared/Reorganize'; + +jest.mock('axios', () => ({ get: jest.fn(), isAxiosError: () => false })); +const axios = require('axios'); + +describe('useLayoutChange', () => { + test('reports success for the folder', async () => { + const setFolderLayout = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useLayoutChange({ token: 'token', setFolderLayout, review: jest.fn() })); + await act(async () => { await result.current.changeLayout('Kids', 'tv'); }); + expect(result.current.result).toEqual({ folder: 'Kids', tone: 'success', text: 'Now a TV shows folder.' }); + }); + + test('hands a reorganize refusal to the review', async () => { + const change = { type: 'folderLayout' as const, folder: 'Kids', layout: 'tv' as const }; + const review = jest.fn(); + const setFolderLayout = jest.fn().mockRejectedValue(new ReorganizeRequiredError('Review the move', change)); + const { result } = renderHook(() => useLayoutChange({ token: 'token', setFolderLayout, review })); + await act(async () => { await result.current.changeLayout('Kids', 'tv'); }); + expect(review).toHaveBeenCalledWith(change, { kind: 'layout', folder: 'Kids', target: 'tv' }); + expect(result.current.result).toBeNull(); + }); + + test('shows a refusal message', async () => { + const setFolderLayout = jest.fn().mockRejectedValue(new Error('Wait for the current download to finish')); + const { result } = renderHook(() => useLayoutChange({ token: 'token', setFolderLayout, review: jest.fn() })); + await act(async () => { await result.current.changeLayout('Kids', 'tv'); }); + expect(result.current.result).toEqual({ folder: 'Kids', tone: 'warning', text: 'Wait for the current download to finish' }); + }); + + describe('when a move ends', () => { + const renderMoving = () => { + const { result } = renderHook(() => useLayoutChange({ token: 'token', setFolderLayout: jest.fn(), review: jest.fn() })); + act(() => { result.current.showMoving('Kids', 'tv'); }); + return { result }; + }; + + test('the folder now in its new layout reads as switched', async () => { + axios.get.mockResolvedValue({ data: { folders: [{ name: '', layout: 'videos' }, { name: 'kids', layout: 'tv' }] } }); + const { result } = renderMoving(); + await act(async () => { await result.current.settleMoving('Kids', 'tv'); }); + expect(result.current.result).toEqual({ folder: 'Kids', tone: 'success', text: 'Now a TV shows folder.' }); + }); + + test('a move the server undid clears the moving line', async () => { + axios.get.mockResolvedValue({ data: { folders: [{ name: 'Kids', layout: 'videos' }] } }); + const { result } = renderMoving(); + await act(async () => { await result.current.settleMoving('Kids', 'tv'); }); + expect(result.current.result).toBeNull(); + }); + + test('a later result for another folder stays', async () => { + axios.get.mockResolvedValue({ data: { folders: [{ name: 'Kids', layout: 'tv' }] } }); + const setFolderLayout = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useLayoutChange({ token: 'token', setFolderLayout, review: jest.fn() })); + act(() => { result.current.showMoving('Kids', 'tv'); }); + await act(async () => { await result.current.changeLayout('Docs', 'tv'); }); + await act(async () => { await result.current.settleMoving('Kids', 'tv'); }); + expect(result.current.result).toEqual({ folder: 'Docs', tone: 'success', text: 'Now a TV shows folder.' }); + }); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/__tests__/useLibraryFolderDetail.test.ts b/client/src/components/LibraryFolders/hooks/__tests__/useLibraryFolderDetail.test.ts new file mode 100644 index 000000000..34575e024 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/useLibraryFolderDetail.test.ts @@ -0,0 +1,35 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +jest.mock('axios', () => ({ get: jest.fn(), isAxiosError: () => false })); +const axios = require('axios'); +import { useLibraryFolderDetail } from '../useLibraryFolderDetail'; + +describe('useLibraryFolderDetail', () => { + test('loads the main folder by its route key', async () => { + axios.get.mockResolvedValue({ data: { name: '', layout: 'videos', channels: [], followers: { count: 0, sample: [] }, playlists: [], titleShows: [], example: null } }); + const { result } = renderHook(() => useLibraryFolderDetail('token', '')); + await waitFor(() => expect(result.current.detail?.name).toBe('')); + expect(axios.get).toHaveBeenCalledWith('/api/library-folders/folder/~main', { headers: { 'x-access-token': 'token' } }); + }); + + test('refetches when folders change', async () => { + axios.get.mockResolvedValue({ data: { name: 'Kids', layout: 'videos', channels: [], followers: { count: 0, sample: [] }, playlists: [], titleShows: [], example: null } }); + renderHook(() => useLibraryFolderDetail('token', 'Kids')); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + act(() => { window.dispatchEvent(new Event('library-folders-updated')); }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + }); + + test('loads nothing without a folder', () => { + renderHook(() => useLibraryFolderDetail('token', null)); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('clears loading when the folder is deselected mid-request', async () => { + axios.get.mockReturnValue(new Promise(() => undefined)); + const { result, rerender } = renderHook(({ name }: { name: string | null }) => useLibraryFolderDetail('token', name), { initialProps: { name: 'Kids' as string | null } }); + await waitFor(() => expect(result.current.loading).toBe(true)); + rerender({ name: null }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/__tests__/usePlexRefreshMapping.test.ts b/client/src/components/LibraryFolders/hooks/__tests__/usePlexRefreshMapping.test.ts new file mode 100644 index 000000000..234b08f94 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/usePlexRefreshMapping.test.ts @@ -0,0 +1,36 @@ +import { act, renderHook } from '@testing-library/react'; +jest.mock('axios', () => ({ put: jest.fn(), delete: jest.fn(), isAxiosError: () => false })); +const axios = require('axios'); +import { usePlexRefreshMapping } from '../usePlexRefreshMapping'; + +const saved = { mappedLibraryId: null, choice: 'default', plexSubfolderLibraryMappings: [{ subfolder: 'TV', libraryId: null }] }; + +describe('usePlexRefreshMapping', () => { + test('stores the explicit default and patches the config', async () => { + axios.put.mockResolvedValue({ data: saved }); + const patched = jest.fn(); + window.addEventListener('config-patched', patched); + const { result } = renderHook(() => usePlexRefreshMapping('token')); + + await act(async () => { await result.current.setMapping('TV', null); }); + + expect(axios.put).toHaveBeenCalledWith('/api/library-folders/plex-mapping', { folder: 'TV', libraryId: null, replace: true }, { headers: { 'x-access-token': 'token' } }); + expect((patched.mock.calls[0][0] as CustomEvent).detail).toEqual({ plexSubfolderLibraryMappings: saved.plexSubfolderLibraryMappings }); + window.removeEventListener('config-patched', patched); + }); + + test('removes a setting for the main folder', async () => { + axios.delete.mockResolvedValue({ data: { ...saved, choice: 'none', plexSubfolderLibraryMappings: [] } }); + const { result } = renderHook(() => usePlexRefreshMapping('token')); + await act(async () => { await result.current.removeMapping(''); }); + expect(axios.delete).toHaveBeenCalledWith('/api/library-folders/plex-mapping', { headers: { 'x-access-token': 'token' }, params: { folder: '' } }); + }); + + test('throws the fallback message on failure', async () => { + axios.put.mockRejectedValue(new Error('network')); + const { result } = renderHook(() => usePlexRefreshMapping('token')); + await act(async () => { + await expect(result.current.setMapping('TV', '41')).rejects.toThrow('Could not save the Plex library mapping.'); + }); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/__tests__/useReorganizeHandoff.test.tsx b/client/src/components/LibraryFolders/hooks/__tests__/useReorganizeHandoff.test.tsx new file mode 100644 index 000000000..079ad185a --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/useReorganizeHandoff.test.tsx @@ -0,0 +1,143 @@ +import { act, renderHook } from '@testing-library/react'; +import { useReorganizeHandoff } from '../useReorganizeHandoff'; + +jest.mock('../../../shared/Reorganize', () => { + const actual = jest.requireActual('../../../shared/Reorganize'); + return { ...actual, useReorganizeOutcome: jest.fn() }; +}); +const { useReorganizeOutcome } = require('../../../shared/Reorganize'); + +type Finished = (() => void) | null; + +function captureFinished(): { current: Finished } { + const holder: { current: Finished } = { current: null }; + useReorganizeOutcome.mockImplementation((_token: string, operationId: number | null, onFinished: () => void) => { + if (operationId !== null) holder.current = onFinished; + }); + return holder; +} + +function renderHandoff(overrides: Partial<Parameters<typeof useReorganizeHandoff>[1]> = {}) { + const options = { + onSettled: jest.fn(), onLayoutMoving: jest.fn(), onLayoutSettled: jest.fn(), readBackDefault: jest.fn().mockResolvedValue('Kids'), + ...overrides, + }; + const { result } = renderHook(() => useReorganizeHandoff('token', options)); + return { result, options }; +} + +describe('useReorganizeHandoff', () => { + test('a default change reads the default back at its end, and the read-back alone refreshes', async () => { + const finished = captureFinished(); + const { result, options } = renderHandoff(); + + act(() => { result.current.review({ type: 'defaultSubfolder', value: 'TV' }, { kind: 'default', folder: 'TV' }); }); + expect(result.current.dialogProps.open).toBe(true); + act(() => { result.current.dialogProps.onApplied?.({ operationId: 5, applied: true }); }); + await act(async () => { finished.current?.(); }); + + expect(options.readBackDefault).toHaveBeenCalledTimes(1); + expect(options.onSettled).not.toHaveBeenCalled(); + }); + + test('a default change refreshes once when the read-back fails', async () => { + const finished = captureFinished(); + const { result, options } = renderHandoff({ readBackDefault: jest.fn().mockResolvedValue(null) }); + act(() => { result.current.review({ type: 'defaultSubfolder', value: 'TV' }, { kind: 'default', folder: 'TV' }); }); + act(() => { result.current.dialogProps.onApplied?.({ operationId: 5, applied: true }); }); + + await act(async () => { finished.current?.(); }); + + expect(options.onSettled).toHaveBeenCalledTimes(1); + }); + + test('a layout move reports the moving notice for its folder', () => { + useReorganizeOutcome.mockImplementation(() => undefined); + const { result, options } = renderHandoff(); + + act(() => { result.current.review({ type: 'folderLayout', folder: 'Kids', layout: 'tv' }, { kind: 'layout', folder: 'Kids', target: 'tv' }); }); + act(() => { result.current.dialogProps.onApplied?.({ operationId: 6, applied: true }); }); + + expect(options.onLayoutMoving).toHaveBeenCalledWith('Kids', 'tv'); + }); + + test('closing a default change reads it back, and the read-back alone refreshes', async () => { + useReorganizeOutcome.mockImplementation(() => undefined); + const { result, options } = renderHandoff({ readBackDefault: jest.fn().mockResolvedValue('') }); + + act(() => { result.current.review({ type: 'defaultSubfolder', value: 'TV' }, { kind: 'default', folder: 'TV' }); }); + await act(async () => { result.current.dialogProps.onClose(); }); + + expect(result.current.dialogProps.open).toBe(false); + expect(options.readBackDefault).toHaveBeenCalledTimes(1); + expect(options.onSettled).not.toHaveBeenCalled(); + }); + + test('closing a layout change refreshes once', () => { + useReorganizeOutcome.mockImplementation(() => undefined); + const { result, options } = renderHandoff(); + act(() => { result.current.review({ type: 'folderLayout', folder: 'Kids', layout: 'tv' }, { kind: 'layout', folder: 'Kids', target: 'tv' }); }); + + act(() => { result.current.dialogProps.onClose(); }); + + expect(options.onSettled).toHaveBeenCalledTimes(1); + expect(options.readBackDefault).not.toHaveBeenCalled(); + }); + + test('a bare settings change (no operation) does not report a moving layout', () => { + useReorganizeOutcome.mockImplementation(() => undefined); + const { result, options } = renderHandoff(); + act(() => { result.current.review({ type: 'folderLayout', folder: 'Kids', layout: 'tv' }, { kind: 'layout', folder: 'Kids', target: 'tv' }); }); + act(() => { result.current.dialogProps.onApplied?.({ operationId: null, applied: true }); }); + expect(options.onLayoutMoving).not.toHaveBeenCalled(); + }); + + test('a retried default operation reads the default back again at the retried end', async () => { + const finished = captureFinished(); + const attempts: number[] = []; + useReorganizeOutcome.mockImplementation((_t: string, operationId: number | null, onFinished: () => void, options: { attempt: number }) => { + if (operationId !== null) { finished.current = onFinished; attempts.push(options.attempt); } + }); + const { result, options } = renderHandoff(); + act(() => { result.current.review({ type: 'defaultSubfolder', value: 'TV' }, { kind: 'default', folder: 'TV' }); }); + act(() => { result.current.dialogProps.onApplied?.({ operationId: 5, applied: true }); }); + await act(async () => { finished.current?.(); }); + act(() => { result.current.dialogProps.onRetried?.(5); }); + expect(attempts[attempts.length - 1]).toBe(1); + await act(async () => { finished.current?.(); }); + expect(options.readBackDefault).toHaveBeenCalledTimes(2); + }); + + test('a layout operation end refreshes once and settles the moving line, without reading the default back', async () => { + const finished = captureFinished(); + const { result, options } = renderHandoff(); + act(() => { result.current.review({ type: 'folderLayout', folder: 'Kids', layout: 'tv' }, { kind: 'layout', folder: 'Kids', target: 'tv' }); }); + act(() => { result.current.dialogProps.onApplied?.({ operationId: 6, applied: true }); }); + + await act(async () => { finished.current?.(); }); + + expect(options.onSettled).toHaveBeenCalledTimes(1); + expect(options.onLayoutSettled).toHaveBeenCalledWith('Kids', 'tv'); + expect(options.readBackDefault).not.toHaveBeenCalled(); + }); + + test('a created TV folder that had to move settles its moving line at the end', async () => { + const finished = captureFinished(); + const { result, options } = renderHandoff(); + act(() => { result.current.review({ type: 'folderLayout', folder: 'Old', layout: 'tv' }, { kind: 'create', folder: 'Old', target: 'tv' }); }); + act(() => { result.current.dialogProps.onApplied?.({ operationId: 7, applied: true }); }); + + await act(async () => { finished.current?.(); }); + + expect(options.onLayoutSettled).toHaveBeenCalledWith('Old', 'tv'); + }); + + test('names the operation it follows', () => { + useReorganizeOutcome.mockImplementation(() => undefined); + const { result } = renderHandoff(); + expect(result.current.trackedOperationId).toBeNull(); + act(() => { result.current.review({ type: 'folderLayout', folder: 'Kids', layout: 'tv' }, { kind: 'layout', folder: 'Kids', target: 'tv' }); }); + act(() => { result.current.dialogProps.onApplied?.({ operationId: 6, applied: true }); }); + expect(result.current.trackedOperationId).toBe(6); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/__tests__/useSelectedFolder.test.tsx b/client/src/components/LibraryFolders/hooks/__tests__/useSelectedFolder.test.tsx new file mode 100644 index 000000000..b66480e07 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/__tests__/useSelectedFolder.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { useSelectedFolder } from '../useSelectedFolder'; +import type { LibraryFolder } from '../../../../types/tvShows'; + +const folders: LibraryFolder[] = [ + { name: '', layout: 'videos', isDefault: false, hasFiles: false, channels: 0 }, + { name: 'Kids', layout: 'videos', isDefault: true, hasFiles: false, channels: 2 }, +]; +let location: ReturnType<typeof useLocation>; +function Spy() { location = useLocation(); return null; } + +const wrapperAt = (entry: string | { pathname: string; state?: unknown }) => function Wrapper({ children }: { children: React.ReactNode }) { + return ( + <MemoryRouter initialEntries={[entry]}> + <Routes><Route path="/settings/library/*" element={<>{children}<Spy /></>} /></Routes> + </MemoryRouter> + ); +}; + +describe('useSelectedFolder', () => { + test('two columns: the bare URL selects the default folder at once', async () => { + renderHook(() => useSelectedFolder({ folders, loaded: true, twoColumn: true }), { wrapper: wrapperAt('/settings/library') }); + await waitFor(() => expect(location.pathname).toBe('/settings/library/Kids')); + }); + + test('list/detail: the bare URL selects nothing', () => { + const { result } = renderHook(() => useSelectedFolder({ folders, loaded: true, twoColumn: false }), { wrapper: wrapperAt('/settings/library') }); + expect(result.current.selected).toBeNull(); + expect(location.pathname).toBe('/settings/library'); + }); + + test('matches the URL ignoring case and resolves the main folder key', () => { + const { result } = renderHook(() => useSelectedFolder({ folders, loaded: true, twoColumn: true }), { wrapper: wrapperAt('/settings/library/kids') }); + expect(result.current.selected?.name).toBe('Kids'); + const view = renderHook(() => useSelectedFolder({ folders, loaded: true, twoColumn: true }), { wrapper: wrapperAt('/settings/library/~main') }); + expect(view.result.current.selected?.name).toBe(''); + }); + + test('an unknown folder goes back to the list with a notice', async () => { + const { result } = renderHook(() => useSelectedFolder({ folders, loaded: true, twoColumn: false }), { wrapper: wrapperAt('/settings/library/Gone') }); + await waitFor(() => expect(location.pathname).toBe('/settings/library')); + expect(result.current.missingName).toBe('Gone'); + }); + + test('waits for the folder list before deciding a folder is unknown', () => { + renderHook(() => useSelectedFolder({ folders: [], loaded: false, twoColumn: true }), { wrapper: wrapperAt('/settings/library/Kids') }); + expect(location.pathname).toBe('/settings/library/Kids'); + }); + + test('a folder selected while the list refetches is not reported missing', async () => { + const { result, rerender } = renderHook( + ({ list, loading }: { list: LibraryFolder[]; loading: boolean }) => useSelectedFolder({ folders: list, loaded: true, loading, twoColumn: true }), + { wrapper: wrapperAt('/settings/library/Kids'), initialProps: { list: folders, loading: true } } + ); + act(() => { result.current.select('Science'); }); + expect(location.pathname).toBe('/settings/library/Science'); + rerender({ list: [...folders, { name: 'Science', layout: 'tv', isDefault: false, hasFiles: false, channels: 0 }], loading: false }); + await waitFor(() => expect(result.current.selected?.name).toBe('Science')); + expect(result.current.missingName).toBeNull(); + expect(location.pathname).toBe('/settings/library/Science'); + }); + + test('after a failed load a deep link is not reported missing', () => { + const { result } = renderHook(() => useSelectedFolder({ folders: [], loaded: true, error: 'Failed to load', twoColumn: true }), { wrapper: wrapperAt('/settings/library/Kids') }); + expect(result.current.missingName).toBeNull(); + expect(location.pathname).toBe('/settings/library/Kids'); + }); +}); diff --git a/client/src/components/LibraryFolders/hooks/useCreateLibraryFolder.ts b/client/src/components/LibraryFolders/hooks/useCreateLibraryFolder.ts new file mode 100644 index 000000000..d4d4318e9 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/useCreateLibraryFolder.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from 'react'; +import axios from 'axios'; +import type { CreateLibraryFolderResult, LibraryLayout } from '../../../types/tvShows'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; +import { SUBFOLDERS_UPDATED_EVENT } from '../../../hooks/useSubfolders'; +import { isReorganizeRequired, toRequestError } from '../../shared/Reorganize/reorganizeErrors'; + +function announceFolders() { + window.dispatchEvent(new Event(SUBFOLDERS_UPDATED_EVENT)); + window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); +} + +/** POST /api/subfolders { name, layout }: creates the directory and registers the folder. */ +export function useCreateLibraryFolder(token: string | null) { + const [creating, setCreating] = useState(false); + + const createFolder = useCallback(async (name: string, layout: LibraryLayout): Promise<CreateLibraryFolderResult> => { + setCreating(true); + try { + const response = await axios.post<CreateLibraryFolderResult>('/api/subfolders', { name, layout }, { + headers: { 'x-access-token': token || '' }, + }); + announceFolders(); + return response.data; + } catch (err: unknown) { + const error = toRequestError(err, 'Failed to create the folder'); + // The folder exists as Videos; its move to TV goes through the review. + if (isReorganizeRequired(error)) announceFolders(); + throw error; + } finally { + setCreating(false); + } + }, [token]); + + return { creating, createFolder }; +} diff --git a/client/src/components/LibraryFolders/hooks/useDefaultFolder.ts b/client/src/components/LibraryFolders/hooks/useDefaultFolder.ts new file mode 100644 index 000000000..4cb9d7c14 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/useDefaultFolder.ts @@ -0,0 +1,46 @@ +import { useCallback, useState } from 'react'; +import axios from 'axios'; +import type { LibraryFoldersResponse } from '../../../types/tvShows'; +import { CONFIG_PATCHED_EVENT } from '../../../hooks/useConfig'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; +import { toRequestError } from '../../shared/Reorganize/reorganizeErrors'; + +function announceDefault(value: string) { + window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: { defaultSubfolder: value } })); + window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); +} + +/** PUT /api/library-folders/default, and reading the saved default back after a reorganize. */ +export function useDefaultFolder(token: string | null) { + const [saving, setSaving] = useState(false); + + const setDefaultFolder = useCallback(async (name: string): Promise<void> => { + setSaving(true); + try { + const response = await axios.put<{ changed: boolean; defaultSubfolder: string }>( + '/api/library-folders/default', { name }, { headers: { 'x-access-token': token || '' } } + ); + announceDefault(response.data.defaultSubfolder); + } catch (err: unknown) { + throw toRequestError(err, 'Failed to change the default folder'); + } finally { + setSaving(false); + } + }, [token]); + + // The server undoes a layout-changing switch when nothing could move. + const readBackDefault = useCallback(async (): Promise<string | null> => { + try { + const response = await axios.get<LibraryFoldersResponse>('/api/library-folders', { + headers: { 'x-access-token': token || '' }, + }); + const value = response.data.folders.find((folder) => folder.isDefault)?.name ?? ''; + announceDefault(value); + return value; + } catch { + return null; + } + }, [token]); + + return { saving, setDefaultFolder, readBackDefault }; +} diff --git a/client/src/components/LibraryFolders/hooks/useGuideOpen.ts b/client/src/components/LibraryFolders/hooks/useGuideOpen.ts new file mode 100644 index 000000000..b7c599ab7 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/useGuideOpen.ts @@ -0,0 +1,37 @@ +import { useCallback, useState } from 'react'; + +const STORAGE_KEY = 'youtarr:libraryGuideOpen'; + +function readStored(): boolean | null { + try { + const value = window.localStorage.getItem(STORAGE_KEY); + return value === 'true' ? true : value === 'false' ? false : null; + } catch { + return null; + } +} + +function writeStored(open: boolean) { + try { + window.localStorage.setItem(STORAGE_KEY, String(open)); + } catch { + // Storage blocked: the toggle still works for this visit. + } +} + +/** + * The guide's open state: an explicit toggle, remembered per browser, wins over the default. The default + * is taken once, when it is first known (null until the folders load), so the guide doesn't fold mid-visit. + */ +export function useGuideOpen(defaultOpen: boolean | null): [boolean, () => void] { + const [stored, setStored] = useState<boolean | null>(readStored); + const [decided, setDecided] = useState<boolean | null>(defaultOpen); + if (decided === null && defaultOpen !== null) setDecided(defaultOpen); + const open = stored ?? decided ?? defaultOpen ?? false; + const toggle = useCallback(() => { + const next = !open; + setStored(next); + writeStored(next); + }, [open]); + return [open, toggle]; +} diff --git a/client/src/components/LibraryFolders/hooks/useLayoutChange.ts b/client/src/components/LibraryFolders/hooks/useLayoutChange.ts new file mode 100644 index 000000000..7fcfbc422 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/useLayoutChange.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; +import axios from 'axios'; +import type { LibraryFoldersResponse, LibraryLayout } from '../../../types/tvShows'; +import type { ReorganizeChange } from '../../../types/reorganize'; +import { folderKey } from '../../../utils/libraryLayouts'; +import { isReorganizeRequired } from '../../shared/Reorganize'; +import { layoutName, movingNotice } from '../folderText'; +import type { ReorganizeHandoffContext } from './useReorganizeHandoff'; + +export interface LayoutResult { + folder: string; + tone: 'success' | 'warning' | 'info'; + text: string; +} + +interface Options { + token: string | null; + setFolderLayout: (name: string, layout: LibraryLayout) => Promise<void>; + review: (change: ReorganizeChange, context: ReorganizeHandoffContext) => void; +} + +const switchedResult = (folder: string, target: LibraryLayout): LayoutResult => ( + { folder, tone: 'success', text: `Now a ${layoutName(target)} folder.` } +); + +/** Folder layout changes from the page: the result line, and the hand-off when files must move (UI 5.7.3). */ +export function useLayoutChange({ token, setFolderLayout, review }: Options) { + const [busyFolder, setBusyFolder] = useState<string | null>(null); + const [result, setResult] = useState<LayoutResult | null>(null); + + const changeLayout = useCallback(async (name: string, target: LibraryLayout) => { + setBusyFolder(name); + setResult(null); + try { + await setFolderLayout(name, target); + setResult(switchedResult(name, target)); + } catch (err: unknown) { + if (isReorganizeRequired(err)) { + review(err.change, { kind: 'layout', folder: name, target }); + } else { + setResult({ folder: name, tone: 'warning', text: err instanceof Error ? err.message : 'Failed to change the folder layout' }); + } + } finally { + setBusyFolder(null); + } + }, [setFolderLayout, review]); + + const showMoving = useCallback((folder: string, target: LibraryLayout) => { + setResult({ folder, tone: 'info', text: movingNotice(folder, target) }); + }, []); + + // A move ended: the server undoes the layout change when nothing could move, so read the layout back. + const settleMoving = useCallback(async (folder: string, target: LibraryLayout) => { + let layout: LibraryLayout | null = null; + try { + const response = await axios.get<LibraryFoldersResponse>('/api/library-folders', { + headers: { 'x-access-token': token || '' }, + }); + layout = response.data.folders.find((entry) => folderKey(entry.name) === folderKey(folder))?.layout ?? null; + } catch { + layout = null; + } + setResult((current) => { + if (!current || current.tone !== 'info' || folderKey(current.folder) !== folderKey(folder)) return current; + return layout === target ? switchedResult(current.folder, target) : null; + }); + }, [token]); + + return { busyFolder, result, changeLayout, showMoving, settleMoving }; +} diff --git a/client/src/components/LibraryFolders/hooks/useLibraryFolderDetail.ts b/client/src/components/LibraryFolders/hooks/useLibraryFolderDetail.ts new file mode 100644 index 000000000..7dd277904 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/useLibraryFolderDetail.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import type { LibraryFolderDetail } from '../../../types/tvShows'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; +import { SUBFOLDERS_UPDATED_EVENT } from '../../../hooks/useSubfolders'; +import { folderKey, folderRouteKey } from '../../../utils/libraryLayouts'; +import { serverMessageOf } from '../../shared/Reorganize/reorganizeErrors'; + +/** GET /api/library-folders/folder/:key for the selected folder; refetched when folders change. */ +export function useLibraryFolderDetail(token: string | null, name: string | null) { + const [detail, setDetail] = useState<LibraryFolderDetail | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const requestSeq = useRef(0); + + const fetchDetail = useCallback(async () => { + const seq = ++requestSeq.current; + // Never show one folder's detail under another. + setDetail((current) => (current && name !== null && folderKey(current.name) === folderKey(name) ? current : null)); + if (!token || name === null) { + setLoading(false); + setError(null); + return; + } + setLoading(true); + setError(null); + try { + const response = await axios.get<LibraryFolderDetail>(`/api/library-folders/folder/${folderRouteKey(name)}`, { + headers: { 'x-access-token': token }, + }); + if (seq === requestSeq.current) setDetail(response.data); + } catch (err: unknown) { + if (seq === requestSeq.current) setError(serverMessageOf(err, 'Failed to load the library folder')); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [token, name]); + + useEffect(() => { + fetchDetail(); + return () => { requestSeq.current += 1; }; + }, [fetchDetail]); + + useEffect(() => { + const handler = () => { fetchDetail(); }; + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + window.addEventListener(SUBFOLDERS_UPDATED_EVENT, handler); + return () => { + window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); + window.removeEventListener(SUBFOLDERS_UPDATED_EVENT, handler); + }; + }, [fetchDetail]); + + return { detail, loading, error, refetch: fetchDetail }; +} diff --git a/client/src/components/LibraryFolders/hooks/usePlexRefreshMapping.ts b/client/src/components/LibraryFolders/hooks/usePlexRefreshMapping.ts new file mode 100644 index 000000000..8ce4c6d00 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/usePlexRefreshMapping.ts @@ -0,0 +1,45 @@ +import { useCallback, useState } from 'react'; +import axios from 'axios'; +import type { PlexMappingChoice } from '../../../types/tvShows'; +import type { ConfigState } from '../../Configuration/types'; +import { CONFIG_PATCHED_EVENT } from '../../../hooks/useConfig'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; +import { serverMessageOf } from '../../shared/Reorganize/reorganizeErrors'; + +export const PLEX_MAPPING_SAVE_ERROR = 'Could not save the Plex library mapping.'; + +interface PlexRefreshMappingResponse { + mappedLibraryId: string | null; + choice: PlexMappingChoice; + plexSubfolderLibraryMappings: ConfigState['plexSubfolderLibraryMappings']; +} + +/** The Plex library a folder's downloads refresh: set (replace mode), explicit default (null), or removed. */ +export function usePlexRefreshMapping(token: string | null) { + const [saving, setSaving] = useState(false); + + const save = useCallback(async (request: () => Promise<{ data: PlexRefreshMappingResponse }>) => { + setSaving(true); + try { + const { data } = await request(); + window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { + detail: { plexSubfolderLibraryMappings: data.plexSubfolderLibraryMappings }, + })); + window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); + } catch (err: unknown) { + throw new Error(serverMessageOf(err, PLEX_MAPPING_SAVE_ERROR)); + } finally { + setSaving(false); + } + }, []); + + const setMapping = useCallback((folder: string, libraryId: string | null) => save(() => axios.put<PlexRefreshMappingResponse>( + '/api/library-folders/plex-mapping', { folder, libraryId, replace: true }, { headers: { 'x-access-token': token || '' } } + )), [save, token]); + + const removeMapping = useCallback((folder: string) => save(() => axios.delete<PlexRefreshMappingResponse>( + '/api/library-folders/plex-mapping', { headers: { 'x-access-token': token || '' }, params: { folder } } + )), [save, token]); + + return { saving, setMapping, removeMapping }; +} diff --git a/client/src/components/LibraryFolders/hooks/useReorganizeHandoff.ts b/client/src/components/LibraryFolders/hooks/useReorganizeHandoff.ts new file mode 100644 index 000000000..16fedd638 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/useReorganizeHandoff.ts @@ -0,0 +1,95 @@ +import { useCallback, useState } from 'react'; +import type { LibraryLayout } from '../../../types/tvShows'; +import type { ReorganizeChange, ReorganizeStartResult } from '../../../types/reorganize'; +import { LIBRARY_FOLDERS_UPDATED_EVENT } from '../../../hooks/useLibraryFolders'; +import { useReorganizeOutcome, useReorganizeRequest } from '../../shared/Reorganize'; + +export interface ReorganizeHandoffContext { + kind: 'layout' | 'default' | 'create'; + folder: string; + target?: LibraryLayout; +} + +interface Options { + /** Refetch folders and the check */ + onSettled: () => void; + onLayoutMoving: (folder: string, target: LibraryLayout) => void; + /** A move the page started ended: replace its moving line */ + onLayoutSettled: (folder: string, target: LibraryLayout) => void; + /** Patches the config and announces the folder change (which refetches folders and the check); null when it failed */ + readBackDefault: () => Promise<string | null>; +} + +interface TrackedOperation { + operationId: number; + attempt: number; + kind: ReorganizeHandoffContext['kind']; + folder: string; + target?: LibraryLayout; +} + +/** + * The page's hand-off to the shared ReorganizeDialog (UI 7.5): opens it on a + * change, follows the started operation to each end, and reads the default + * folder back for a default switch, since the server undoes the change when + * nothing moved. Each settle refreshes once. + */ +export function useReorganizeHandoff(token: string | null, { onSettled, onLayoutMoving, onLayoutSettled, readBackDefault }: Options) { + const request = useReorganizeRequest(); + const [context, setContext] = useState<ReorganizeHandoffContext | null>(null); + const [tracked, setTracked] = useState<TrackedOperation | null>(null); + const { review: openReview, close } = request; + + const review = useCallback((change: ReorganizeChange, next: ReorganizeHandoffContext) => { + setContext(next); + openReview(change); + }, [openReview]); + + // The read-back's announcement refetches folders and the check; refresh here only when it failed. + const settleDefault = useCallback(() => { + void Promise.resolve(readBackDefault()).then((value) => { + if (value === null) onSettled(); + }); + }, [readBackDefault, onSettled]); + + const onApplied = useCallback((result: ReorganizeStartResult) => { + window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); + if (result.operationId && context && context.kind !== 'default' && context.target) onLayoutMoving(context.folder, context.target); + if (result.operationId) { + setTracked({ + operationId: result.operationId, attempt: 0, kind: context?.kind ?? 'layout', folder: context?.folder ?? '', target: context?.target, + }); + } + }, [context, onLayoutMoving]); + + const onRetried = useCallback((operationId: number) => { + setTracked((current) => (current && current.operationId === operationId ? { ...current, attempt: current.attempt + 1 } : current)); + }, []); + + const onClose = useCallback(() => { + const kind = context?.kind; + close(); + setContext(null); + if (kind === 'default') settleDefault(); + else onSettled(); + }, [close, context, onSettled, settleDefault]); + + useReorganizeOutcome(token, tracked?.operationId ?? null, () => { + if (!tracked) return; + if (tracked.kind === 'default') { + settleDefault(); + return; + } + onSettled(); + if (tracked.target) onLayoutSettled(tracked.folder, tracked.target); + }, { attempt: tracked?.attempt ?? 0 }); + + return { + review, + /** The operation this page started and follows to its end, or null */ + trackedOperationId: tracked?.operationId ?? null, + dialogProps: { + open: request.open, change: request.change, operationId: request.operationId, onClose, onApplied, onRetried, + }, + }; +} diff --git a/client/src/components/LibraryFolders/hooks/useSelectedFolder.ts b/client/src/components/LibraryFolders/hooks/useSelectedFolder.ts new file mode 100644 index 000000000..f345bea86 --- /dev/null +++ b/client/src/components/LibraryFolders/hooks/useSelectedFolder.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import type { LibraryFolder } from '../../../types/tvShows'; +import { LIBRARY_FOLDERS_PATH, folderFromRouteKey, folderKey, libraryFolderUrl } from '../../../utils/libraryLayouts'; + +export interface LibraryRouteState { + /** The list was opened before this detail screen, so Back can return in history */ + fromList?: boolean; + /** A folder URL that matched no folder */ + missingFolder?: string; +} + +export interface UseSelectedFolderOptions { + folders: LibraryFolder[]; + loaded: boolean; + /** A refetch is running: the list may not hold a folder created a moment ago */ + loading?: boolean; + /** The last load failed: an empty list says nothing about a folder */ + error?: string | null; + /** null until the page is measured */ + twoColumn: boolean | null; +} + +/** + * The folder the URL selects (/settings/library/:folder, ~main for the main + * folder). Two columns select the default folder for the bare URL, once, and + * never reselect on their own; list/detail pushes the detail so Back returns. + */ +export function useSelectedFolder({ folders, loaded, loading = false, error = null, twoColumn }: UseSelectedFolderOptions) { + const params = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const state = (location.state ?? null) as LibraryRouteState | null; + const routeKey = params['*'] || ''; + const requested = routeKey ? folderFromRouteKey(routeKey) : null; + const selected = requested === null ? null : folders.find((folder) => folderKey(folder.name) === folderKey(requested)) ?? null; + + useEffect(() => { + if (!loaded || loading || error) return; + if (requested !== null && !selected) { + navigate(LIBRARY_FOLDERS_PATH, { replace: true, state: { missingFolder: requested } }); + return; + } + if (requested === null && twoColumn === true) { + const fallback = folders.find((folder) => folder.isDefault) ?? folders[0]; + if (fallback) navigate(libraryFolderUrl(fallback.name), { replace: true, state }); + } + }, [loaded, loading, error, requested, selected, twoColumn, folders, navigate, state]); + + const select = useCallback((name: string) => { + if (twoColumn) navigate(libraryFolderUrl(name), { replace: true }); + else navigate(libraryFolderUrl(name), { state: { fromList: true } }); + }, [navigate, twoColumn]); + + const backToList = useCallback(() => { + if (state?.fromList) navigate(-1); + else navigate(LIBRARY_FOLDERS_PATH); + }, [navigate, state]); + + // After a delete: return to the list without leaving the deleted folder's entry behind. + const leaveFolder = useCallback(() => { + if (state?.fromList) navigate(-1); + else navigate(LIBRARY_FOLDERS_PATH, { replace: true }); + }, [navigate, state]); + + const dismissMissing = useCallback(() => { + navigate(`${location.pathname}${location.search}`, { replace: true, state: { ...state, missingFolder: undefined } }); + }, [navigate, location.pathname, location.search, state]); + + return { selected, missingName: state?.missingFolder ?? null, dismissMissing, select, backToList, leaveFolder }; +} diff --git a/client/src/components/LibraryFolders/index.tsx b/client/src/components/LibraryFolders/index.tsx new file mode 100644 index 000000000..417db7dab --- /dev/null +++ b/client/src/components/LibraryFolders/index.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '../ui'; +import { cn } from '../../lib/cn'; +import type { ReorganizeChange } from '../../types/reorganize'; +import type { LibraryFolder, LibraryLayout } from '../../types/tvShows'; +import type { MediaServerType } from '../../types/libraryCheck'; +import type { PlexLibrary } from '../../utils/plexLibraries'; +import type { ConfigState, DeploymentEnvironment, PlatformManagedState, PlexConnectionStatus, SnackbarState } from '../Configuration/types'; +import { useLibraryFolders } from '../../hooks/useLibraryFolders'; +import { useLibraryCheck } from '../../hooks/useLibraryCheck'; +import { useMediaServerStatus } from '../../hooks/useMediaServerStatus'; +import { useContainerWidth } from '../../hooks/useContainerWidth'; +import { useMediaQuery } from '../../hooks/useMediaQuery'; +import { ReorganizeDialog, useActiveReorganize } from '../shared/Reorganize'; +import { MainFolderTvDialog } from '../Configuration/sections/components/MainFolderTvDialog'; +import { SERVER_ORDER, buildAttention, serversOf } from '../../utils/libraryAttention'; +import { folderKey } from '../../utils/libraryLayouts'; +import { HEADER_HEIGHT_DESKTOP } from '../layout/navLayoutConstants'; +import { LibraryPageProvider, LibraryPageValue } from './LibraryFoldersContext'; +import { useSelectedFolder } from './hooks/useSelectedFolder'; +import { useLibraryFolderDetail } from './hooks/useLibraryFolderDetail'; +import { useDefaultFolder } from './hooks/useDefaultFolder'; +import { ReorganizeHandoffContext, useReorganizeHandoff } from './hooks/useReorganizeHandoff'; +import { useLayoutChange } from './hooks/useLayoutChange'; +import { folderLabel, otherLayout } from './folderText'; +import { AttentionStrip } from './components/AttentionStrip'; +import { CheckBanners } from './components/CheckBanners'; +import { FolderDetailScreen } from './components/FolderDetailScreen'; +import { FolderInspector } from './components/FolderInspector'; +import { FolderShelf } from './components/FolderShelf'; +import { LibraryGuide } from './components/LibraryGuide'; +import { NoTvFoldersRow } from './components/NoTvFoldersRow'; +import { PageHeader } from './components/PageHeader'; +import { AddFolderDialog } from './components/dialogs/AddFolderDialog'; +import { DeleteFolderDialog } from './components/dialogs/DeleteFolderDialog'; +import { MakeDefaultDialog } from './components/dialogs/MakeDefaultDialog'; +import { StartTvShowsDialog } from './components/dialogs/StartTvShowsDialog'; + +const TWO_COLUMN_MIN_WIDTH = 1012; +const PHONE_QUERY = '(max-width: 767px)'; +const INCLUDE: Array<'usage' | 'files'> = ['usage', 'files']; +const INSPECTOR_GAP = 16; + +export interface LibraryFoldersProps { + token: string | null; + config: ConfigState; + isPlatformManaged: PlatformManagedState; + deploymentEnvironment: DeploymentEnvironment; + plexLibraries: PlexLibrary[]; + plexConnectionStatus: PlexConnectionStatus; + setSnackbar: (state: SnackbarState) => void; +} + +/** Settings > Library folders (/settings/library, /settings/library/:folder). */ +export default function LibraryFolders({ + token, config, isPlatformManaged, deploymentEnvironment, plexLibraries, plexConnectionStatus, setSnackbar, +}: LibraryFoldersProps) { + const [measureRef, width] = useContainerWidth<HTMLDivElement>(); + const twoColumn = width === null ? null : width >= TWO_COLUMN_MIN_WIDTH; + const phone = useMediaQuery(PHONE_QUERY); + const library = useLibraryFolders(token, { include: INCLUDE }); + const checkResult = useLibraryCheck(token); + const { status: serverStatus, loading: serverStatusLoading } = useMediaServerStatus(token); + const configuredServers = useMemo(() => SERVER_ORDER.filter((type) => serverStatus[type]), [serverStatus]); + const { operation } = useActiveReorganize(token); + const { detail: mainDetail } = useLibraryFolderDetail(token, ''); + const selection = useSelectedFolder({ + folders: library.folders, loaded: library.loaded, loading: library.loading, error: library.error, twoColumn, + }); + const { readBackDefault } = useDefaultFolder(token); + const [addLayout, setAddLayout] = useState<LibraryLayout | null>(null); + const [startTvOpen, setStartTvOpen] = useState(false); + const [mainTvOpen, setMainTvOpen] = useState(false); + const [makeDefaultFolder, setMakeDefaultFolder] = useState<LibraryFolder | null>(null); + const [deleteFolder, setDeleteFolder] = useState<LibraryFolder | null>(null); + const [focusTarget, setFocusTarget] = useState<LibraryPageValue['focusTarget']>(null); + const [focusRow, setFocusRow] = useState<string | null>(null); + const [deletedFocus, setDeletedFocus] = useState<LibraryFolder | null>(null); + const shelfHeadings = useRef<Record<LibraryLayout, HTMLHeadingElement | null>>({ videos: null, tv: null }); + + const { refetch: refetchFolders } = library; + const { refetch: refetchCheck } = checkResult; + const refreshAll = useCallback(() => { + void refetchFolders(); + void refetchCheck(); + }, [refetchFolders, refetchCheck]); + // useLayoutChange hands refusals to the reorganize hand-off, which reports + // moves back to useLayoutChange: a ref breaks the cycle. + const reviewRef = useRef<((change: ReorganizeChange, context: ReorganizeHandoffContext) => void) | null>(null); + const reviewLater = useCallback((change: ReorganizeChange, context: ReorganizeHandoffContext) => reviewRef.current?.(change, context), []); + const layoutChange = useLayoutChange({ token, setFolderLayout: library.setFolderLayout, review: reviewLater }); + const { settleMoving } = layoutChange; + const onLayoutSettled = useCallback((folder: string, target: LibraryLayout) => { void settleMoving(folder, target); }, [settleMoving]); + const handoff = useReorganizeHandoff(token, { + onSettled: refreshAll, onLayoutMoving: layoutChange.showMoving, onLayoutSettled, readBackDefault, + }); + reviewRef.current = handoff.review; + const { trackedOperationId } = handoff; + + // Any reorganize ending (another tab's, or one running before the page opened) changes folders; + // the hand-off refreshes for the one this page follows. + const runningOperationId = useRef<number | null | undefined>(undefined); + useEffect(() => { + if (operation) { + runningOperationId.current = operation.id; + return; + } + if (runningOperationId.current === undefined) return; + const ended = runningOperationId.current; + runningOperationId.current = undefined; + if (ended === null || ended !== trackedOperationId) refreshAll(); + }, [operation, trackedOperationId, refreshAll]); + + const notify = useCallback((message: string, severity: SnackbarState['severity'] = 'success') => ( + setSnackbar({ open: true, message, severity }) + ), [setSnackbar]); + const { data: checkData, loading: checkLoading, error: checkError, lastCheckedAt, applyPlexMapping } = checkResult; + const check = useMemo(() => ({ + data: checkData, loading: checkLoading, error: checkError, lastCheckedAt, refetch: refetchCheck, applyPlexMapping, + }), [checkData, checkLoading, checkError, lastCheckedAt, refetchCheck, applyPlexMapping]); + const servers = useMemo(() => serversOf(check, configuredServers), [check, configuredServers]); + const movingFolders = useMemo(() => { + const change = operation?.change; + if (!change) return []; + if (change.type === 'folderLayout') return [folderKey(change.folder)]; + if (change.type === 'defaultSubfolder') return [folderKey(change.value), folderKey(change.previousValue ?? '')]; + return []; + }, [operation]); + + const { select } = selection; + const jumpTo = useCallback((folder: string, serverType?: MediaServerType) => { + setFocusTarget({ folder, serverType }); + select(folder); + }, [select]); + const clearFocusTarget = useCallback(() => setFocusTarget(null), []); + const openStartTv = useCallback(() => setStartTvOpen(true), []); + const openMainFolderTv = useCallback(() => setMainTvOpen(true), []); + const clearFocusRow = useCallback(() => setFocusRow(null), []); + + const value: LibraryPageValue = { + token, config, isPlatformManaged, timeZone: deploymentEnvironment.timezone ?? null, phone, twoColumn: twoColumn === true, + folders: library.folders, foldersLoaded: library.loaded, mainDetail, check, configuredServers, + serversKnown: !serverStatusLoading || checkData !== null, servers, plexLibraries, plexConnectionStatus, + reorganizing: Boolean(operation), movingFolders, + layoutResult: layoutChange.result, busyLayoutFolder: layoutChange.busyFolder, changeLayout: layoutChange.changeLayout, + selectFolder: select, + openAddFolder: setAddLayout, + openStartTv, + openMainFolderTv, + openMakeDefault: setMakeDefaultFolder, + openDelete: setDeleteFolder, + reviewChange: handoff.review, + jumpTo, + focusTarget, + clearFocusTarget, + notify, + }; + + const attention = buildAttention(library.folders, check, configuredServers); + const tvFolders = library.folders.filter((folder) => folder.layout === 'tv'); + const videoFolders = library.folders.filter((folder) => folder.layout === 'videos'); + const main = library.folders.find((folder) => !folder.name); + const shelfProps = { + selectedName: selection.selected?.name ?? null, + focusName: focusRow, + onFocused: clearFocusRow, + }; + // Keyed so the Video folders shelf keeps its own DOM and state when the TV shelf comes or goes. + const videoShelf = ( + <FolderShelf key="videos" layout="videos" folders={videoFolders} {...shelfProps} + headingRef={(element) => { shelfHeadings.current.videos = element; }} /> + ); + const shelves = tvFolders.length > 0 ? ( + <> + <FolderShelf key="tv" layout="tv" folders={tvFolders} {...shelfProps} headingRef={(element) => { shelfHeadings.current.tv = element; }} /> + {videoShelf} + </> + ) : ( + <>{videoShelf}<NoTvFoldersRow key="no-tv" /></> + ); + const body = library.error ? ( + <div role="alert" className="flex items-center gap-3 rounded-ui border border-destructive bg-card px-3 py-2.5 text-[13px]"> + <span className="flex-1">{library.error}</span> + <Button variant="text" onClick={() => { void library.refetch(); }} className={phone ? 'min-h-[44px]' : undefined}>Retry</Button> + </div> + ) : !library.loaded ? ( + <div aria-busy="true" className="flex flex-col gap-4"> + {[0, 1].map((key) => <div key={key} className="h-32 animate-pulse rounded-ui border border-border bg-card" />)} + </div> + ) : shelves; + + const back = () => { + setFocusRow(selection.selected?.name ?? null); + selection.backToList(); + }; + const onDeleted = (folder: LibraryFolder) => { + setDeleteFolder(null); + notify(`Deleted ${folderLabel(folder.name)}`); + setDeletedFocus(folder); + selection.leaveFolder(); + }; + const selectedKey = selection.selected ? folderKey(selection.selected.name) || '~main' : null; + const detailKey = twoColumn === false ? selectedKey : null; + const shelvesShown = twoColumn !== null && detailKey === null && library.loaded && !library.error; + + // After a delete, focus the heading of the shelf the folder was on (UI 10) once the + // refetched list no longer holds it; deleting the last TV folder removes that shelf. + useEffect(() => { + if (!deletedFocus) return; + if (library.error) { + setDeletedFocus(null); + return; + } + if (!shelvesShown || library.folders.some((folder) => folderKey(folder.name) === folderKey(deletedFocus.name))) return; + (shelfHeadings.current[deletedFocus.layout] ?? shelfHeadings.current[otherLayout(deletedFocus.layout)])?.focus(); + setDeletedFocus(null); + }, [deletedFocus, shelvesShown, library.folders, library.error]); + + // A detail screen opens at the top of the window, so its back bar is in view. + useEffect(() => { + if (detailKey !== null) window.scrollTo(0, 0); + }, [detailKey]); + + return ( + <LibraryPageProvider value={value}> + <div className={cn('mx-auto w-full max-w-[1088px]', phone ? 'px-4 pb-6 pt-4' : 'px-6 pb-16 pt-5')}> + {/* No padding here: the two-column breakpoint measures the content box. */} + <div ref={measureRef}> + {detailKey !== null && selection.selected ? ( + <FolderDetailScreen folder={selection.selected} onBack={back} /> + ) : ( + <div className={cn('flex flex-col gap-4', twoColumn === false && 'mx-auto max-w-[720px]')}> + <PageHeader /> + <CheckBanners missingName={selection.missingName} onDismissMissing={selection.dismissMissing} /> + <LibraryGuide /> + {check.data && <AttentionStrip items={attention} />} + {twoColumn ? ( + <div className="grid grid-cols-[minmax(560px,1fr)_432px] items-start gap-5"> + <div className="flex flex-col gap-4">{body}</div> + {/* Keyed by folder: the aside is the scroll container, so a new folder starts at the top. */} + <aside key={selectedKey ?? '~none'} id="library-inspector" aria-labelledby="insp-title" + className="sticky overflow-y-auto overscroll-contain rounded-ui border border-border bg-card" + style={{ top: HEADER_HEIGHT_DESKTOP + INSPECTOR_GAP, maxHeight: `calc(100vh - ${HEADER_HEIGHT_DESKTOP + 2 * INSPECTOR_GAP}px)` }}> + {selection.selected ? ( + <FolderInspector folder={selection.selected} /> + ) : ( + <div aria-busy="true" className="h-64 animate-pulse" /> + )} + </aside> + </div> + ) : ( + twoColumn === false && <div className="flex flex-col gap-4">{body}</div> + )} + </div> + )} + </div> + </div> + {addLayout && ( + <AddFolderDialog open initialLayout={addLayout} onClose={() => setAddLayout(null)} + onCreated={(name) => select(name)} + onNeedsReview={(change, name) => { + setAddLayout(null); + select(name); + handoff.review(change, { kind: 'create', folder: name, target: 'tv' }); + }} /> + )} + {startTvOpen && <StartTvShowsDialog open onClose={() => setStartTvOpen(false)} />} + {makeDefaultFolder && <MakeDefaultDialog folder={makeDefaultFolder} onClose={() => setMakeDefaultFolder(null)} />} + {deleteFolder && <DeleteFolderDialog folder={deleteFolder} onClose={() => setDeleteFolder(null)} onDeleted={onDeleted} />} + <MainFolderTvDialog open={mainTvOpen} confirmLabel={main?.layoutChangeNeedsReview ? 'Review the move' : 'Use for TV shows'} + busy={layoutChange.busyFolder === ''} onCancel={() => setMainTvOpen(false)} + onConfirm={() => { setMainTvOpen(false); void layoutChange.changeLayout('', 'tv'); }} /> + <ReorganizeDialog token={token} {...handoff.dialogProps} /> + </LibraryPageProvider> + ); +} diff --git a/client/src/components/LibraryFolders/layoutPreview.ts b/client/src/components/LibraryFolders/layoutPreview.ts new file mode 100644 index 000000000..42e56e2e8 --- /dev/null +++ b/client/src/components/LibraryFolders/layoutPreview.ts @@ -0,0 +1,113 @@ +import type { LibraryFolderDetail, LibraryLayout } from '../../types/tvShows'; + +/** The layout preview's example tree and reads-as line (UI 5.7.3), computed without yt-dlp. */ + +const LAST = '\u2514\u2500 '; +const BRANCH = '\u251c\u2500 '; +const INDENT = ' '; + +export interface PreviewExample { + channelName: string; + title: string; + youtubeId: string; + uploadedAt: Date; + uploadedAtSource: 'timestamp' | 'upload_date'; + relativePath: string; + /** A real download (false: the sample) */ + real: boolean; +} + +export const SAMPLE_EXAMPLE: PreviewExample = { + channelName: 'Channel Name', title: 'Video Title', youtubeId: 'aBcD3fGh1jK', + uploadedAt: new Date('2026-09-28T15:30:00.000Z'), uploadedAtSource: 'timestamp', relativePath: '', real: false, +}; + +export function exampleFrom(example: LibraryFolderDetail['example']): PreviewExample { + if (!example) return SAMPLE_EXAMPLE; + return { + channelName: example.channelName || SAMPLE_EXAMPLE.channelName, + title: example.title || SAMPLE_EXAMPLE.title, + youtubeId: example.youtubeId, + uploadedAt: example.uploadedAt ? new Date(example.uploadedAt) : SAMPLE_EXAMPLE.uploadedAt, + uploadedAtSource: example.uploadedAtSource === 'upload_date' ? 'upload_date' : 'timestamp', + relativePath: example.relativePath, + real: true, + }; +} + +const pad = (value: number): string => String(value).padStart(2, '0'); + +export function seasonOf(date: Date): string { + return String(date.getUTCFullYear()); +} + +/** MMDDHHMM in UTC, the date numbering's episode number. */ +export function episodeOf(date: Date): string { + return `${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}`; +} + +export interface PreviewRow { + prefix: string; + name: string; + note?: string; + kind: 'dir' | 'media' | 'file'; +} + +// The example's real path, when it has the shape of the layout (a folder can +// hold flat and per-video downloads side by side). +function realSegments(example: PreviewExample, layout: LibraryLayout): string[] | null { + if (!example.real || !example.relativePath) return null; + const segments = example.relativePath.split('/').filter(Boolean); + if (layout === 'tv') return segments.length === 3 ? segments : null; + return segments.length === 2 || segments.length === 3 ? segments : null; +} + +export function previewRows({ folderName, baseName, layout, currentLayout, example, flat }: { + folderName: string; baseName: string; layout: LibraryLayout; currentLayout: LibraryLayout; example: PreviewExample; flat: boolean; +}): PreviewRow[] { + const rows: PreviewRow[] = []; + const root = folderName ? `__${folderName}/` : `${baseName}/`; + rows.push({ prefix: '', name: root, note: folderName ? 'library folder' : 'downloads folder', kind: 'dir' }); + const real = layout === currentLayout ? realSegments(example, layout) : null; + if (layout === 'tv') { + if (!folderName) rows.push({ prefix: BRANCH, name: '.plexignore', note: 'Plex skips __subfolders', kind: 'file' }); + const season = seasonOf(example.uploadedAt); + const [show, seasonDir, file] = real ?? [ + example.channelName, `Season ${season}`, + `S${season}E${episodeOf(example.uploadedAt)} - ${example.title} [${example.youtubeId}].mp4`, + ]; + rows.push({ prefix: LAST, name: `${show}/`, note: 'the show', kind: 'dir' }); + rows.push({ prefix: `${INDENT}${BRANCH}`, name: 'tvshow.nfo, poster.jpg', kind: 'file' }); + rows.push({ prefix: `${INDENT}${LAST}`, name: `${seasonDir}/`, note: 'upload year', kind: 'dir' }); + rows.push({ prefix: `${INDENT}${INDENT}${LAST}`, name: file, note: 'episode number = upload time, plus an .nfo', kind: 'media' }); + return rows; + } + const computed = flat + ? [example.channelName, `${example.channelName} - ${example.title} [${example.youtubeId}].mp4`] + : [example.channelName, `${example.channelName} - ${example.title} - ${example.youtubeId}`, + `${example.channelName} - ${example.title} [${example.youtubeId}].mp4`]; + const segments = real ?? computed; + const channel = segments[0]; + const file = segments[segments.length - 1]; + const videoFolder = segments.length === 3 ? segments[1] : null; + rows.push({ prefix: LAST, name: `${channel}/`, note: 'the channel', kind: 'dir' }); + if (videoFolder) { + rows.push({ prefix: `${INDENT}${BRANCH}`, name: 'poster.jpg', kind: 'file' }); + rows.push({ prefix: `${INDENT}${LAST}`, name: `${videoFolder}/`, note: 'one folder per video', kind: 'dir' }); + rows.push({ prefix: `${INDENT}${INDENT}${LAST}`, name: file, note: 'the movie, plus an .nfo', kind: 'media' }); + } else { + rows.push({ prefix: `${INDENT}${BRANCH}`, name: 'poster.jpg', kind: 'file' }); + rows.push({ prefix: `${INDENT}${LAST}`, name: file, note: 'the movie, plus an .nfo', kind: 'media' }); + } + return rows; +} + +export function readsAs(layout: LibraryLayout, example: PreviewExample): string { + if (layout === 'videos') return `Shows up as one movie per video, titled "${example.title}".`; + const date = example.uploadedAt; + const day = date.toLocaleString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); + const uploaded = example.uploadedAtSource === 'upload_date' + ? `uploaded ${day} (no time recorded, read as 00:00 UTC)` + : `uploaded ${day}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} UTC`; + return `Shows up as the show ${example.channelName}, Season ${seasonOf(date)}, episode ${episodeOf(date)}: ${uploaded}.`; +} diff --git a/client/src/components/LibraryFolders/libraryTypes.ts b/client/src/components/LibraryFolders/libraryTypes.ts new file mode 100644 index 000000000..4086d1044 --- /dev/null +++ b/client/src/components/LibraryFolders/libraryTypes.ts @@ -0,0 +1,85 @@ +import type { LibraryLayout } from '../../types/tvShows'; +import type { MediaServerType } from '../../types/libraryCheck'; + +/** Library type names and setup rows per server and layout (UI 5.7.2.4), shared by the inspector, Add folder and the panel. */ + +export type SetupServer = MediaServerType | 'kodi'; + +export const SETUP_SERVER_NAMES: Record<SetupServer, string> = { plex: 'Plex', jellyfin: 'Jellyfin', emby: 'Emby', kodi: 'Kodi' }; + +export const LIBRARY_TYPE_NAMES: Record<SetupServer, Record<LibraryLayout, string>> = { + plex: { videos: 'Other Videos', tv: 'TV Shows' }, + jellyfin: { videos: 'Movies', tv: 'Shows' }, + emby: { videos: 'Movies', tv: 'TV shows' }, + kodi: { videos: 'Movies source', tv: 'TV shows source' }, +}; + +/** The library type text when no server is connected (UI 5.6 column header). */ +export const GENERIC_LIBRARY_TYPES: Record<LibraryLayout, string> = { + videos: 'Plex Other Videos, Jellyfin or Emby Movies, Kodi Movies source', + tv: 'Plex TV Shows, Jellyfin Shows, Emby TV shows, Kodi TV shows source', +}; + +export function libraryTypeName(server: SetupServer, layout: LibraryLayout): string { + return LIBRARY_TYPE_NAMES[server][layout]; +} + +/** "Plex Other Videos" */ +export function serverLibraryType(server: SetupServer, layout: LibraryLayout): string { + return `${SETUP_SERVER_NAMES[server]} ${libraryTypeName(server, layout)}`; +} + +/** A folder's path under a server's downloads folder, keeping the server's separator. */ +export function joinServerPath(base: string, folder: string): string { + const separator = base.includes('\\') && !base.includes('/') ? '\\' : '/'; + const trimmed = base.length > 1 ? base.replace(/[\\/]+$/, '') : base; + if (!folder) return trimmed; + return trimmed.endsWith(separator) ? `${trimmed}__${folder}` : `${trimmed}${separator}__${folder}`; +} + +export interface ServerPath { + text: string; + /** A real path (copy button), not a description */ + copyable: boolean; +} + +export function folderServerPath(folder: string, downloadsPath: string | null | undefined, serverName: string): ServerPath { + if (downloadsPath) return { text: joinServerPath(downloadsPath, folder), copyable: true }; + return { + text: folder ? `__${folder} in your downloads folder, as ${serverName} sees it` : `Your downloads folder, as ${serverName} sees it`, + copyable: false, + }; +} + +export interface SetupRow { + key: string; + value: string; + /** The Folder row: its path, with a copy button when real */ + path?: ServerPath; +} + +export function setupRows(server: SetupServer, layout: LibraryLayout, path: ServerPath): SetupRow[] { + const folder: SetupRow = { key: `Folder (as ${SETUP_SERVER_NAMES[server]} sees it)`, value: path.text, path }; + const row = (key: string, value: string): SetupRow => ({ key, value }); + switch (`${server}:${layout}`) { + case 'plex:tv': + return [row('Library type', 'TV Shows'), folder, row('Scanner', 'Plex TV Series'), + row('Agent', 'Plex NFO Series or Personal Media'), row('Advanced', 'Use local assets: on')]; + case 'plex:videos': + return [row('Library type', 'Other Videos'), folder, row('Agent', 'Plex Personal Media')]; + case 'jellyfin:tv': + return [row('Content type', 'Shows'), folder, row('Metadata savers', 'Nfo: off'), row('Downloaders', 'All off'), + row('Image fetchers', 'All off')]; + case 'jellyfin:videos': + return [row('Content type', 'Movies'), folder, row('Downloaders', 'All off'), row('Image fetchers', 'TheMovieDb and OMDb off')]; + case 'emby:tv': + return [row('Content type', 'TV shows'), folder, row('Metadata readers', 'Nfo: on'), row('Metadata savers', 'Nfo: off'), + row('Downloaders and image fetchers', 'All off')]; + case 'emby:videos': + return [row('Content type', 'Movies'), folder, row('Downloaders', 'All off')]; + case 'kodi:tv': + return [row('Source type', 'TV shows'), folder, row('Information provider', 'Local information only')]; + default: + return [row('Source type', 'Movies'), folder]; + } +} diff --git a/client/src/components/LibraryFolders/mediaServerText.ts b/client/src/components/LibraryFolders/mediaServerText.ts new file mode 100644 index 000000000..4653ceb22 --- /dev/null +++ b/client/src/components/LibraryFolders/mediaServerText.ts @@ -0,0 +1,220 @@ +import type { LibraryFolder, LibraryLayout } from '../../types/tvShows'; +import type { LibraryCheckLibrary, LibraryCheckResponse, LibraryCheckServerReport, MediaServerType } from '../../types/libraryCheck'; +import { + CheckStatus, SERVER_NAMES, SERVER_ORDER, ServerRef, ServerStatus, folderState, holdingLibraries, isOverlapIssue, joinNames, reportFor, timeAgo, +} from '../../utils/libraryAttention'; +import { countOf, folderLabel, layoutName, SEP, units } from './folderText'; +import { LIBRARY_TYPE_NAMES, libraryTypeName } from './libraryTypes'; + +/** Inspector Media servers copy (UI 5.7.2) and the layout preview's afterwards lines (UI 5.7.3). */ + +export const NO_SERVERS_INTRO = "No media server is connected, so Youtarr can't check your libraries. Libraries you set up " + + 'by hand keep working; connecting Plex, Jellyfin or Emby lets Youtarr check them.'; +const MAIN_TV_INTRO = 'Everything here is saved as TV shows: one TV library per server, pointed at the downloads folder. ' + + "Youtarr's .plexignore keeps the __subfolders out of a Plex TV library here; Jellyfin and Emby show each __subfolder " + + 'as an extra show.'; +const MAIN_ACTIVE_WITH_TV = 'Channels download straight into the main folder, but you also have TV show folders. A ' + + 'Jellyfin or Emby library on the whole downloads folder keeps your TV libraries empty, and Plex shows the episodes ' + + 'twice. Give these channels a Video folder (Channel Settings > Library folder moves their videos), then point a ' + + 'library at each folder.'; +const MAIN_ACTIVE_WITH_SUBFOLDERS = 'Channels download straight into the main folder, and you also use subfolders. That ' + + 'works while every folder uses Videos: one library on the whole downloads folder shows them all. Before you add a TV ' + + 'show folder, give these channels a Video folder.'; + +export type IntroLink = { kind: 'startTv' } | { kind: 'folder'; folder: string } | { kind: 'channels' }; + +export interface IntroSpec { + text: string; + link?: IntroLink; + /** Text after a folder link */ + trailing?: string; +} + +function article(word: string): string { + return /^[aeiou]/i.test(word) ? 'an' : 'a'; +} + +/** "Plex TV Shows and Jellyfin Shows" */ +export function typesList(servers: ServerRef[], layout: LibraryLayout): string { + return joinNames(servers.map((server) => `${server.name} ${libraryTypeName(server.serverType, layout)}`)); +} + +/** "a Plex Other Videos and a Jellyfin Movies" */ +function typesWithArticles(servers: ServerRef[], layout: LibraryLayout): string { + return joinNames(servers.map((server) => { + const text = `${server.name} ${libraryTypeName(server.serverType, layout)}`; + return `${article(text)} ${text}`; + })); +} + +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + +/** Videos libraries pointed at the downloads folder itself, per server. */ +export function wholeFolderLibraries(check: LibraryCheckResponse | null): Array<{ serverName: string; library: LibraryCheckLibrary }> { + const main = reportFor(check, ''); + if (!main || !check) return []; + return main.servers.flatMap((report) => report.libraries + .filter((library) => library.relation === 'exact' && library.type === 'videos') + .slice(0, 1) + .map((library) => ({ serverName: check.servers.find((s) => s.serverType === report.serverType)?.name ?? report.serverType, library }))); +} + +function librariesPhrase(entries: Array<{ serverName: string; library: { name: string } }>): string { + const names = new Set(entries.map((entry) => entry.library.name)); + if (names.size === 1) return entries[0].library.name; + return joinNames(entries.map((entry) => `${entry.library.name} (${entry.serverName})`)); +} + +function firstTvOverlapFolder(folders: LibraryFolder[], check: LibraryCheckResponse | null): string | null { + const tv = folders.filter((folder) => folder.layout === 'tv' && folder.name); + const hit = tv.find((folder) => reportFor(check, folder.name)?.servers + .some((report) => report.issues.some((issue) => isOverlapIssue(issue.code)))); + return hit ? hit.name : null; +} + +export function inspectorIntro({ folder, folders, check, servers }: { + folder: LibraryFolder; folders: LibraryFolder[]; check: LibraryCheckResponse | null; servers: ServerRef[]; +}): IntroSpec | null { + if (servers.length === 0) return { text: NO_SERVERS_INTRO }; + const state = folderState(folder); + const tvExists = folders.some((entry) => entry.layout === 'tv'); + const label = folderLabel(folder.name, true); + if (!folder.name) { + if (folder.layout === 'tv') return { text: MAIN_TV_INTRO }; + const whole = wholeFolderLibraries(check); + const count = folder.fileCount ?? 0; + const unit = units(folder.layout); + const subfolderActive = folders.some((entry) => entry.name && folderState(entry) === 'active'); + if (state === 'active' && !subfolderActive) { + return { text: `Everything downloads here. ${capitalize(typesWithArticles(servers, 'videos'))} library on the downloads folder shows it.` }; + } + if (state === 'active' && tvExists) return { text: MAIN_ACTIVE_WITH_TV, link: { kind: 'channels' } }; + if (state === 'active') return { text: MAIN_ACTIVE_WITH_SUBFOLDERS, link: { kind: 'startTv' } }; + if (whole.length > 0 && !tvExists) { + const held = count > 0 ? ` It also shows the ${countOf(count, unit.one, unit.many)} still in the main folder.` : ''; + return { text: `${librariesPhrase(whole)} shows your whole downloads folder, including every subfolder. That works while every folder uses Videos.${held}` }; + } + if (whole.length > 0) { + const target = firstTvOverlapFolder(folders, check); + const text = `${librariesPhrase(whole)} shows your whole downloads folder. Now that you have TV show folders, point it at your Video folders instead`; + return target ? { text: `${text}: see the fix on `, link: { kind: 'folder', folder: target }, trailing: '.' } : { text: `${text}.` }; + } + if (state === 'holdsVideos') { + return { + text: `No library shows the ${count > 0 ? countOf(count, unit.one, unit.many) : 'files'} still in the main folder. To watch them, give their ` + + 'channels a Video folder (Channel Settings > Library folder moves their videos), or point a library at the whole ' + + 'downloads folder while every folder uses Videos.', + }; + } + return { text: 'Nothing is saved in the main folder itself, so it needs no library of its own while you use subfolders.' }; + } + if (state === 'unused') { + return { text: `Nothing downloads here yet. Once something does, ${folder.layout === 'tv' ? 'a TV' : 'a movie-style'} library on each server should show ${label}.` }; + } + if (folder.layout === 'tv') { + return { text: `TV show folders need their own ${typesList(servers, 'tv')} library, pointed at ${label} itself. A library on a parent folder doesn't count on Jellyfin and Emby.` }; + } + const report = reportFor(check, folder.name); + const holding = (report?.servers ?? []).filter((entry) => entry.status === 'ok' || entry.status === 'warning'); + const throughWhole = holding.length > 0 && holding.every((entry) => { + const holders = holdingLibraries(entry); + return holders.length > 0 && holders.every((library) => library.relation === 'covers'); + }); + if (throughWhole) { + const entries = holding.map((entry) => ({ + serverName: check?.servers.find((s) => s.serverType === entry.serverType)?.name ?? entry.serverType, + library: holdingLibraries(entry)[0], + })); + return { text: `Shown by ${librariesPhrase(entries)}, which is pointed at the whole downloads folder.` }; + } + return null; +} + +export function libraryTypeLabel(serverType: MediaServerType, type: LibraryCheckLibrary['type']): string { + switch (type) { + case 'tv': return LIBRARY_TYPE_NAMES[serverType].tv; + case 'videos': return serverType === 'plex' ? 'Movies or Other Videos' : 'Movies'; + case 'mixed': return 'Mixed'; + case 'music': return 'Music'; + default: return 'Other'; + } +} + +/** The card header's library list: the library name, its type and "whole downloads folder", joined with the middle-dot separator. */ +export function holdersText(serverType: MediaServerType, report: LibraryCheckServerReport): string { + return holdingLibraries(report) + .map((library) => [library.name, libraryTypeLabel(serverType, library.type), library.relation === 'covers' ? 'whole downloads folder' : null] + .filter(Boolean).join(SEP)) + .join(', '); +} + +export function statusDescription(status: ServerStatus): string { + if (status.display !== 'ok' || !status.report) return `${status.name}: ${status.word}${status.word.endsWith('.') ? '' : '.'}`; + const holder = holdingLibraries(status.report)[0]; + if (!holder) return `${status.name}: OK.`; + return holder.relation === 'covers' + ? `${status.name}: OK, shown by ${holder.name} through the whole downloads folder.` + : `${status.name}: OK, ${holder.name}.`; +} + +export function issueHint(code: string, serverName: string, libraryName: string, layout: LibraryLayout): string | null { + switch (code) { + case 'plexSeriesAgent': + case 'plexLegacyAgent': + return `In Plex: edit ${libraryName}, then Advanced, then Agent.`; + case 'nfoSaver': + return `In ${serverName}: Dashboard, Libraries, ${libraryName}, Metadata savers.`; + case 'onlineFetchers': + return `In ${serverName}: Dashboard, Libraries, ${libraryName}, Metadata downloaders and Image fetchers.`; + case 'overlapTv': + return layout === 'videos' ? `Point ${libraryName} at your TV show folders only.` : null; + default: + return null; + } +} + +export function noLibraryLine(server: ServerRef, folder: LibraryFolder): string { + return `No ${server.name} ${libraryTypeName(server.serverType, folder.layout)} library holds ${folderLabel(folder.name, true)}.`; +} + +export function footerNote(folder: LibraryFolder, servers: ServerRef[]): string { + const missing = SERVER_ORDER.filter((type) => !servers.some((server) => server.serverType === type)).map((type) => SERVER_NAMES[type]); + const parts: string[] = []; + if (missing.length > 0) parts.push(`${joinNames(missing)} ${missing.length === 1 ? "isn't" : "aren't"} connected.`); + if (!folder.name) parts.push("Kodi isn't checked."); + else if (folder.layout === 'tv') parts.push(`Kodi isn't checked: add __${folder.name} as a TV shows source set to Local information only.`); + else parts.push(`Kodi isn't checked: add __${folder.name} as a Movies source.`); + return parts.join(' '); +} + +export function afterwardsLine(server: ServerRef, report: LibraryCheckServerReport | null, { target, label }: { target: LibraryLayout; label: string }): string { + const type = libraryTypeName(server.serverType, target); + if (!report || report.status === 'unreachable') return `${server.name}: couldn't be checked. It needs a ${type} library on ${label}.`; + if (report.status === 'ok') return `${server.name}: ${holdingLibraries(report)[0]?.name ?? 'its library'} already fits.`; + if (report.status === 'missing') return `${server.name}: needs a ${type} library on ${label}.`; + return `${server.name}: ${report.issues.map((issue) => issue.message).join(' ')}`; +} + +export function afterwardsWithoutServers(target: LibraryLayout): string { + return `Afterwards its media server libraries must be the ${layoutName(target)} type.`; +} + +/** The check status line (UI 4.6); a partial check never reads as full success. */ +export function checkStatusText(status: CheckStatus, servers: ServerRef[], now: number, timeZone: string | null): string { + if (status.kind === 'none' || servers.length === 0) return 'No media server connected.'; + if (status.running) return `Checking ${joinNames(servers.map((server) => server.name))}...`; + const when = status.lastCheckedAt !== null ? timeAgo(status.lastCheckedAt, now, timeZone) : null; + switch (status.kind) { + case 'full': + return `Checked ${joinNames(status.checked)}${when ? ` ${when}` : ''}`; + case 'partial': + return [`Checked ${joinNames(status.checked)}${when ? ` ${when}` : ''}.`, + ...status.unreachable.map((server) => `${server.name} couldn't be reached.`)].join(' '); + case 'unreachable': + return `Not checked: ${joinNames(status.unreachable.map((server) => server.name), 'or')} couldn't be reached.`; + case 'failed': + return status.hasEarlierResults && when ? `Last checked ${when}. The latest check failed.` : 'Not checked'; + default: + return 'Not checked'; + } +} diff --git a/client/src/components/LibraryFolders/tvSetupPaths.ts b/client/src/components/LibraryFolders/tvSetupPaths.ts new file mode 100644 index 000000000..be1ce6dcb --- /dev/null +++ b/client/src/components/LibraryFolders/tvSetupPaths.ts @@ -0,0 +1,294 @@ +import type { LibraryFolder, LibraryLayout } from '../../types/tvShows'; +import type { LibraryCheckLibrary, LibraryCheckResponse, MediaServerType } from '../../types/libraryCheck'; +import { SERVER_NAMES, SERVER_ORDER, ServerRef, folderState, reportFor } from '../../utils/libraryAttention'; +import { folderKey } from '../../utils/libraryLayouts'; +import { countOf, folderLabel } from './folderText'; +import { LIBRARY_TYPE_NAMES, ServerPath, SetupServer, folderServerPath, libraryTypeName } from './libraryTypes'; + +/** + * The Start using TV shows panel (UI 7.6): the setup detected from folder + * usage and the library check, the migration paths that apply (A: add a TV + * folder, B: the whole downloads folder as TV, C: turn a Video folder into a + * TV folder), and each path's steps in Youtarr and on each server. + */ + +export const WATCH_STATE_FIRST = 'Videos Youtarr moves get their played state and resume position back once the new ' + + 'library has scanned them (every Jellyfin and Emby user, the Plex owner).'; +/** Second sentence, from the Phase 2 verification (Task 2.0 decision rules, REBUILD_NOTE). */ +export const WATCH_STATE_REBUILD = "A library you remove and create again starts over: played state and resume positions in it aren't restored."; +export const FULL_GUIDE_URL = 'https://dialmasterorg.github.io/Youtarr/docs/usage-guide/#move-an-existing-setup-to-tv-shows'; + +export type SetupKind = 'whole' | 'perFolder' | 'none' | 'unchecked'; + +export interface ServerSetup extends ServerRef { + kind: SetupKind; + whole: LibraryCheckLibrary | null; + libraries: string[]; + downloadsPath: string | null; + /** folderKey -> the library pointed at that folder itself on this server */ + folderLibraries: Record<string, string>; +} + +export interface DetectedSetup { + main: LibraryFolder | null; + /** Videos subfolders that are Active or hold videos */ + videoFolders: LibraryFolder[]; + /** Every Videos subfolder, active and holding first (path C's select) */ + videoSubfolders: LibraryFolder[]; + tvFolders: LibraryFolder[]; + mainChannels: number; + hasVideosContent: boolean; + servers: ServerSetup[]; +} + +const inUse = (folder: LibraryFolder): boolean => ['active', 'holdsVideos'].includes(folderState(folder)); +const byLabel = (a: LibraryFolder, b: LibraryFolder): number => a.name.localeCompare(b.name); + +export function detectSetup(folders: LibraryFolder[], check: LibraryCheckResponse | null, servers: ServerRef[]): DetectedSetup { + const main = folders.find((folder) => !folder.name) ?? null; + const videoSubfolders = folders.filter((folder) => folder.name && folder.layout === 'videos'); + const mainReport = reportFor(check, ''); + return { + main, + videoFolders: videoSubfolders.filter(inUse).sort(byLabel), + videoSubfolders: [...videoSubfolders.filter(inUse).sort(byLabel), ...videoSubfolders.filter((f) => !inUse(f)).sort(byLabel)], + tvFolders: folders.filter((folder) => folder.layout === 'tv').sort(byLabel), + mainChannels: main && main.layout === 'videos' ? main.channels : 0, + hasVideosContent: folders.some((folder) => folder.layout === 'videos' && inUse(folder)), + servers: servers.map((server): ServerSetup => { + const checkServer = check?.servers.find((entry) => entry.serverType === server.serverType); + const folderLibraries: Record<string, string> = {}; + for (const report of check?.folders ?? []) { + const exact = report.servers.find((entry) => entry.serverType === server.serverType) + ?.libraries.find((library) => library.relation === 'exact'); + if (exact) folderLibraries[folderKey(report.name)] = exact.name; + } + const base = { ...server, downloadsPath: checkServer?.downloadsPath ?? null, folderLibraries }; + const mainEntry = mainReport?.servers.find((entry) => entry.serverType === server.serverType); + if (!checkServer || !checkServer.reachable || !mainEntry) return { ...base, kind: 'unchecked', whole: null, libraries: [] }; + const whole = mainEntry.libraries.find((library) => library.relation === 'exact' && library.type === 'videos') ?? null; + if (whole) return { ...base, kind: 'whole', whole, libraries: [whole.name] }; + const libraries = [...new Set(Object.values(folderLibraries))]; + return { ...base, kind: libraries.length > 0 ? 'perFolder' : 'none', whole: null, libraries }; + }), + }; +} + +export function setupLines(setup: DetectedSetup): string[] { + const lines: string[] = []; + if (setup.videoFolders.length === 0 && setup.main && folderState(setup.main) === 'active') { + lines.push('Everything downloads into the main folder.'); + } else if (setup.videoFolders.length > 0) { + lines.push(`${countOf(setup.videoFolders.length, 'Video folder', 'Video folders')} in use: ${setup.videoFolders.map((f) => folderLabel(f.name)).join(', ')}.`); + } + if (setup.tvFolders.length > 0) { + lines.push(`${countOf(setup.tvFolders.length, 'TV show folder', 'TV show folders')}: ${setup.tvFolders.map((f) => folderLabel(f.name)).join(', ')}.`); + } + if (setup.mainChannels > 0) { + lines.push(`${countOf(setup.mainChannels, 'channel downloads', 'channels download')} straight into the main folder.`); + } + if (setup.servers.length === 0) { + lines.push("No media server is connected, so Youtarr can't see your libraries. The steps cover each kind of setup."); + } + for (const server of setup.servers) { + if (server.kind === 'whole' && server.whole) { + lines.push(`${server.name}: ${server.whole.name} (${libraryTypeName(server.serverType, 'videos')}) shows your whole downloads folder.`); + } else if (server.kind === 'perFolder') { + lines.push(`${server.name}: a library per folder (${server.libraries.join(', ')}).`); + } else if (server.kind === 'none') { + lines.push(`${server.name}: no library found for your folders.`); + } else { + lines.push(`${server.name}: not checked.`); + } + } + return lines; +} + +export type PathKey = 'A' | 'B' | 'C'; + +export interface PathOption { + key: PathKey; + title: string; + description: string; + recommended: boolean; + disabledReason: string | null; + note: string | null; +} + +export function pathOptions(setup: DetectedSetup, servers: ServerRef[]): PathOption[] { + const options: PathOption[] = [{ + key: 'A', + title: 'Add a TV show folder next to your Video folders', + description: 'Your Video folders stay as they are. The new folder gets its own TV library.', + recommended: setup.hasVideosContent, + disabledReason: null, + note: null, + }]; + if (!setup.main || setup.main.layout === 'videos') { + const onlyPlex = servers.length === 1 && servers[0].serverType === 'plex'; + const blocked = setup.videoFolders.length > 0 && !onlyPlex; + options.push({ + key: 'B', + title: 'Make the whole downloads folder TV shows', + description: 'Every channel becomes a show; one TV library shows the downloads folder.', + recommended: false, + disabledReason: blocked + ? `You use Video folders (${setup.videoFolders.map((f) => folderLabel(f.name)).join(', ')}): Jellyfin and Emby would show each one as an extra show.` + : null, + note: setup.videoFolders.length > 0 && onlyPlex ? "Plex skips the __subfolders; Jellyfin and Emby wouldn't." : null, + }); + } + if (setup.videoSubfolders.length > 0) { + options.push({ + key: 'C', + title: 'Turn a Video folder into a TV show folder', + description: 'Its channels become shows and their videos move; you review every move first.', + recommended: false, + disabledReason: null, + note: null, + }); + } + return options; +} + +export type YoutarrAction = { kind: 'addTvFolder' } | { kind: 'mainFolderTv' } | { kind: 'openFolder'; folder: string }; + +export interface YoutarrStep { + text: string; + action?: YoutarrAction; + actionLabel?: string; + /** List the channels saved in the main folder under this step */ + channelLinks?: boolean; +} + +export interface ServerStep { + text: string; + paths?: ServerPath[]; + after?: string; + /** Show the new library's settings (behind a disclosure) */ + setup?: { layout: LibraryLayout; path: ServerPath }; +} + +export interface ServerStepGroup { + serverType: SetupServer; + name: string; + steps: ServerStep[]; +} + +export interface PathSteps { + youtarr: YoutarrStep[]; + servers: ServerStepGroup[]; +} + +function groupsFor(setup: DetectedSetup): ServerSetup[] { + if (setup.servers.length > 0) return setup.servers; + return SERVER_ORDER.map((serverType: MediaServerType) => ({ + serverType, name: SERVER_NAMES[serverType], kind: 'unchecked' as const, whole: null, libraries: [], downloadsPath: null, + folderLibraries: {}, + })); +} + +const newTvFolderPath = (server: ServerSetup): ServerPath => ({ text: `the new TV show folder, as ${server.name} sees it`, copyable: false }); + +export function pathSteps(key: PathKey, setup: DetectedSetup, { folder }: { folder?: string }): PathSteps { + const tvType = (server: ServerSetup): string => LIBRARY_TYPE_NAMES[server.serverType].tv; + const movieType = (server: ServerSetup): string => LIBRARY_TYPE_NAMES[server.serverType].videos; + const anyWhole = setup.servers.some((server) => server.kind === 'whole'); + if (key === 'A') { + const youtarr: YoutarrStep[] = []; + if (setup.mainChannels > 0 && anyWhole) { + youtarr.push({ + text: `Give the ${countOf(setup.mainChannels, 'channel', 'channels')} in the main folder a Video folder (Channel Settings > Library folder moves their videos).`, + channelLinks: true, + }); + } + youtarr.push({ text: 'Add a TV show folder.', action: { kind: 'addTvFolder' }, actionLabel: 'Add TV folder' }); + youtarr.push({ + text: 'Switch the channels you want as shows to it: Channel Settings > Library folder, or TV Show > Show this channel as TV show. Each switch shows its moves first.', + }); + return { + youtarr, + servers: groupsFor(setup).map((server) => { + const addTv: ServerStep = { + text: `Add a ${tvType(server)} library pointed at the new TV show folder only.`, + setup: { layout: 'tv', path: newTvFolderPath(server) }, + }; + if (server.kind === 'whole' && server.whole) { + const paths = setup.videoFolders.map((videoFolder) => folderServerPath(videoFolder.name, server.downloadsPath, server.name)); + return { + serverType: server.serverType, name: server.name, steps: [ + paths.length > 0 + ? { text: `Edit ${server.whole.name}: remove ${server.whole.location} and add your Video folders:`, paths, after: 'It keeps its type.' } + : { text: `Edit ${server.whole.name}: remove ${server.whole.location} and add your Video folders instead. It keeps its type.` }, + addTv, + ], + }; + } + const steps = [addTv]; + if (server.kind === 'none') steps.push({ text: `Your Video folders need a ${movieType(server)} library too.` }); + return { serverType: server.serverType, name: server.name, steps }; + }), + }; + } + if (key === 'B') { + return { + youtarr: [{ + text: 'Use the main folder for TV shows. You confirm, then review every move.', + action: { kind: 'mainFolderTv' }, actionLabel: 'Use the main folder for TV shows', + }], + servers: groupsFor(setup).map((server) => { + const path = folderServerPath('', server.downloadsPath, server.name); + return { + serverType: server.serverType, name: server.name, steps: [server.kind === 'whole' && server.whole + ? { + text: `${server.name} can't change a library's type: remove ${server.whole.name} and create a ${tvType(server)} library pointed at ${server.whole.location}.`, + setup: { layout: 'tv', path: { text: server.whole.location, copyable: true } }, + } + : { text: `Create a ${tvType(server)} library pointed at the downloads folder.`, setup: { layout: 'tv', path } }], + }; + }), + }; + } + const chosen = folder ?? setup.videoSubfolders[0]?.name ?? ''; + return { + youtarr: [{ + text: `Open ${folderLabel(chosen)} and choose Move to TV shows. You review every move first.`, + action: { kind: 'openFolder', folder: chosen }, actionLabel: `Open ${folderLabel(chosen)}`, + }], + servers: groupsFor(setup).map((server) => { + const folderPath = folderServerPath(chosen, server.downloadsPath, server.name); + const own = server.folderLibraries[folderKey(chosen)] ?? null; + if (server.kind === 'whole' && server.whole) { + const others = setup.videoFolders.filter((entry) => folderKey(entry.name) !== folderKey(chosen)) + .map((entry) => folderServerPath(entry.name, server.downloadsPath, server.name)); + const addTv = `Then add a ${tvType(server)} library pointed at ${folderPath.text} only.`; + return { + serverType: server.serverType, name: server.name, steps: [others.length > 0 + ? { + text: `Edit ${server.whole.name}: remove ${server.whole.location} and add your other Video folders:`, + paths: others, + after: addTv, + setup: { layout: 'tv', path: folderPath }, + } + : { + text: `Remove ${server.whole.name}: no other Video folder is in use.`, + after: addTv, + setup: { layout: 'tv', path: folderPath }, + }], + }; + } + if (own) { + return { + serverType: server.serverType, name: server.name, steps: [{ + text: `${server.name} can't change a library's type: remove ${own} and create a ${tvType(server)} library pointed at ${folderPath.text}.`, + setup: { layout: 'tv', path: folderPath }, + }], + }; + } + return { + serverType: server.serverType, name: server.name, + steps: [{ text: `Create a ${tvType(server)} library pointed at ${folderPath.text}.`, setup: { layout: 'tv', path: folderPath } }], + }; + }), + }; +} diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 5581be104..395631b94 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -45,7 +45,9 @@ import { ConfigState, SnackbarState } from '../Configuration/types'; import { validateConfig } from '../Configuration/utils/configValidation'; import { FILENAME_PRESETS } from '../../utils/filenameTemplate/presets'; import { validatePrefix } from '../../utils/filenameTemplate/validate'; -import { SETTINGS_PAGES, SettingsIndex } from './SettingsIndex'; +import { SettingsIndex } from './SettingsIndex'; +import { settingsHeading } from './settingsHeading'; +import LibraryFolders from '../LibraryFolders'; import { MaintenanceSection } from './MaintenanceSection'; import { SchedulingSection } from '../Configuration/sections/SchedulingSection'; import { LoggingSection } from '../Configuration/sections/LoggingSection'; @@ -315,12 +317,7 @@ export function Settings({ token }: SettingsProps) { setHasUnsavedChanges(changed); }, [config, initialConfig]); - const pageTitle = useMemo(() => { - if (location.pathname === '/settings') return 'Settings'; - const suffix = location.pathname.replace('/settings/', ''); - const page = SETTINGS_PAGES.find((entry) => entry.key === suffix); - return `Settings / ${page?.title || suffix}`; - }, [location.pathname]); + const pageTitle = useMemo(() => settingsHeading(location.pathname), [location.pathname]); return ( <div> @@ -347,11 +344,13 @@ export function Settings({ token }: SettingsProps) { onClose={() => setChannelApplyTarget(null)} /> - <div style={{ marginBottom: 16 }}> - <Typography variant="h5" style={{ fontWeight: 800 }}> - {pageTitle} - </Typography> - </div> + {pageTitle !== null && ( + <div style={{ marginBottom: 16 }}> + <Typography variant="h5" style={{ fontWeight: 800 }}> + {pageTitle} + </Typography> + </div> + )} {isLoading ? ( <ConfigurationSkeleton compact /> @@ -373,6 +372,20 @@ export function Settings({ token }: SettingsProps) { /> } /> + <Route + path="library/*" + element={ + <LibraryFolders + token={token} + config={config} + isPlatformManaged={isPlatformManaged} + deploymentEnvironment={deploymentEnvironment} + plexLibraries={plexLibraries} + plexConnectionStatus={plexConnectionStatus} + setSnackbar={setSnackbar} + /> + } + /> <Route path="scheduling" element={<SchedulingSection diff --git a/client/src/components/Settings/SettingsIndex.tsx b/client/src/components/Settings/SettingsIndex.tsx index 1557a93ea..e721be685 100644 --- a/client/src/components/Settings/SettingsIndex.tsx +++ b/client/src/components/Settings/SettingsIndex.tsx @@ -4,9 +4,18 @@ import { Link as RouterLink } from 'react-router-dom'; const SETTINGS_CARD_CONTENT_HEIGHT = 72; -export const SETTINGS_PAGES = [ +export interface SettingsPage { + key: string; + title: string; + description: string; + /** Shorter label for the nav sub-item and phone tab strip */ + navLabel?: string; +} + +export const SETTINGS_PAGES: SettingsPage[] = [ { key: 'scheduling', title: 'Scheduling', description: 'Choose when automatic downloads and maintenance tasks run.' }, { key: 'core', title: 'Core', description: 'Downloads folder, quality, defaults, and core behavior.' }, + { key: 'library', title: 'Library folders', navLabel: 'Library', description: 'Folders, their Videos or TV shows layout, and the media server library for each.' }, { key: 'downloading', title: 'YT-DLP', description: 'yt-dlp backend settings for downloads and reliability.' }, { key: 'api-keys', title: 'API Keys', description: 'API key settings and rate limits.' }, { key: 'appearance', title: 'Appearance', description: 'Theme, animations, and visual preferences.' }, diff --git a/client/src/components/Settings/__tests__/settingsHeading.test.ts b/client/src/components/Settings/__tests__/settingsHeading.test.ts new file mode 100644 index 000000000..45b9d1e3e --- /dev/null +++ b/client/src/components/Settings/__tests__/settingsHeading.test.ts @@ -0,0 +1,14 @@ +import { settingsHeading } from '../settingsHeading'; + +describe('settingsHeading', () => { + test('titles pages by their first path segment', () => { + expect(settingsHeading('/settings')).toBe('Settings'); + expect(settingsHeading('/settings/core')).toBe('Settings / Core'); + expect(settingsHeading('/settings/downloading')).toBe('Settings / YT-DLP'); + }); + + test('the library page renders its own heading', () => { + expect(settingsHeading('/settings/library')).toBeNull(); + expect(settingsHeading('/settings/library/Kids')).toBeNull(); + }); +}); diff --git a/client/src/components/Settings/settingsHeading.ts b/client/src/components/Settings/settingsHeading.ts new file mode 100644 index 000000000..00393721e --- /dev/null +++ b/client/src/components/Settings/settingsHeading.ts @@ -0,0 +1,13 @@ +import { SETTINGS_PAGES } from './SettingsIndex'; + +/** Pages under /settings/<key> that render their own heading row. */ +const OWN_HEADING = new Set(['library']); + +/** "Settings / {title}" from the first path segment, or null when the page renders its own heading. */ +export function settingsHeading(pathname: string): string | null { + const segment = pathname.replace(/^\/settings\/?/, '').split('/')[0]; + if (!segment) return 'Settings'; + if (OWN_HEADING.has(segment)) return null; + const page = SETTINGS_PAGES.find((entry) => entry.key === segment); + return `Settings / ${page?.title || segment}`; +} diff --git a/client/src/components/layout/AppShell.tsx b/client/src/components/layout/AppShell.tsx index 12978e4d9..a759a44ae 100644 --- a/client/src/components/layout/AppShell.tsx +++ b/client/src/components/layout/AppShell.tsx @@ -114,8 +114,9 @@ export function AppShell({ () => SETTINGS_PAGES.map((page) => ({ key: page.key, - label: page.title, + label: page.navLabel ?? page.title, to: `/settings/${page.key}`, + matchPrefix: true, })), [] ); diff --git a/client/src/components/layout/NavDrawerContent.tsx b/client/src/components/layout/NavDrawerContent.tsx index 05acb3606..326e630b9 100644 --- a/client/src/components/layout/NavDrawerContent.tsx +++ b/client/src/components/layout/NavDrawerContent.tsx @@ -12,7 +12,7 @@ import { Typography, } from '../ui'; import { StorageFooterWidget } from './StorageFooterWidget'; -import { NavItem, isNavItemExpanded, isNavPathActive } from './navigation'; +import { NavItem, isNavItemExpanded, isNavSubItemActive } from './navigation'; import type { ThemeSidebarBehavior } from '../../themes/types'; import { NAV_MAIN_BUTTON_SIDE_PADDING, @@ -76,7 +76,7 @@ export const NavDrawerContent: React.FC<NavDrawerContentProps> = ({ useEffect(() => { const activeItem = navItems.find((item) => - item.subItems?.some((subItem) => isNavPathActive(location.pathname, subItem.to)) + item.subItems?.some((subItem) => isNavSubItemActive(location.pathname, subItem)) ); if (activeItem?.key) { @@ -238,7 +238,7 @@ export const NavDrawerContent: React.FC<NavDrawerContentProps> = ({ {item.subItems && ( <List disablePadding style={{ marginTop: NAV_SUB_VERTICAL_GAP * 8, display: 'flex', flexDirection: 'column', gap: `${NAV_SUB_VERTICAL_GAP * 8}px` }}> {item.subItems.map((subItem) => { - const subSelected = isNavPathActive(location.pathname, subItem.to); + const subSelected = isNavSubItemActive(location.pathname, subItem); return ( <ListItemButton key={subItem.key} diff --git a/client/src/components/layout/NavHeaderTopItems.tsx b/client/src/components/layout/NavHeaderTopItems.tsx index 0dc43ec62..1bc7367e7 100644 --- a/client/src/components/layout/NavHeaderTopItems.tsx +++ b/client/src/components/layout/NavHeaderTopItems.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Button, Paper } from '../ui'; import { Link as RouterLink, useLocation } from 'react-router-dom'; import { useThemeEngine } from '../../contexts/ThemeEngineContext'; -import { NavItem, isNavItemExpanded, isNavPathActive } from './navigation'; +import { NavItem, isNavItemExpanded, isNavSubItemActive } from './navigation'; interface NavHeaderTopItemsProps { navItems: NavItem[]; @@ -110,7 +110,7 @@ export const NavHeaderTopItems: React.FC<NavHeaderTopItemsProps> = ({ > <Paper style={menuPaperStyle}> {(item.subItems ?? []).map((subItem) => { - const isSubActive = isNavPathActive(location.pathname, subItem.to); + const isSubActive = isNavSubItemActive(location.pathname, subItem); return ( <RouterLink key={subItem.key} diff --git a/client/src/components/layout/NavSidebarMobileBottomNav.tsx b/client/src/components/layout/NavSidebarMobileBottomNav.tsx index 09a3959c8..370e50914 100644 --- a/client/src/components/layout/NavSidebarMobileBottomNav.tsx +++ b/client/src/components/layout/NavSidebarMobileBottomNav.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef } from 'react'; import type { Location, NavigateFunction } from 'react-router-dom'; import { Paper } from '../ui'; -import { NavItem, isNavPathActive } from './navigation'; +import { NavItem, isNavSubItemActive } from './navigation'; import { MOBILE_NAV_PRIMARY_HEIGHT, NAV_SUB_FONT_SIZE } from './navLayoutConstants'; import { useScrollEdges } from './useScrollEdges'; @@ -80,7 +80,7 @@ export const NavSidebarMobileBottomNav: React.FC<NavSidebarMobileBottomNavProps> }} > {(activeItemWithSubItems.subItems ?? []).map((subItem) => { - const subSelected = isNavPathActive(location.pathname, subItem.to); + const subSelected = isNavSubItemActive(location.pathname, subItem); return ( <button key={subItem.key} diff --git a/client/src/components/layout/__tests__/AppShell.test.tsx b/client/src/components/layout/__tests__/AppShell.test.tsx index 08d37fd0e..b08d96e80 100644 --- a/client/src/components/layout/__tests__/AppShell.test.tsx +++ b/client/src/components/layout/__tests__/AppShell.test.tsx @@ -5,6 +5,9 @@ import '@testing-library/jest-dom'; import { MemoryRouter } from 'react-router-dom'; import { ThemeEngineProvider } from '../../../contexts/ThemeEngineContext'; import { AppShell } from '../AppShell'; +import { NavItem, NavSubItem, isNavSubItemActive } from '../navigation'; + +const mockNav: { items: NavItem[] } = { items: [] }; jest.mock('../BackgroundDecorations', () => ({ BackgroundDecorations: () => null, @@ -33,13 +36,22 @@ jest.mock('../DownloadPauseBanner', () => ({ })); jest.mock('../NavSidebar', () => ({ - NavSidebar: ({ collapsed, isTopNav }: { collapsed: boolean; isTopNav: boolean }) => ( - <div data-testid="nav-sidebar"> - collapsed:{String(collapsed)}|topnav:{String(isTopNav)} - </div> - ), + NavSidebar: ({ collapsed, isTopNav, navItems }: { collapsed: boolean; isTopNav: boolean; navItems: NavItem[] }) => { + mockNav.items = navItems; + return ( + <div data-testid="nav-sidebar"> + collapsed:{String(collapsed)}|topnav:{String(isTopNav)} + </div> + ); + }, })); +function navSubItem(key: string): NavSubItem { + const found = mockNav.items.flatMap((item) => item.subItems ?? []).find((subItem) => subItem.key === key); + if (!found) throw new Error(`No nav sub-item ${key}`); + return found; +} + function setViewportMatch(isMobile: boolean, isLandscape = false) { Object.defineProperty(window, 'matchMedia', { writable: true, @@ -159,6 +171,26 @@ describe('AppShell', () => { expect(getLayoutRoot().style.getPropertyValue('--layout-content-padding')).toBe('8px 4px'); }); + it('keeps the Library settings sub-item, labelled by its nav label, active on a folder page', () => { + renderShell('playful'); + + expect(navSubItem('library').label).toBe('Library'); + expect(isNavSubItemActive('/settings/library/Kids', navSubItem('library'))).toBe(true); + }); + + it('labels settings sub-items without a nav label by their title', () => { + renderShell('playful'); + + expect(navSubItem('core').label).toBe('Core'); + }); + + it('matches the Channels and Videos sub-items exactly', () => { + renderShell('playful'); + + expect(isNavSubItemActive('/subscriptions/find', navSubItem('subscriptions-list'))).toBe(false); + expect(isNavSubItemActive('/videos/find', navSubItem('videos-downloaded'))).toBe(false); + }); + it('removes landscape mobile side gutters so the content fills the window', () => { renderShell('playful', true, true); diff --git a/client/src/components/layout/__tests__/navigation.test.tsx b/client/src/components/layout/__tests__/navigation.test.tsx index 2d1e7927a..a9424d005 100644 --- a/client/src/components/layout/__tests__/navigation.test.tsx +++ b/client/src/components/layout/__tests__/navigation.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { isNavItemExpanded, isNavItemSelected, isNavPathActive } from '../navigation'; +import { isNavItemExpanded, isNavItemSelected, isNavSubItemActive } from '../navigation'; const subscriptionsItem = { key: 'subscriptions', @@ -14,8 +14,8 @@ const subscriptionsItem = { describe('navigation helpers', () => { it('keeps subscriptions expanded but not selected on the imports subpage', () => { - expect(isNavPathActive('/subscriptions/imports', '/subscriptions')).toBe(false); - expect(isNavPathActive('/subscriptions/imports', '/subscriptions/imports')).toBe(true); + expect(isNavSubItemActive('/subscriptions/imports', { to: '/subscriptions' })).toBe(false); + expect(isNavSubItemActive('/subscriptions/imports', { to: '/subscriptions/imports' })).toBe(true); expect(isNavItemSelected('/subscriptions/imports', subscriptionsItem)).toBe(false); expect(isNavItemExpanded('/subscriptions/imports', subscriptionsItem)).toBe(true); }); @@ -26,3 +26,16 @@ describe('navigation helpers', () => { expect(isNavItemSelected('/playlist/42', subscriptionsItem)).toBe(true); }); }); + +describe('isNavSubItemActive', () => { + test('a prefix sub-item stays active on paths under it', () => { + const library = { to: '/settings/library', matchPrefix: true }; + expect(isNavSubItemActive('/settings/library', library)).toBe(true); + expect(isNavSubItemActive('/settings/library/Kids', library)).toBe(true); + expect(isNavSubItemActive('/settings/libraryx', library)).toBe(false); + }); + + test('other sub-items match exactly, so /videos is not active on /videos/find', () => { + expect(isNavSubItemActive('/videos/find', { to: '/videos' })).toBe(false); + }); +}); diff --git a/client/src/components/layout/navigation.ts b/client/src/components/layout/navigation.ts index ffa225d46..0610144f7 100644 --- a/client/src/components/layout/navigation.ts +++ b/client/src/components/layout/navigation.ts @@ -6,6 +6,8 @@ export interface NavSubItem { key: string; label: string; to: string; + /** Also active on paths under `to` (a page with its own sub-routes) */ + matchPrefix?: boolean; } export interface NavItem { @@ -29,14 +31,14 @@ export const isNavItemSelected = (path: string, item: NavItem) => { return path === item.to || path.startsWith(`${item.to}/`); }; +export const isNavSubItemActive = (path: string, subItem: Pick<NavSubItem, 'to' | 'matchPrefix'>) => ( + path === subItem.to || (subItem.matchPrefix === true && path.startsWith(`${subItem.to}/`)) +); + export const isNavItemExpanded = (path: string, item: NavItem) => { if (isNavItemSelected(path, item)) { return true; } - return item.subItems?.some((subItem) => isNavPathActive(path, subItem.to)) || false; -}; - -export const isNavPathActive = (path: string, targetPath: string) => { - return path === targetPath; + return item.subItems?.some((subItem) => isNavSubItemActive(path, subItem)) || false; }; diff --git a/client/src/components/shared/LayoutChip.tsx b/client/src/components/shared/LayoutChip.tsx new file mode 100644 index 000000000..2dcc70a76 --- /dev/null +++ b/client/src/components/shared/LayoutChip.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Film, Tv } from '../../lib/icons'; +import { cn } from '../../lib/cn'; +import type { LibraryLayout } from '../../types/tvShows'; + +export interface LayoutChipProps { + layout: LibraryLayout; + className?: string; +} + +/** Videos / TV shows chip for a library folder's layout. */ +export function LayoutChip({ layout, className }: LayoutChipProps) { + const tv = layout === 'tv'; + const Icon = tv ? Tv : Film; + return ( + <span + className={cn( + 'inline-flex items-center gap-1 rounded-ui border px-1.5 py-0.5 text-xs leading-none', + tv ? 'border-info/50 text-info' : 'border-border text-muted-foreground', + className + )} + > + <Icon size={12} aria-hidden="true" /> + {tv ? 'TV shows' : 'Videos'} + </span> + ); +} + +export default LayoutChip; diff --git a/client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts b/client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts index 0d5c73671..a7d2d54cc 100644 --- a/client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts +++ b/client/src/components/shared/Reorganize/hooks/__tests__/useActiveReorganize.test.ts @@ -44,6 +44,67 @@ describe('useActiveReorganize', () => { await waitFor(() => expect(result.current.operation).toMatchObject({ id: 3 })); }); + test('keeps the change of the running operation across progress messages', async () => { + axios.get.mockResolvedValueOnce({ data: { operation: { + id: 3, label: 'Kids', status: 'running', change: { type: 'folderLayout', folder: 'Kids', layout: 'tv' }, + } } }); + const { result } = renderHook(() => useActiveReorganize('token'), { wrapper: makeWrapper(subscribers) }); + await waitFor(() => expect(result.current.operation?.change).toBeDefined()); + + act(() => send(progress('running'))); + + expect(result.current.operation).toMatchObject({ id: 3, done: 1, change: { type: 'folderLayout', folder: 'Kids' } }); + }); + + test('fetches the change once when progress names an operation it was not seeded with', async () => { + const { result } = renderHook(() => useActiveReorganize('token'), { wrapper: makeWrapper(subscribers) }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + axios.get.mockResolvedValueOnce({ data: { operation: { + id: 3, label: 'Chan', status: 'running', done: 0, change: { type: 'folderLayout', folder: 'Kids', layout: 'tv' }, + } } }); + + act(() => { + send(progress('running')); + send(progress('running')); + send(progress('running')); + }); + + await waitFor(() => expect(result.current.operation?.change).toEqual({ type: 'folderLayout', folder: 'Kids', layout: 'tv' })); + expect(axios.get).toHaveBeenCalledTimes(2); + expect(result.current.operation).toMatchObject({ id: 3, done: 1 }); + }); + + test('a change that answers after the operation ended does not bring it back', async () => { + const { result } = renderHook(() => useActiveReorganize('token'), { wrapper: makeWrapper(subscribers) }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + let answer: (value: unknown) => void = () => undefined; + axios.get.mockReturnValueOnce(new Promise((resolve) => { answer = resolve; })); + + act(() => send(progress('running'))); + act(() => send(progress('completed'))); + await act(async () => { + answer({ data: { operation: { id: 3, label: 'Chan', status: 'running', change: { type: 'folderLayout', folder: 'Kids', layout: 'tv' } } } }); + }); + + expect(result.current.operation).toBeNull(); + }); + + test('a retried operation fetches its change again', async () => { + const { result } = renderHook(() => useActiveReorganize('token'), { wrapper: makeWrapper(subscribers) }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + act(() => send(progress('running'))); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + act(() => send(progress('failed'))); + axios.get.mockResolvedValueOnce({ data: { operation: { + id: 3, label: 'Chan', status: 'running', change: { type: 'defaultSubfolder', value: 'TV' }, + } } }); + + act(() => send(progress('running'))); + + await waitFor(() => expect(result.current.operation?.change).toEqual({ type: 'defaultSubfolder', value: 'TV' })); + expect(axios.get).toHaveBeenCalledTimes(3); + }); + test('follows progress broadcasts and clears when the reorganize ends', async () => { const { result } = renderHook(() => useActiveReorganize('token'), { wrapper: makeWrapper(subscribers) }); await waitFor(() => expect(axios.get).toHaveBeenCalled()); diff --git a/client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts b/client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts index 6fb343e70..86ea1052e 100644 --- a/client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts +++ b/client/src/components/shared/Reorganize/hooks/useActiveReorganize.ts @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import axios from 'axios'; import WebSocketContext from '../../../../contexts/WebSocketContext'; import { ReorganizeOperation, ReorganizeProgressMessage } from '../../../../types/reorganize'; @@ -13,19 +13,39 @@ export interface UseActiveReorganizeResult { export function useActiveReorganize(token: string | null): UseActiveReorganizeResult { const [operation, setOperation] = useState<ReorganizeOperation | null>(null); const ws = useContext(WebSocketContext); + // The operation whose change was asked for (or seeded), so a burst of progress messages asks once. + const seededId = useRef<number | null>(null); - const fetchActive = useCallback(async () => { - if (!token) return; + const requestActive = useCallback(async (): Promise<ReorganizeOperation | null | undefined> => { + if (!token) return undefined; try { const response = await axios.get<{ operation: ReorganizeOperation | null }>('/api/tv/operations/active', { headers: { 'x-access-token': token }, }); - setOperation(response.data?.operation ?? null); + return response.data?.operation ?? null; } catch { // The notice is informational; a failed check leaves it as it was. + return undefined; } }, [token]); + const fetchActive = useCallback(async () => { + const active = await requestActive(); + if (active === undefined) return; + seededId.current = active?.id ?? null; + setOperation(active); + }, [requestActive]); + + // Progress messages don't carry the change: ask for it once, and only add it to + // the same operation if that is still running when the answer arrives. + const fetchChange = useCallback(async (operationId: number) => { + const active = await requestActive(); + if (!active || active.id !== operationId) return; + setOperation((current) => (current && current.id === operationId + ? { ...current, change: active.change, changeType: active.changeType } + : current)); + }, [requestActive]); + useEffect(() => { fetchActive(); }, [fetchActive]); @@ -35,13 +55,23 @@ export function useActiveReorganize(token: string | null): UseActiveReorganizeRe const progressFilter = (msg: { type?: string }) => msg.type === REORGANIZE_PROGRESS_MESSAGE; const onProgress = (payload: ReorganizeProgressMessage) => { if (payload.status !== 'running') { + // A retry runs the same operation again; it asks for its change anew. + seededId.current = null; setOperation(null); return; } - setOperation({ + if (seededId.current !== payload.operationId) { + seededId.current = payload.operationId; + void fetchChange(payload.operationId); + } + setOperation((current) => ({ + // Keep the change the REST seed (or the fetch above) gave. + ...(current && current.id === payload.operationId + ? { change: current.change, changeType: current.changeType } + : {}), id: payload.operationId, label: payload.label, status: payload.status, total: payload.total, done: payload.done, failed: payload.failed, - }); + })); }; const reconnectFilter = (msg: { type?: string }) => msg.type === 'connectionRestored'; const onReconnect = () => { fetchActive(); }; @@ -51,7 +81,7 @@ export function useActiveReorganize(token: string | null): UseActiveReorganizeRe ws.unsubscribe(onProgress); ws.unsubscribe(onReconnect); }; - }, [ws, fetchActive]); + }, [ws, fetchActive, fetchChange]); return { operation }; } diff --git a/client/src/components/shared/__tests__/LayoutChip.test.tsx b/client/src/components/shared/__tests__/LayoutChip.test.tsx new file mode 100644 index 000000000..d5560ce99 --- /dev/null +++ b/client/src/components/shared/__tests__/LayoutChip.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { LayoutChip } from '../LayoutChip'; + +describe('LayoutChip', () => { + test('names each layout', () => { + render(<><LayoutChip layout="videos" /><LayoutChip layout="tv" /></>); + expect(screen.getByText('Videos')).toBeInTheDocument(); + expect(screen.getByText('TV shows')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ui/__tests__/segmented-control.test.tsx b/client/src/components/ui/__tests__/segmented-control.test.tsx new file mode 100644 index 000000000..78f069b35 --- /dev/null +++ b/client/src/components/ui/__tests__/segmented-control.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SegmentedControl } from '../segmented-control'; + +const options = [{ value: 'videos', label: 'Videos' }, { value: 'tv', label: 'TV shows' }]; + +describe('SegmentedControl', () => { + test('is a radio group with the value checked', () => { + render(<SegmentedControl aria-label="Preview Kids as" value="videos" onChange={jest.fn()} options={options} />); + expect(screen.getByRole('radiogroup', { name: 'Preview Kids as' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Videos' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: 'TV shows' })).toHaveAttribute('aria-checked', 'false'); + }); + + test('reports a new value and ignores clicking the checked one', async () => { + const onChange = jest.fn(); + render(<SegmentedControl aria-label="Layout" value="videos" onChange={onChange} options={options} />); + + await userEvent.click(screen.getByRole('radio', { name: 'Videos' })); + await userEvent.click(screen.getByRole('radio', { name: 'TV shows' })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('tv'); + }); + + test('arrow keys move between options', async () => { + render(<SegmentedControl aria-label="Layout" value="videos" onChange={jest.fn()} options={options} />); + act(() => { screen.getByRole('radio', { name: 'Videos' }).focus(); }); + + await userEvent.keyboard('{ArrowRight}'); + + expect(screen.getByRole('radio', { name: 'TV shows' })).toHaveFocus(); + }); +}); diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 15b05b0a7..5f4352c69 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -16,6 +16,7 @@ export * from './list'; export * from './menu'; export * from './paper'; export * from './progress'; +export * from './segmented-control'; export * from './select'; export * from './snackbar'; export * from './table'; diff --git a/client/src/components/ui/segmented-control.tsx b/client/src/components/ui/segmented-control.tsx new file mode 100644 index 000000000..ae90f8576 --- /dev/null +++ b/client/src/components/ui/segmented-control.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import * as ToggleGroup from '@radix-ui/react-toggle-group'; +import { cn } from '../../lib/cn'; + +export interface SegmentedControlOption<T extends string> { + value: T; + label: React.ReactNode; + icon?: React.ReactNode; + /** Small trailing text, e.g. "current" */ + hint?: React.ReactNode; +} + +export interface SegmentedControlProps<T extends string> { + value: T; + onChange: (value: T) => void; + options: SegmentedControlOption<T>[]; + 'aria-label': string; + /** lg: 44px touch targets */ + size?: 'md' | 'lg'; + disabled?: boolean; + className?: string; +} + +/** + * A single-choice segmented control with radio semantics (radiogroup / radio, + * arrow keys move between options). Theme-neutral: colors and corners come + * from the theme tokens. + */ +export function SegmentedControl<T extends string>({ + value, onChange, options, size = 'md', disabled, className, 'aria-label': ariaLabel, +}: SegmentedControlProps<T>) { + return ( + <ToggleGroup.Root + type="single" + role="radiogroup" + aria-label={ariaLabel} + value={value} + disabled={disabled} + onValueChange={(next) => { + // Clicking the checked option would clear a single toggle group. + if (next) onChange(next as T); + }} + className={cn('grid auto-cols-fr grid-flow-col gap-0.5 rounded-ui border border-border p-0.5', className)} + > + {options.map((option) => ( + <ToggleGroup.Item + key={option.value} + value={option.value} + role="radio" + aria-checked={option.value === value} + className={cn( + 'inline-flex items-center justify-center gap-1.5 rounded-ui px-3 text-sm text-muted-foreground transition-colors', + size === 'lg' ? 'min-h-[44px]' : 'min-h-[30px]', + 'data-[state=on]:bg-primary/10 data-[state=on]:font-semibold data-[state=on]:text-foreground', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50' + )} + > + {option.icon} + <span>{option.label}</span> + {option.hint ? <span className="text-[11px] font-normal text-muted-foreground">{option.hint}</span> : null} + </ToggleGroup.Item> + ))} + </ToggleGroup.Root> + ); +} diff --git a/client/src/config/configSchema.ts b/client/src/config/configSchema.ts index e56ae6315..4ed266b2d 100644 --- a/client/src/config/configSchema.ts +++ b/client/src/config/configSchema.ts @@ -49,7 +49,7 @@ export const CONFIG_FIELDS = { plexApiKey: { default: '', trackChanges: true }, plexYoutubeLibraryId: { default: '', trackChanges: true }, plexSubfolderLibraryMappings: { - default: [] as Array<{ subfolder: string | null; libraryId: string }>, + default: [] as Array<{ subfolder: string | null; libraryId: string | null }>, trackChanges: true, }, plexIP: { default: '', trackChanges: true }, diff --git a/client/src/hooks/__tests__/useContainerWidth.test.tsx b/client/src/hooks/__tests__/useContainerWidth.test.tsx new file mode 100644 index 000000000..357b7d8a7 --- /dev/null +++ b/client/src/hooks/__tests__/useContainerWidth.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { useContainerWidth } from '../useContainerWidth'; + +function Probe() { + const [ref, width] = useContainerWidth<HTMLDivElement>(); + return <div ref={ref}>{width === null ? 'unmeasured' : `width ${width}`}</div>; +} + +describe('useContainerWidth', () => { + let observed: ResizeObserverCallback | null = null; + const originalObserver = global.ResizeObserver; + + beforeEach(() => { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ width: 800 } as DOMRect); + global.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { observed = callback; } + observe() {} + disconnect() {} + unobserve() {} + } as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + global.ResizeObserver = originalObserver; + observed = null; + }); + + test('measures on mount and follows resizes', () => { + render(<Probe />); + expect(screen.getByText('width 800')).toBeInTheDocument(); + + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ width: 1100 } as DOMRect); + act(() => { observed?.([], {} as ResizeObserver); }); + + expect(screen.getByText('width 1100')).toBeInTheDocument(); + }); + + test('still measures once without ResizeObserver', () => { + // @ts-expect-error simulate a browser without ResizeObserver + global.ResizeObserver = undefined; + render(<Probe />); + expect(screen.getByText('width 800')).toBeInTheDocument(); + }); +}); diff --git a/client/src/hooks/__tests__/useLibraryCheck.test.ts b/client/src/hooks/__tests__/useLibraryCheck.test.ts index a013ce296..25f5c4186 100644 --- a/client/src/hooks/__tests__/useLibraryCheck.test.ts +++ b/client/src/hooks/__tests__/useLibraryCheck.test.ts @@ -28,6 +28,23 @@ describe('useLibraryCheck', () => { })); }); + describe('lastCheckedAt', () => { + afterEach(() => jest.restoreAllMocks()); + + test('records when the last check succeeded and keeps it after a failed refresh', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_000); + axios.get.mockResolvedValueOnce({ data: { servers: [], folders: [] } }); + const { result } = renderHook(() => useLibraryCheck('token')); + await waitFor(() => expect(result.current.lastCheckedAt).toBe(1_000)); + + axios.get.mockRejectedValueOnce(new Error('down')); + await act(async () => { await result.current.refetch(); }); + + expect(result.current.lastCheckedAt).toBe(1_000); + expect(result.current.data).toEqual({ servers: [], folders: [] }); + }); + }); + test('asks only for the given folders', async () => { const { result } = renderHook(() => useLibraryCheck('token', { folders: ['TV', ''] })); @@ -128,6 +145,46 @@ describe('useLibraryCheck', () => { window.removeEventListener(CONFIG_PATCHED_EVENT, listener); }); + test('tells the folder list a saved mapping changed, checking once', async () => { + axios.put.mockResolvedValue({ data: { mappedLibraryId: '41', plexSubfolderLibraryMappings: [] } }); + const updated = jest.fn(); + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, updated); + const { result } = renderHook(() => useLibraryCheck('token')); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + + await act(async () => { await result.current.applyPlexMapping('TV', '41'); }); + + expect(updated).toHaveBeenCalledTimes(1); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, updated); + }); + + test('resolves applyPlexMapping only once the refreshed check is in, with one check request', async () => { + axios.put.mockResolvedValue({ data: { mappedLibraryId: '41', plexSubfolderLibraryMappings: [] } }); + const updated = jest.fn(); + window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, updated); + const { result } = renderHook(() => useLibraryCheck('token')); + await waitFor(() => expect(result.current.data).toEqual(RESPONSE)); + + const refreshed = { servers: [], folders: [] }; + let release: (value: { data: typeof refreshed }) => void = () => undefined; + axios.get.mockReturnValueOnce(new Promise((resolve) => { release = resolve; })); + let done = false; + let applied: Promise<void> = Promise.resolve(); + act(() => { applied = result.current.applyPlexMapping('TV', '41').then(() => { done = true; }); }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + await act(async () => { await Promise.resolve(); }); + expect(done).toBe(false); + + await act(async () => { release({ data: refreshed }); await applied; }); + + expect(done).toBe(true); + expect(result.current.data).toEqual(refreshed); + expect(axios.get).toHaveBeenCalledTimes(2); + expect(updated).toHaveBeenCalledTimes(1); + window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, updated); + }); + test("throws the server's refusal when the mapping fails", async () => { axios.put.mockRejectedValue({ isAxiosError: true, response: { data: { error: 'Already mapped.' } } }); const { result } = renderHook(() => useLibraryCheck('token')); diff --git a/client/src/hooks/__tests__/useLibraryFolders.test.ts b/client/src/hooks/__tests__/useLibraryFolders.test.ts index c9b03738a..cb78f43b0 100644 --- a/client/src/hooks/__tests__/useLibraryFolders.test.ts +++ b/client/src/hooks/__tests__/useLibraryFolders.test.ts @@ -81,6 +81,70 @@ describe('useLibraryFolders', () => { await expect(result.current.setFolderLayout('', 'tv')).rejects.toMatchObject({ name: 'ReorganizeRequiredError', change }); }); + test('asks for the included fields', async () => { + axios.get.mockResolvedValueOnce({ data: { folders: [] } }); + + renderHook(() => useLibraryFolders('token', { include: ['usage', 'files'] })); + + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/library-folders', { + headers: { 'x-access-token': 'token' }, + params: { include: 'usage,files' }, + })); + }); + + test('is loaded only after the first answer', async () => { + axios.get.mockResolvedValueOnce({ data: { folders: [{ name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 0 }] } }); + + const { result } = renderHook(() => useLibraryFolders('token')); + + expect(result.current.loaded).toBe(false); + await waitFor(() => expect(result.current.loaded).toBe(true)); + }); + + test('with include, a layout change refetches instead of taking the plain list', async () => { + const withUsage = { name: 'TV', layout: 'tv', isDefault: false, hasFiles: false, channels: 0, fileCount: 3 }; + axios.get.mockResolvedValueOnce({ data: { folders: [withUsage] } }); + // The refetch the layout event triggers never answers, so only the PUT's list could change the state. + axios.get.mockImplementation(() => new Promise(() => {})); + axios.put.mockResolvedValueOnce({ data: { changed: true, folders: [{ ...withUsage, fileCount: undefined }] } }); + const { result } = renderHook(() => useLibraryFolders('token', { include: ['files'] })); + await waitFor(() => expect(result.current.folders).toHaveLength(1)); + + await act(async () => { await result.current.setFolderLayout('TV', 'videos'); }); + + expect(result.current.folders[0].fileCount).toBe(3); + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + test('an older answer that arrives last does not overwrite the newer one, and loading lasts until the latest answers', async () => { + const answers: Array<(value: unknown) => void> = []; + axios.get.mockImplementation(() => new Promise((resolve) => { answers.push(resolve); })); + const { result } = renderHook(() => useLibraryFolders('token', { include: ['usage', 'files'] })); + await waitFor(() => expect(answers).toHaveLength(1)); + act(() => { void result.current.refetch(); }); + await waitFor(() => expect(answers).toHaveLength(2)); + + await act(async () => { answers[1]({ data: { folders: [FOLDERS[1]] } }); }); + await act(async () => { answers[0]({ data: { folders: FOLDERS } }); }); + + expect(result.current.folders).toEqual([FOLDERS[1]]); + expect(result.current.loading).toBe(false); + }); + + test('loading stays on while a newer request is still running', async () => { + const answers: Array<(value: unknown) => void> = []; + axios.get.mockImplementation(() => new Promise((resolve) => { answers.push(resolve); })); + const { result } = renderHook(() => useLibraryFolders('token', { include: ['usage', 'files'] })); + await waitFor(() => expect(answers).toHaveLength(1)); + act(() => { void result.current.refetch(); }); + await waitFor(() => expect(answers).toHaveLength(2)); + + await act(async () => { answers[0]({ data: { folders: FOLDERS } }); }); + + expect(result.current.loading).toBe(true); + expect(result.current.folders).toEqual([]); + }); + test('refetches when subfolders change', async () => { renderHook(() => useLibraryFolders('token')); await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); diff --git a/client/src/hooks/useContainerWidth.ts b/client/src/hooks/useContainerWidth.ts new file mode 100644 index 000000000..1711130c3 --- /dev/null +++ b/client/src/hooks/useContainerWidth.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * The measured width of an element, kept current with a ResizeObserver, for + * layouts that switch on the space they get (an expanded sidebar, a theme's + * wider chrome) rather than on the viewport. Measure an element without + * padding: the width is its border box. null until measured. + */ +export function useContainerWidth<T extends HTMLElement>(): [(node: T | null) => void, number | null] { + const [node, setNode] = useState<T | null>(null); + const [width, setWidth] = useState<number | null>(null); + const ref = useCallback((element: T | null) => setNode(element), []); + + useEffect(() => { + if (!node) return undefined; + const measure = () => setWidth(node.getBoundingClientRect().width); + measure(); + if (typeof ResizeObserver === 'undefined') return undefined; + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [node]); + + return [ref, width]; +} + +export default useContainerWidth; diff --git a/client/src/hooks/useLibraryCheck.ts b/client/src/hooks/useLibraryCheck.ts index ea2bd26c0..1d632a672 100644 --- a/client/src/hooks/useLibraryCheck.ts +++ b/client/src/hooks/useLibraryCheck.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import axios from 'axios'; import { LibraryCheckResponse } from '../types/libraryCheck'; -import type { LibraryLayout } from '../types/tvShows'; +import type { LibraryLayout, PlexMappingChoice } from '../types/tvShows'; import { LIBRARY_FOLDERS_UPDATED_EVENT } from './useLibraryFolders'; import { CONFIG_PATCHED_EVENT } from './useConfig'; import type { ConfigState } from '../components/Configuration/types'; @@ -22,6 +22,8 @@ export interface UseLibraryCheckResult { data: LibraryCheckResponse | null; loading: boolean; error: string | null; + /** Client time of the last successful check; kept when a refresh fails */ + lastCheckedAt: number | null; refetch: () => Promise<void>; /** Map a TV subfolder to the Plex library that holds it, then check again; throws with the server's message */ applyPlexMapping: (folder: string, libraryId: string) => Promise<void>; @@ -30,6 +32,7 @@ export interface UseLibraryCheckResult { /** PUT /api/library-folders/plex-mapping */ interface PlexMappingResponse { mappedLibraryId: string; + choice?: PlexMappingChoice; plexSubfolderLibraryMappings: ConfigState['plexSubfolderLibraryMappings']; } @@ -49,7 +52,9 @@ export function useLibraryCheck( const [data, setData] = useState<LibraryCheckResponse | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); + const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null); const requestSeq = useRef(0); + const skipOwnEvent = useRef(false); const foldersKey = folders ? JSON.stringify(folders) : null; // A report is about the folders it was asked for: a new set starts from nothing. const reportedKey = useRef(foldersKey); @@ -58,6 +63,7 @@ export function useLibraryCheck( if (reportedKey.current !== foldersKey) { reportedKey.current = foldersKey; setData(null); + setLastCheckedAt(null); } if (!token || !enabled) { setLoading(false); @@ -74,7 +80,10 @@ export function useLibraryCheck( headers: { 'x-access-token': token }, params, }); - if (seq === requestSeq.current) setData(response.data); + if (seq === requestSeq.current) { + setData(response.data); + setLastCheckedAt(Date.now()); + } } catch (err: unknown) { if (seq === requestSeq.current) setError(serverMessage(err, CHECK_FAILED_MESSAGE)); } finally { @@ -100,6 +109,11 @@ export function useLibraryCheck( // it over the page's unsaved edits. const patch: Partial<ConfigState> = { plexSubfolderLibraryMappings: saved.plexSubfolderLibraryMappings }; window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: patch })); + // The folder list reads each folder's mapping. This instance checks once itself, awaited + // so callers' spinners last until the refreshed check is in; its own listener skips this event. + skipOwnEvent.current = true; + window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); + skipOwnEvent.current = false; await fetchCheck(); }, [token, fetchCheck]); @@ -111,12 +125,15 @@ export function useLibraryCheck( }, [fetchCheck]); useEffect(() => { - const handler = () => { fetchCheck(); }; + const handler = () => { + if (skipOwnEvent.current) return; + fetchCheck(); + }; window.addEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); return () => window.removeEventListener(LIBRARY_FOLDERS_UPDATED_EVENT, handler); }, [fetchCheck]); - return { data, loading, error, refetch: fetchCheck, applyPlexMapping }; + return { data, loading, error, lastCheckedAt, refetch: fetchCheck, applyPlexMapping }; } export default useLibraryCheck; diff --git a/client/src/hooks/useLibraryFolders.ts b/client/src/hooks/useLibraryFolders.ts index abb6ead42..5ef15cfcd 100644 --- a/client/src/hooks/useLibraryFolders.ts +++ b/client/src/hooks/useLibraryFolders.ts @@ -1,6 +1,6 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import axios from 'axios'; -import { LibraryFolder, LibraryFoldersResponse, LibraryLayout } from '../types/tvShows'; +import { LibraryFolder, LibraryFolderInclude, LibraryFoldersResponse, LibraryLayout } from '../types/tvShows'; import { buildLayoutResolver, LayoutResolver } from '../utils/libraryLayouts'; import { SUBFOLDERS_UPDATED_EVENT } from './useSubfolders'; import { toRequestError } from '../components/shared/Reorganize/reorganizeErrors'; @@ -11,9 +11,16 @@ interface LibraryFolderChangeResponse extends LibraryFoldersResponse { changed: boolean; } +export interface UseLibraryFoldersOptions { + /** Ask for usage fields and/or the downloaded video count (the Library folders page, the Core card) */ + include?: LibraryFolderInclude[]; +} + export interface UseLibraryFoldersResult { folders: LibraryFolder[]; loading: boolean; + /** The first answer (or failure) has arrived */ + loaded: boolean; error: string | null; /** Layout of a library folder ('' = main folder); videos until loaded */ layoutOf: LayoutResolver; @@ -34,26 +41,38 @@ function errorMessage(err: unknown, fallback: string): string { } /** Library folders (main folder and subfolders) with their layouts. */ -export function useLibraryFolders(token: string | null): UseLibraryFoldersResult { +export function useLibraryFolders( + token: string | null, + { include }: UseLibraryFoldersOptions = {} +): UseLibraryFoldersResult { const [folders, setFolders] = useState<LibraryFolder[]>([]); const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); const [error, setError] = useState<string | null>(null); + const includeKey = include && include.length > 0 ? include.join(',') : ''; + // Only the latest request answers: overlapping refetches can finish out of order. + const requestSeq = useRef(0); const fetchFolders = useCallback(async () => { if (!token) return; + const seq = ++requestSeq.current; setLoading(true); setError(null); try { const response = await axios.get<LibraryFoldersResponse>('/api/library-folders', { headers: { 'x-access-token': token }, + ...(includeKey ? { params: { include: includeKey } } : {}), }); - setFolders(Array.isArray(response.data?.folders) ? response.data.folders : []); + if (seq === requestSeq.current) setFolders(Array.isArray(response.data?.folders) ? response.data.folders : []); } catch (err) { - setError(errorMessage(err, 'Failed to load library folders')); + if (seq === requestSeq.current) setError(errorMessage(err, 'Failed to load library folders')); } finally { - setLoading(false); + if (seq === requestSeq.current) { + setLoading(false); + setLoaded(true); + } } - }, [token]); + }, [token, includeKey]); const setFolderLayout = useCallback(async (name: string, layout: LibraryLayout) => { if (!token) return; @@ -67,12 +86,14 @@ export function useLibraryFolders(token: string | null): UseLibraryFoldersResult } catch (err) { throw toRequestError(err, 'Failed to change the folder layout'); } - if (Array.isArray(response.data?.folders)) setFolders(response.data.folders); + // The PUT's list has no usage fields; with include, the event's refetch brings them. + if (!includeKey && Array.isArray(response.data?.folders)) setFolders(response.data.folders); window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); - }, [token]); + }, [token, includeKey]); useEffect(() => { fetchFolders(); + return () => { requestSeq.current += 1; }; }, [fetchFolders]); useEffect(() => { @@ -87,7 +108,7 @@ export function useLibraryFolders(token: string | null): UseLibraryFoldersResult const layoutOf = useMemo(() => buildLayoutResolver(folders), [folders]); - return { folders, loading, error, layoutOf, refetch: fetchFolders, setFolderLayout }; + return { folders, loading, loaded, error, layoutOf, refetch: fetchFolders, setFolderLayout }; } export default useLibraryFolders; diff --git a/client/src/lib/icons.ts b/client/src/lib/icons.ts index d9856d996..bee433838 100644 --- a/client/src/lib/icons.ts +++ b/client/src/lib/icons.ts @@ -121,6 +121,12 @@ export { Sun, Monitor, Tv, + ArrowLeft, + ArrowRight, + MinusCircle, + Film, + HelpCircle, + Library, Smartphone, Tablet, Code, diff --git a/client/src/types/libraryCheck.ts b/client/src/types/libraryCheck.ts index 5ced9e267..69f6b0a47 100644 --- a/client/src/types/libraryCheck.ts +++ b/client/src/types/libraryCheck.ts @@ -1,4 +1,4 @@ -import type { LibraryLayout } from './tvShows'; +import type { LibraryLayout, PlexMappingChoice } from './tvShows'; export type MediaServerType = 'plex' | 'jellyfin' | 'emby'; @@ -27,6 +27,7 @@ export interface PlexMappingState { mappedLibraryId: string | null; /** The one Plex TV library that holds the folder */ suggestedLibraryId: string | null; + choice?: PlexMappingChoice; } export interface LibraryCheckServerReport { @@ -52,6 +53,8 @@ export interface LibraryCheckServer { name: string; reachable: boolean; error: string | null; + /** Where this server sees the downloads folder, in its own spelling; null unless exactly one place is known */ + downloadsPath?: string | null; } /** GET /api/library-folders/check */ diff --git a/client/src/types/tvShows.ts b/client/src/types/tvShows.ts index 0a5a9b992..bceacb9e7 100644 --- a/client/src/types/tvShows.ts +++ b/client/src/types/tvShows.ts @@ -3,6 +3,19 @@ import type { ChannelReorganizeState } from './reorganize'; /** Layout of a library folder: movie-style videos or TV shows. */ export type LibraryLayout = 'videos' | 'tv'; +export type LibraryFolderInclude = 'usage' | 'files'; +export type DeleteBlockerCode = 'channels' | 'disabledChannels' | 'playlists' | 'shows' | 'default' | 'files' | 'main'; +export interface DeleteBlocker { + code: DeleteBlockerCode; + count?: number; +} +export type PlexMappingChoice = 'library' | 'default' | 'none'; +/** The Plex library a folder's downloads refresh: a library, the default by choice, or no setting. */ +export interface FolderPlexMapping { + choice: PlexMappingChoice; + libraryId: string | null; +} + /** A library folder from GET /api/library-folders. */ export interface LibraryFolder { /** '' for the main downloads folder, else the subfolder name without __ */ @@ -14,12 +27,60 @@ export interface LibraryFolder { hasFiles: boolean; /** Enabled channels that download to this folder */ channels: number; + /** include=usage: enabled channels whose own setting names this folder */ + channelsChosen?: number; + /** include=usage: enabled channels following the default folder into it (default folder only) */ + channelsFollowing?: number; + /** include=usage: enabled playlists that download here by default */ + playlists?: number; + /** include=usage: active title shows in this folder */ + titleShows?: number; + /** include=usage: a layout change opens Review the move (the server's own guard rule) */ + layoutChangeNeedsReview?: boolean; + /** include=usage: making it the default opens Review the move */ + makeDefaultNeedsReview?: boolean; + plexMapping?: FolderPlexMapping; + /** include=usage: every reason delete is refused, in guard order */ + deleteBlockers?: DeleteBlocker[]; + deletable?: boolean; + /** include=files: downloaded videos whose file sits in this folder */ + fileCount?: number; } export interface LibraryFoldersResponse { folders: LibraryFolder[]; } +export interface LibraryFolderExample { + channelName: string; + title: string; + youtubeId: string; + /** ISO time; null when the info.json has neither timestamp nor upload date */ + uploadedAt: string | null; + uploadedAtSource: 'timestamp' | 'upload_date' | null; + /** The media file's path inside the folder, with / separators */ + relativePath: string; +} + +/** GET /api/library-folders/folder/:key */ +export interface LibraryFolderDetail { + name: string; + layout: LibraryLayout; + channels: Array<{ channelId: string; name: string; videoCount: number }>; + followers: { count: number; sample: string[] }; + playlists: Array<{ playlistId: string; name: string; videoCount: number }>; + titleShows: Array<{ id: number; name: string; channelId: string; channelName: string; episodeCount: number }>; + example: LibraryFolderExample | null; +} + +/** POST /api/subfolders */ +export interface CreateLibraryFolderResult { + name: string; + layout: LibraryLayout; + created: boolean; + existingContent?: boolean; +} + /** A channel's show, as stored on the server. */ export interface ChannelTvShow { name: string; diff --git a/client/src/utils/__tests__/libraryAttention.test.ts b/client/src/utils/__tests__/libraryAttention.test.ts new file mode 100644 index 000000000..e6f29087d --- /dev/null +++ b/client/src/utils/__tests__/libraryAttention.test.ts @@ -0,0 +1,110 @@ +import { + buildAttention, checkStatus, folderState, isOverlapIssue, joinNames, plexMappingChoice, serverStatuses, timeAgo, +} from '../libraryAttention'; +import type { LibraryFolder } from '../../types/tvShows'; +import type { LibraryCheckResponse, LibraryCheckServerReport } from '../../types/libraryCheck'; + +const folder = (name: string, extra: Partial<LibraryFolder> = {}): LibraryFolder => ({ + name, layout: 'videos', isDefault: false, hasFiles: false, channels: 0, ...extra, +}); +const report = (serverType: 'plex' | 'jellyfin', extra: Partial<LibraryCheckServerReport> = {}): LibraryCheckServerReport => ({ + serverType, status: 'ok', libraries: [{ id: '1', name: 'YouTube', type: 'videos', location: '/yt', relation: 'exact' }], issues: [], ...extra, +}); +const nfo = { code: 'nfoSaver', message: 'YouTube saves NFO files...', libraryId: '1' }; +const servers = [ + { serverType: 'plex' as const, name: 'Plex', reachable: true, error: null }, + { serverType: 'jellyfin' as const, name: 'Jellyfin', reachable: true, error: null }, +]; +const state = (data: LibraryCheckResponse | null, extra = {}) => ({ data, loading: false, error: null, lastCheckedAt: 0, ...extra }); + +describe('libraryAttention', () => { + test('folder states', () => { + expect(folderState(folder('Kids', { channels: 2 }))).toBe('active'); + expect(folderState(folder('Old', { fileCount: 4 }))).toBe('holdsVideos'); + expect(folderState(folder('Raw', { hasFiles: true, fileCount: 0 }))).toBe('holdsVideos'); + expect(folderState(folder('Empty'))).toBe('unused'); + expect(folderState(folder(''))).toBe('emptyMain'); + expect(folderState(folder('', { isDefault: true }))).toBe('active'); + }); + + test('a missing library is red only for folders that need one', () => { + const data = { servers, folders: [ + { name: 'Kids', layout: 'videos' as const, hasFiles: true, channels: 1, servers: [report('plex', { status: 'missing', libraries: [] })] }, + { name: 'Empty', layout: 'videos' as const, hasFiles: false, channels: 0, servers: [report('plex', { status: 'missing', libraries: [] })] }, + ] }; + expect(serverStatuses(folder('Kids', { channels: 1 }), state(data), [])[0]).toMatchObject({ display: 'noLibrary', word: 'No library' }); + expect(serverStatuses(folder('Empty'), state(data), [])[0]).toMatchObject({ display: 'fine', word: 'No library' }); + }); + + test('shows the configured servers as checking before the first answer', () => { + expect(serverStatuses(folder('Kids'), state(null, { loading: true }), ['plex'])).toEqual([ + expect.objectContaining({ serverType: 'plex', display: 'checking', word: 'Checking...' }), + ]); + }); + + test('counts a library-wide issue once across folders and keeps folder items separate', () => { + const data: LibraryCheckResponse = { servers, folders: [ + { name: 'A', layout: 'videos', hasFiles: true, channels: 1, servers: [report('jellyfin', { status: 'warning', issues: [nfo] })] }, + { name: 'B', layout: 'videos', hasFiles: true, channels: 1, servers: [report('jellyfin', { status: 'warning', issues: [nfo] })] }, + { name: 'C', layout: 'videos', hasFiles: true, channels: 1, servers: [report('plex', { status: 'missing', libraries: [] })] }, + ] }; + const folders = ['A', 'B', 'C'].map((name) => folder(name, { channels: 1 })); + + const items = buildAttention(folders, state(data), []); + + expect(items.map((item) => item.text)).toEqual([ + 'Jellyfin library YouTube saves NFO files (affects 2 folders)', + '__C (Plex)', + ]); + }); + + test('never counts an unreachable server', () => { + const data = { + servers: [{ serverType: 'plex' as const, name: 'Plex', reachable: false, error: 'ECONNREFUSED' }, servers[1]], + folders: [{ name: 'A', layout: 'videos' as const, hasFiles: true, channels: 1, servers: [ + { serverType: 'plex' as const, status: 'unreachable' as const, libraries: [], issues: [{ code: 'unreachable', message: 'x' }] }, + report('jellyfin', { status: 'warning', issues: [{ code: 'wrongType', message: 'Wrong type', libraryId: '1' }] }), + ] }], + }; + + const items = buildAttention([folder('A', { channels: 1 })], state(data), []); + expect(items.map((item) => item.text)).toEqual(['__A (Jellyfin)']); + expect(checkStatus(state(data), [])).toMatchObject({ kind: 'partial', checked: ['Jellyfin'], unreachable: [{ name: 'Plex', error: 'ECONNREFUSED' }] }); + }); + + test('check status kinds', () => { + expect(checkStatus(state({ servers: [], folders: [] }), []).kind).toBe('none'); + expect(checkStatus(state({ servers, folders: [] }), []).kind).toBe('full'); + expect(checkStatus(state(null, { error: 'boom', lastCheckedAt: null }), ['plex']).kind).toBe('failed'); + expect(checkStatus(state(null, { loading: true, lastCheckedAt: null }), ['plex']).kind).toBe('checking'); + }); + + test('joins names and phrases times', () => { + expect(joinNames(['Plex'])).toBe('Plex'); + expect(joinNames(['Plex', 'Jellyfin', 'Emby'])).toBe('Plex, Jellyfin and Emby'); + expect(joinNames(['Plex', 'Emby'], 'or')).toBe('Plex or Emby'); + expect(timeAgo(1_000, 30_000, null)).toBe('just now'); + expect(timeAgo(0, 5 * 60_000, null)).toBe('5 min ago'); + expect(timeAgo(0, 3 * 3_600_000, null)).toBe('3 h ago'); + }); + + test('isOverlapIssue is true for nestedLibrary and overlap only', () => { + expect(isOverlapIssue('nestedLibrary')).toBe(true); + expect(isOverlapIssue('overlap')).toBe(true); + expect(isOverlapIssue('overlapTv')).toBe(false); + expect(isOverlapIssue('noLibrary')).toBe(false); + }); + + test('plexMappingChoice: an explicit choice wins', () => { + expect(plexMappingChoice({ mappedLibraryId: '4', suggestedLibraryId: null, choice: 'default' })).toBe('default'); + }); + + test('plexMappingChoice: no choice with a mapped library is library', () => { + expect(plexMappingChoice({ mappedLibraryId: '4', suggestedLibraryId: null })).toBe('library'); + }); + + test('plexMappingChoice: neither is none', () => { + expect(plexMappingChoice({ mappedLibraryId: null, suggestedLibraryId: '4' })).toBe('none'); + expect(plexMappingChoice(undefined)).toBe('none'); + }); +}); diff --git a/client/src/utils/__tests__/libraryLayouts.test.ts b/client/src/utils/__tests__/libraryLayouts.test.ts index 6a4901167..97724f283 100644 --- a/client/src/utils/__tests__/libraryLayouts.test.ts +++ b/client/src/utils/__tests__/libraryLayouts.test.ts @@ -1,4 +1,8 @@ import { + MAIN_FOLDER_ROUTE_KEY, + folderFromRouteKey, + folderRouteKey, + libraryFolderUrl, folderKey, effectiveLibraryFolder, buildLayoutResolver, @@ -52,3 +56,14 @@ describe('libraryLayouts utils', () => { expect([folderKey(' TV Shows '), folderKey(''), folderKey(null)]).toEqual(['tv shows', '', '']); }); }); + +describe('library folder route keys', () => { + test('round-trips names with spaces and the main folder', () => { + expect(folderRouteKey('Science Shows')).toBe('Science%20Shows'); + expect(folderFromRouteKey('Science%20Shows')).toBe('Science Shows'); + expect(folderFromRouteKey('Science Shows')).toBe('Science Shows'); + expect(folderRouteKey('')).toBe(MAIN_FOLDER_ROUTE_KEY); + expect(folderFromRouteKey('~main')).toBe(''); + expect(libraryFolderUrl('Kids')).toBe('/settings/library/Kids'); + }); +}); diff --git a/client/src/utils/libraryAttention.ts b/client/src/utils/libraryAttention.ts new file mode 100644 index 000000000..a968e9df2 --- /dev/null +++ b/client/src/utils/libraryAttention.ts @@ -0,0 +1,252 @@ +import type { LibraryFolder, PlexMappingChoice } from '../types/tvShows'; +import type { + LibraryCheckFolder, LibraryCheckIssue, LibraryCheckLibrary, LibraryCheckResponse, LibraryCheckServerReport, + MediaServerType, PlexMappingState, +} from '../types/libraryCheck'; +import { formatDateTimeInZone } from './formatters'; +import { folderKey, libraryFolderLabel } from './libraryLayouts'; + +/** + * What the Library folders page and the Core card say about folders and the + * media server check (UI spec 4.3-4.6): folder states, each server's status + * per folder, the attention list (library-wide issues counted once), and the + * check status. Shared so both always show the same count. + */ + +export const SERVER_ORDER: MediaServerType[] = ['plex', 'jellyfin', 'emby']; +export const SERVER_NAMES: Record<MediaServerType, string> = { plex: 'Plex', jellyfin: 'Jellyfin', emby: 'Emby' }; + +export type FolderState = 'active' | 'holdsVideos' | 'unused' | 'emptyMain'; + +export function isActive(folder: LibraryFolder): boolean { + return folder.isDefault || folder.channels > 0 || (folder.playlists ?? 0) > 0 || (folder.titleShows ?? 0) > 0; +} + +export function folderState(folder: LibraryFolder): FolderState { + if (isActive(folder)) return 'active'; + if (folder.hasFiles || (folder.fileCount ?? 0) > 0) return 'holdsVideos'; + return folder.name ? 'unused' : 'emptyMain'; +} + +/** Active and Holds videos folders should be in a library on every server. */ +export function needsLibrary(state: FolderState): boolean { + return state === 'active' || state === 'holdsVideos'; +} + +/** Issues that describe a library rather than a folder, with their short attention text. */ +export const LIBRARY_WIDE_SHORT: Record<string, string> = { + nfoSaver: 'saves NFO files', + onlineFetchers: 'looks things up online', + plexSeriesAgent: 'uses the Plex Series agent', + plexLegacyAgent: 'uses a legacy agent or scanner', +}; +/** Issue codes shown as notes (info icon) and left out of counts. None today (UI 4.4). */ +export const NOTE_CODES: ReadonlySet<string> = new Set<string>(); +const UNLISTED_CODES = new Set(['noLibrary', 'unreachable']); + +/** Issue codes that say a library overlaps another library's folder. */ +export const OVERLAP_ISSUE_CODES: ReadonlySet<string> = new Set(['nestedLibrary', 'overlap']); + +export function isOverlapIssue(code: string): boolean { + return OVERLAP_ISSUE_CODES.has(code); +} + +/** The mapping choice of a Plex TV folder; an older answer without `choice` is read from its mapped library. */ +export function plexMappingChoice(mapping: PlexMappingState | null | undefined): PlexMappingChoice { + return mapping?.choice ?? (mapping?.mappedLibraryId ? 'library' : 'none'); +} + +export function isLibraryWide(code: string): boolean { + return Object.prototype.hasOwnProperty.call(LIBRARY_WIDE_SHORT, code); +} + +/** The issues a server card lists. */ +export function listedIssues(report: LibraryCheckServerReport): LibraryCheckIssue[] { + return report.issues.filter((issue) => !UNLISTED_CODES.has(issue.code)); +} + +export function countedIssues(report: LibraryCheckServerReport): LibraryCheckIssue[] { + return listedIssues(report).filter((issue) => !NOTE_CODES.has(issue.code)); +} + +/** Libraries that show the folder (a library inside a TV folder is an issue, not a holder). */ +export function holdingLibraries(report: LibraryCheckServerReport): LibraryCheckLibrary[] { + return report.libraries.filter((library) => library.relation !== 'inside'); +} + +export interface LibraryCheckState { + data: LibraryCheckResponse | null; + loading: boolean; + error: string | null; + lastCheckedAt: number | null; +} + +export interface ServerRef { + serverType: MediaServerType; + name: string; +} + +/** The check's servers once it answered, else the configured ones. */ +export function serversOf(check: LibraryCheckState, configured: MediaServerType[]): ServerRef[] { + const list = check.data + ? check.data.servers.map((server) => ({ serverType: server.serverType, name: server.name })) + : configured.map((serverType) => ({ serverType, name: SERVER_NAMES[serverType] })); + return [...list].sort((a, b) => SERVER_ORDER.indexOf(a.serverType) - SERVER_ORDER.indexOf(b.serverType)); +} + +export function reportFor(data: LibraryCheckResponse | null, name: string): LibraryCheckFolder | null { + return data?.folders.find((entry) => folderKey(entry.name) === folderKey(name)) ?? null; +} + +export type ServerDisplay = 'checking' | 'unchecked' | 'ok' | 'issues' | 'noLibrary' | 'fine'; + +export interface ServerStatus extends ServerRef { + display: ServerDisplay; + /** The chip word (UI 4.4) */ + word: string; + report: LibraryCheckServerReport | null; +} + +function okWord(report: LibraryCheckServerReport): string { + const holders = holdingLibraries(report); + if (holders.length === 0) return 'OK'; + return holders.length === 1 ? holders[0].name : `${holders[0].name} +${holders.length - 1}`; +} + +export function serverStatuses(folder: LibraryFolder, check: LibraryCheckState, configured: MediaServerType[]): ServerStatus[] { + const folderReport = reportFor(check.data, folder.name); + const state = folderState(folder); + return serversOf(check, configured).map((server): ServerStatus => { + const report = folderReport?.servers.find((entry) => entry.serverType === server.serverType) ?? null; + if (!report) { + const checking = check.loading && !check.error; + return { ...server, report, display: checking ? 'checking' : 'unchecked', word: checking ? 'Checking...' : 'Not checked' }; + } + if (report.status === 'unreachable') return { ...server, report, display: 'unchecked', word: 'Not checked' }; + if (report.status === 'ok') return { ...server, report, display: 'ok', word: okWord(report) }; + if (report.status === 'warning') { + const count = countedIssues(report).length; + return { ...server, report, display: 'issues', word: `${count} ${count === 1 ? 'issue' : 'issues'}` }; + } + return { ...server, report, display: needsLibrary(state) ? 'noLibrary' : 'fine', word: 'No library' }; + }); +} + +export interface FolderAttentionServer extends ServerRef { + display: 'noLibrary' | 'issues'; + issueCount: number; + /** The first issue that belongs to the folder, for the Core card's one-line message */ + firstMessage: string | null; +} + +export type AttentionItem = + | { + kind: 'library'; key: string; serverType: MediaServerType; serverName: string; libraryId: string; + libraryName: string; code: string; short: string; folders: string[]; text: string; + } + | { + kind: 'folder'; key: string; folder: string; label: string; layout: LibraryFolder['layout']; + servers: FolderAttentionServer[]; text: string; + }; + +type LibraryItem = Extract<AttentionItem, { kind: 'library' }>; +type FolderItem = Extract<AttentionItem, { kind: 'folder' }>; + +export function buildAttention(folders: LibraryFolder[], check: LibraryCheckState, configured: MediaServerType[]): AttentionItem[] { + if (!check.data) return []; + const groups = new Map<string, LibraryItem>(); + const folderItems: FolderItem[] = []; + for (const folder of folders) { + const counted: FolderAttentionServer[] = []; + for (const status of serverStatuses(folder, check, configured)) { + const { report } = status; + if (!report || status.display === 'unchecked' || status.display === 'checking') continue; + for (const issue of report.issues) { + if (!isLibraryWide(issue.code) || !issue.libraryId) continue; + const key = `${status.serverType}:${issue.libraryId}:${issue.code}`; + const libraryName = report.libraries.find((library) => library.id === issue.libraryId)?.name ?? `library ${issue.libraryId}`; + const group = groups.get(key) ?? { + kind: 'library', key, serverType: status.serverType, serverName: status.name, libraryId: issue.libraryId, + libraryName, code: issue.code, short: LIBRARY_WIDE_SHORT[issue.code], folders: [], text: '', + }; + if (!group.folders.some((name) => folderKey(name) === folderKey(folder.name))) group.folders.push(folder.name); + groups.set(key, group); + } + const own = countedIssues(report).filter((issue) => !isLibraryWide(issue.code)); + if (status.display === 'noLibrary') { + counted.push({ serverType: status.serverType, name: status.name, display: 'noLibrary', issueCount: 0, firstMessage: null }); + } else if (status.display === 'issues' && own.length > 0) { + counted.push({ + serverType: status.serverType, name: status.name, display: 'issues', + issueCount: countedIssues(report).length, firstMessage: own[0].message, + }); + } + } + if (counted.length > 0) { + const label = libraryFolderLabel(folder.name); + folderItems.push({ + kind: 'folder', key: `folder:${folderKey(folder.name)}`, folder: folder.name, label, layout: folder.layout, + servers: counted, text: `${label} (${counted.map((server) => server.name).join(', ')})`, + }); + } + } + const libraryItems = [...groups.values()] + .map((group) => ({ + ...group, + text: `${group.serverName} library ${group.libraryName} ${group.short}` + + `${group.folders.length > 1 ? ` (affects ${group.folders.length} folders)` : ''}`, + })) + .sort((a, b) => b.folders.length - a.folders.length || a.text.localeCompare(b.text)); + folderItems.sort((a, b) => b.servers.length - a.servers.length || a.label.localeCompare(b.label)); + return [...libraryItems, ...folderItems]; +} + +export type CheckStatusKind = 'none' | 'checking' | 'unchecked' | 'full' | 'partial' | 'unreachable' | 'failed'; + +export interface CheckStatus { + kind: CheckStatusKind; + /** A check is running (a re-check keeps the earlier results) */ + running: boolean; + checked: string[]; + unreachable: Array<{ name: string; error: string | null }>; + lastCheckedAt: number | null; + hasEarlierResults: boolean; + error: string | null; +} + +export function checkStatus(check: LibraryCheckState, configured: MediaServerType[]): CheckStatus { + const servers = check.data?.servers ?? []; + const base = { + running: check.loading, + checked: servers.filter((server) => server.reachable).map((server) => server.name), + unreachable: servers.filter((server) => !server.reachable).map((server) => ({ name: server.name, error: server.error })), + lastCheckedAt: check.lastCheckedAt, + hasEarlierResults: Boolean(check.data), + error: check.error, + }; + if (check.data ? servers.length === 0 : configured.length === 0 && !check.loading) return { ...base, kind: 'none' }; + if (check.error) return { ...base, kind: 'failed' }; + if (!check.data) return { ...base, kind: check.loading ? 'checking' : 'unchecked' }; + if (base.checked.length === 0) return { ...base, kind: 'unreachable' }; + return { ...base, kind: base.unreachable.length > 0 ? 'partial' : 'full' }; +} + +/** "Plex", "Plex and Jellyfin", "Plex, Jellyfin and Emby". */ +export function joinNames(names: string[], conjunction: 'and' | 'or' = 'and'): string { + if (names.length <= 1) return names[0] ?? ''; + return `${names.slice(0, -1).join(', ')} ${conjunction} ${names[names.length - 1]}`; +} + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +/** "just now", "5 min ago", "3 h ago", else the date and time in the server's zone. */ +export function timeAgo(at: number, now: number, timeZone: string | null | undefined): string { + const elapsed = Math.max(0, now - at); + if (elapsed < MINUTE_MS) return 'just now'; + if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)} min ago`; + if (elapsed < DAY_MS) return `${Math.floor(elapsed / HOUR_MS)} h ago`; + const iso = new Date(at).toISOString(); + return formatDateTimeInZone(iso, timeZone) ?? iso; +} diff --git a/client/src/utils/libraryLayouts.ts b/client/src/utils/libraryLayouts.ts index 69921de50..30e1ee229 100644 --- a/client/src/utils/libraryLayouts.ts +++ b/client/src/utils/libraryLayouts.ts @@ -35,3 +35,25 @@ export function folderKey(name: string | null | undefined): string { export function libraryFolderLabel(name: string): string { return name ? `__${name}` : 'Main folder'; } + +/** Route key of the main folder in /settings/library/:folder; `~` can't appear in a subfolder name. */ +export const MAIN_FOLDER_ROUTE_KEY = '~main'; +export const LIBRARY_FOLDERS_PATH = '/settings/library'; + +export function folderRouteKey(name: string): string { + return name ? encodeURIComponent(name) : MAIN_FOLDER_ROUTE_KEY; +} + +/** The folder a route key names ('' = main folder). Router params may arrive decoded or not. */ +export function folderFromRouteKey(key: string): string { + if (key === MAIN_FOLDER_ROUTE_KEY) return ''; + try { + return decodeURIComponent(key); + } catch { + return key; + } +} + +export function libraryFolderUrl(name: string): string { + return `${LIBRARY_FOLDERS_PATH}/${folderRouteKey(name)}`; +} diff --git a/client/src/utils/plexLibraries.ts b/client/src/utils/plexLibraries.ts index 097dacf62..fbc72b91b 100644 --- a/client/src/utils/plexLibraries.ts +++ b/client/src/utils/plexLibraries.ts @@ -6,6 +6,8 @@ export interface PlexLibrary { id: string; title: string; + /** Plex section type: movie, show, artist, photo */ + type?: string; } /** From e0fb3732659ee41fe518ae35fd4edd2c4c8bebfc Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Tue, 6 Oct 2026 21:37:19 -0700 Subject: [PATCH 11/12] feat(settings): rework core page, group the index Rebuild Settings > Core as titled sections with visible descriptions, headed by a Library folders card that shows the downloads folder, the default folder, folder counts and what needs attention. The folder controls, the subfolder dialogs and the Plex mappings table move to Library folders, and the folder field in channel, playlist, import and download settings is now "Library folder". /updateconfig keeps the stored default folder, Plex mappings and main folder layout: the Library folders endpoints are their only writers. The Settings index groups its pages and gains a Library folders row. Refs: #175 --- .../ChannelPage/ChannelSettingsDialog.tsx | 8 +- .../__tests__/ChannelSettingsDialog.story.tsx | 3 + .../__tests__/ChannelSettingsDialog.test.tsx | 52 +- .../components/ChannelTvSection.tsx | 2 +- .../TitleShows/TitleShowEditorDialog.tsx | 2 +- .../__tests__/ChannelTvSection.test.tsx | 2 +- .../common/ConfigurationSkeleton.tsx | 38 +- .../Configuration/common/SettingNote.tsx | 16 + .../Configuration/common/SettingRow.tsx | 72 + .../Configuration/common/SettingsSection.tsx | 36 + .../common/__tests__/SettingNote.test.tsx | 11 + .../common/__tests__/SettingRow.test.tsx | 20 + .../common/__tests__/SettingsSection.test.tsx | 24 + .../hooks/__tests__/useConfigSave.test.ts | 73 - .../Configuration/hooks/useConfigSave.ts | 46 +- .../src/components/Configuration/schedules.ts | 2 +- .../sections/CoreSettingsSection.tsx | 1013 ++--------- .../sections/PlexIntegrationSection.tsx | 16 +- .../sections/PlexSubfolderMappings.tsx | 260 --- .../__tests__/CoreSettingsSection.story.tsx | 68 +- .../__tests__/CoreSettingsSection.test.tsx | 1554 ++--------------- .../PlexIntegrationSection.story.tsx | 2 + .../__tests__/PlexIntegrationSection.test.tsx | 9 + .../__tests__/PlexSubfolderMappings.test.tsx | 455 ----- .../__tests__/SchedulingSection.test.tsx | 2 +- .../components/DefaultPlexLibraryDisplay.tsx | 2 +- .../components/FlatStructureDialog.tsx | 87 + .../components/LibraryFoldersCard.tsx | 296 ++++ .../components/LibraryFoldersList.tsx | 243 --- .../sections/components/PlexLibraryLabel.tsx | 9 +- .../components/VideoFilenameTemplate.tsx | 80 +- .../__tests__/FlatStructureDialog.test.tsx | 40 + .../__tests__/LibraryFoldersCard.test.tsx | 203 +++ .../__tests__/LibraryFoldersList.test.tsx | 346 ---- .../__tests__/VideoFilenameTemplate.test.tsx | 91 +- .../__tests__/libraryCardText.test.ts | 70 + .../sections/components/libraryCardText.ts | 69 + .../ManualDownload/DownloadSettingsDialog.tsx | 6 +- .../DownloadSettingsDialog.story.tsx | 3 + .../__tests__/DownloadSettingsDialog.test.tsx | 16 +- .../LibraryFolders/LibraryFoldersContext.tsx | 2 + .../__tests__/LibraryFolders.test.tsx | 13 + .../LibraryFolders/__tests__/renderPage.tsx | 1 + .../components/LibraryGuide.tsx | 3 +- .../__tests__/LibraryGuide.test.tsx | 12 +- .../__tests__/MainFolderTvDialog.test.tsx | 2 +- .../dialogs}/MainFolderTvDialog.tsx | 0 .../src/components/LibraryFolders/index.tsx | 10 +- .../components/PlaylistSettingsDialog.tsx | 6 +- client/src/components/Settings/Settings.tsx | 75 +- .../src/components/Settings/SettingsIndex.tsx | 202 ++- .../Settings/__tests__/SettingsIndex.test.tsx | 79 + .../__tests__/defaultSubfolderMove.test.ts | 18 - .../Settings/defaultSubfolderMove.ts | 15 - .../__tests__/RowSettingsPopover.test.tsx | 8 +- .../components/RowSettingsPopover.tsx | 2 +- .../components/RowSettingsSheet.tsx | 2 +- .../Subscriptions/PlaylistHelpDialog.tsx | 4 +- .../components/AddChannelSettingsDialog.tsx | 2 +- .../components/NewPlaylistSettings.tsx | 4 +- .../components/shared/AddSubfolderDialog.tsx | 37 +- .../shared/ManageSubfoldersDialog.tsx | 116 -- .../shared/SubfolderAutocomplete.tsx | 109 +- .../__tests__/AddSubfolderDialog.story.tsx | 5 +- .../__tests__/AddSubfolderDialog.test.tsx | 83 +- .../__tests__/ManageSubfoldersDialog.test.tsx | 74 - .../__tests__/SubfolderAutocomplete.story.tsx | 3 + .../__tests__/SubfolderAutocomplete.test.tsx | 211 +-- client/src/config/configSchema.ts | 6 +- client/src/hooks/__tests__/useConfig.test.ts | 32 +- .../hooks/__tests__/useSubfolderUsage.test.ts | 70 - client/src/hooks/useConfig.ts | 34 +- client/src/hooks/useLibraryCheck.ts | 5 +- client/src/hooks/useSubfolderUsage.ts | 67 - client/src/index.css | 15 - client/src/themeTokens.css | 4 - client/src/themes/playful.ts | 4 - .../src/utils/__tests__/configPatch.test.ts | 46 - client/src/utils/configPatch.ts | 36 - server/routes/__tests__/config.test.js | 63 +- server/routes/config.js | 37 +- server/routes/index.js | 1 - 82 files changed, 1973 insertions(+), 4892 deletions(-) create mode 100644 client/src/components/Configuration/common/SettingNote.tsx create mode 100644 client/src/components/Configuration/common/SettingRow.tsx create mode 100644 client/src/components/Configuration/common/SettingsSection.tsx create mode 100644 client/src/components/Configuration/common/__tests__/SettingNote.test.tsx create mode 100644 client/src/components/Configuration/common/__tests__/SettingRow.test.tsx create mode 100644 client/src/components/Configuration/common/__tests__/SettingsSection.test.tsx delete mode 100644 client/src/components/Configuration/sections/PlexSubfolderMappings.tsx delete mode 100644 client/src/components/Configuration/sections/__tests__/PlexSubfolderMappings.test.tsx create mode 100644 client/src/components/Configuration/sections/components/FlatStructureDialog.tsx create mode 100644 client/src/components/Configuration/sections/components/LibraryFoldersCard.tsx delete mode 100644 client/src/components/Configuration/sections/components/LibraryFoldersList.tsx create mode 100644 client/src/components/Configuration/sections/components/__tests__/FlatStructureDialog.test.tsx create mode 100644 client/src/components/Configuration/sections/components/__tests__/LibraryFoldersCard.test.tsx delete mode 100644 client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx create mode 100644 client/src/components/Configuration/sections/components/__tests__/libraryCardText.test.ts create mode 100644 client/src/components/Configuration/sections/components/libraryCardText.ts rename client/src/components/{Configuration/sections => LibraryFolders}/components/__tests__/MainFolderTvDialog.test.tsx (97%) rename client/src/components/{Configuration/sections/components => LibraryFolders/components/dialogs}/MainFolderTvDialog.tsx (100%) create mode 100644 client/src/components/Settings/__tests__/SettingsIndex.test.tsx delete mode 100644 client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts delete mode 100644 client/src/components/Settings/defaultSubfolderMove.ts delete mode 100644 client/src/components/shared/ManageSubfoldersDialog.tsx delete mode 100644 client/src/components/shared/__tests__/ManageSubfoldersDialog.test.tsx delete mode 100644 client/src/hooks/__tests__/useSubfolderUsage.test.ts delete mode 100644 client/src/hooks/useSubfolderUsage.ts delete mode 100644 client/src/utils/__tests__/configPatch.test.ts delete mode 100644 client/src/utils/configPatch.ts diff --git a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx index 62cacc568..6a9050242 100644 --- a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx +++ b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx @@ -842,7 +842,7 @@ function ChannelSettingsDialog({ <div> <Typography variant="subtitle2" gutterBottom style={{ fontWeight: 600 }}> - Subfolder + Library folder </Typography> <SubfolderAutocomplete mode="channel" @@ -855,18 +855,18 @@ function ChannelSettingsDialog({ }} subfolders={subfolders} defaultSubfolderDisplay={config.defaultSubfolder || null} - label="Subfolder" + label="Library folder" helperText="Choose where this channel's videos are saved" createSubfolder={createSubfolder} layoutOf={layoutOf} /> <Alert severity="info" style={{ marginTop: 8 }}> <Typography variant="caption"> - Subfolders are automatically prefixed with <code>__</code> on the filesystem. + Library folders are named with a <code>__</code> prefix on disk. </Typography> </Alert> <Typography variant="caption" color="text.secondary" style={{ marginTop: 8, display: 'block' }}> - Note: Changing the subfolder will move the channel's existing folder and files! + Changing the library folder moves the channel's existing folder and files. </Typography> </div> </div> diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx index 12ede0d2b..19a095363 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.story.tsx @@ -1,3 +1,5 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from 'storybook/test'; import { http, HttpResponse } from 'msw'; @@ -6,6 +8,7 @@ import ChannelSettingsDialog from '../ChannelSettingsDialog'; const meta: Meta<typeof ChannelSettingsDialog> = { title: 'Components/ChannelPage/ChannelSettingsDialog', component: ChannelSettingsDialog, + decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>], args: { open: true, channelId: 'chan-1', diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx index 6aacd66dd..b4eb418d6 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx @@ -1,11 +1,15 @@ import React from 'react'; -import { render, screen, waitFor, within } from '@testing-library/react'; +import { render as rtlRender, screen, waitFor, within } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import ChannelSettingsDialog from '../ChannelSettingsDialog'; import { useConfig } from '../../../hooks/useConfig'; import { DEFAULT_CONFIG } from '../../../config/configSchema'; +const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter }); + // Mock the useConfig hook const mockRefetchConfig = jest.fn(); @@ -224,7 +228,7 @@ describe('ChannelSettingsDialog', () => { }); expect(screen.getByLabelText('Channel Video Quality Override')).toBeInTheDocument(); - expect(screen.getByLabelText('Subfolder')).toBeInTheDocument(); + expect(screen.getByLabelText('Library folder')).toBeInTheDocument(); await openSettingsSection('Filters'); @@ -260,7 +264,7 @@ describe('ChannelSettingsDialog', () => { expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); - const subfolderInput = screen.getByLabelText('Subfolder'); + const subfolderInput = screen.getByLabelText('Library folder'); expect(subfolderInput).toHaveValue('__Sports'); await openSettingsSection('Filters'); @@ -289,7 +293,7 @@ describe('ChannelSettingsDialog', () => { expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); - const subfolderInput = screen.getByLabelText('Subfolder'); + const subfolderInput = screen.getByLabelText('Library folder'); await user.click(subfolderInput); // Subfolders from the hook appear as options @@ -349,7 +353,7 @@ describe('ChannelSettingsDialog', () => { expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); - expect(screen.getByLabelText('Subfolder')).toBeInTheDocument(); + expect(screen.getByLabelText('Library folder')).toBeInTheDocument(); consoleSpy.mockRestore(); }); @@ -478,11 +482,11 @@ describe('ChannelSettingsDialog', () => { expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); - const subfolderInput = screen.getByLabelText('Subfolder'); + const subfolderInput = screen.getByLabelText('Library folder'); expect(subfolderInput).toHaveValue('__Sports'); }); - test('allows adding new subfolder via Add Subfolder dialog', async () => { + test('allows adding new subfolder via Add library folder dialog', async () => { const user = userEvent.setup(); mockFetch @@ -498,34 +502,34 @@ describe('ChannelSettingsDialog', () => { }); // Open the subfolder dropdown - const subfolderInput = screen.getByLabelText('Subfolder'); + const subfolderInput = screen.getByLabelText('Library folder'); await user.click(subfolderInput); - // Click "Add Subfolder" option - const addOption = await screen.findByText('Add Subfolder'); + // Click "Add library folder" + const addOption = await screen.findByText('Add library folder'); await user.click(addOption); - // The Add Subfolder dialog should open - expect(screen.getByText('Add New Subfolder')).toBeInTheDocument(); + // The Add library folder dialog should open + expect(await screen.findByText(/Creates a Videos folder/)).toBeInTheDocument(); // Enter a new subfolder name - const dialogInput = screen.getByLabelText('Subfolder Name'); + const dialogInput = screen.getByLabelText('Library folder name'); await user.type(dialogInput, 'Gaming'); // Click Add button - const addButton = screen.getByRole('button', { name: 'Add Subfolder' }); + const addButton = screen.getByRole('button', { name: 'Add library folder' }); await user.click(addButton); // The dialog should close and the new value should be selected await waitFor(() => { - expect(screen.queryByText('Add New Subfolder')).not.toBeInTheDocument(); + expect(screen.queryByText(/Creates a Videos folder/)).not.toBeInTheDocument(); }); // The input should show the new subfolder expect(subfolderInput).toHaveValue('__Gaming'); }); - test('sends clean subfolder name when saving via Add Subfolder', async () => { + test('sends clean subfolder name when saving via Add library folder', async () => { const user = userEvent.setup(); mockFetch @@ -547,20 +551,20 @@ describe('ChannelSettingsDialog', () => { }); // Open the subfolder dropdown and add a new subfolder - const subfolderInput = screen.getByLabelText('Subfolder'); + const subfolderInput = screen.getByLabelText('Library folder'); await user.click(subfolderInput); - const addOption = await screen.findByText('Add Subfolder'); + const addOption = await screen.findByText('Add library folder'); await user.click(addOption); - const dialogInput = screen.getByLabelText('Subfolder Name'); + const dialogInput = screen.getByLabelText('Library folder name'); await user.type(dialogInput, 'Gaming'); - const addButton = screen.getByRole('button', { name: 'Add Subfolder' }); + const addButton = screen.getByRole('button', { name: 'Add library folder' }); await user.click(addButton); await waitFor(() => { - expect(screen.queryByText('Add New Subfolder')).not.toBeInTheDocument(); + expect(screen.queryByText(/Creates a Videos folder/)).not.toBeInTheDocument(); }); const saveButton = screen.getByRole('button', { name: 'Save' }); @@ -593,7 +597,7 @@ describe('ChannelSettingsDialog', () => { await waitFor(() => { expect( - screen.getByText(/Changing the subfolder will move the channel's existing folder/i) + screen.getByText(/Changing the library folder moves the channel's existing folder and files/i) ).toBeInTheDocument(); }); }); @@ -1288,7 +1292,7 @@ describe('ChannelSettingsDialog', () => { }); expect( - screen.getByText(/Subfolders are automatically prefixed with/i) + screen.getByText(/Library folders are named with a/i) ).toBeInTheDocument(); }); @@ -2194,7 +2198,7 @@ describe('ChannelSettingsDialog', () => { }); await openSettingsSection('General'); - expect(screen.getByLabelText('Subfolder')).toHaveValue('__Anime'); + expect(screen.getByLabelText('Library folder')).toHaveValue('__Anime'); expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); }); diff --git a/client/src/components/ChannelPage/components/ChannelTvSection.tsx b/client/src/components/ChannelPage/components/ChannelTvSection.tsx index a2c913b1a..5b6a7efd5 100644 --- a/client/src/components/ChannelPage/components/ChannelTvSection.tsx +++ b/client/src/components/ChannelPage/components/ChannelTvSection.tsx @@ -22,7 +22,7 @@ const HAS_DOWNLOADS_NOTE = 'This channel has downloaded videos: switching moves const SWITCH_SAVES_NOTE = 'Switching saves right away.'; const REORGANIZING_NOTE = "This channel's downloaded videos are being moved."; const DEFAULT_FOLDER_TV_NOTE = - "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; + "The default folder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; const NO_VIDEOS_FOLDER_MESSAGE = 'No library folder uses the Videos layout yet.'; type Step = 'idle' | 'pickTv' | 'pickVideos' | 'setupTv'; diff --git a/client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx b/client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx index c7c7bb7e2..573c2b1cf 100644 --- a/client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx +++ b/client/src/components/ChannelPage/components/TitleShows/TitleShowEditorDialog.tsx @@ -30,7 +30,7 @@ import { EMPTY_PATTERN, useTitleShowForm } from './useTitleShowForm'; const SYNTAX_HELP = 'Placeholders: {season}, {episode}, {title} (the episode title), {episode_end} and {part} (recognized, ' + 'not placed yet). * matches any text, a space any spaces, letters ignore case; start with ^ to match from the start of the title.'; const NO_TV_FOLDER = 'Set up a TV folder first: switch this channel to TV show above, or set a folder to TV shows in ' - + 'Settings > Core > File Structure.'; + + 'Settings > Library folders.'; const EXCLUDE_HELP = 'One per line. A title containing any of them never joins this show (another show can still take it).'; export interface TitleShowEditorDialogProps { diff --git a/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx index ce49a0639..8febdba97 100644 --- a/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx +++ b/client/src/components/ChannelPage/components/__tests__/ChannelTvSection.test.tsx @@ -221,7 +221,7 @@ describe('ChannelTvSection', () => { expect( screen.getByText( - "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show." + "The default folder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show." ) ).toBeInTheDocument(); }); diff --git a/client/src/components/Configuration/common/ConfigurationSkeleton.tsx b/client/src/components/Configuration/common/ConfigurationSkeleton.tsx index 824f37fe0..ca4881f91 100644 --- a/client/src/components/Configuration/common/ConfigurationSkeleton.tsx +++ b/client/src/components/Configuration/common/ConfigurationSkeleton.tsx @@ -5,7 +5,6 @@ import { Skeleton, Card, CardContent, - Grid, } from '../../ui'; interface ConfigurationSkeletonProps { @@ -38,30 +37,19 @@ function ConfigurationSkeleton({ compact = false }: ConfigurationSkeletonProps) </span> )} - {/* Loading skeleton for Core Settings */} - <Card elevation={2} style={{ marginBottom: 24, border: '1px solid var(--border)' }}> - <CardContent> - <Skeleton variant="text" width={150} height={32} style={{ marginBottom: 8 }} /> - <Skeleton variant="text" width={250} height={20} style={{ marginBottom: 16 }} /> - <Grid container spacing={2} style={{ marginTop: 8 }}> - <Grid item xs={12}> - <Skeleton variant="rectangular" height={56} /> - </Grid> - <Grid item xs={12} md={6}> - <Skeleton variant="rectangular" height={42} /> - </Grid> - <Grid item xs={12} md={6}> - <Skeleton variant="rectangular" height={56} /> - </Grid> - <Grid item xs={12} md={6}> - <Skeleton variant="rectangular" height={56} /> - </Grid> - <Grid item xs={12} md={6}> - <Skeleton variant="rectangular" height={56} /> - </Grid> - </Grid> - </CardContent> - </Card> + {/* Loading skeleton for Core: the Library folders card, then two titled sections */} + <div className="mb-6 flex flex-col gap-8"> + <Skeleton variant="rectangular" height={140} /> + {[1, 2].map((row) => ( + <div key={row} className="grid gap-2.5 md:grid-cols-[240px_minmax(0,1fr)] md:gap-10"> + <div> + <Skeleton variant="text" width={120} height={22} /> + <Skeleton variant="text" width={200} height={16} className="mt-1" /> + </div> + <Skeleton variant="rectangular" height={120} /> + </div> + ))} + </div> {/* Loading skeleton for Accordions */} {[1, 2, 3, 4, 5].map((index) => ( diff --git a/client/src/components/Configuration/common/SettingNote.tsx b/client/src/components/Configuration/common/SettingNote.tsx new file mode 100644 index 000000000..c296f8896 --- /dev/null +++ b/client/src/components/Configuration/common/SettingNote.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Info, Warning } from '../../../lib/icons'; +import { cn } from '../../../lib/cn'; + +/** A note inside a setting row (Core 2.3). */ +export function SettingNote({ tone, children }: { tone: 'info' | 'warning'; children: React.ReactNode }) { + const Icon = tone === 'warning' ? Warning : Info; + return ( + <p className="mt-2.5 flex items-start gap-2 text-[13px] leading-[19.5px]"> + <Icon size={16} aria-hidden="true" className={cn('mt-0.5 shrink-0', tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} /> + <span className={tone === 'warning' ? 'text-warning' : 'text-muted-foreground'}>{children}</span> + </p> + ); +} + +export default SettingNote; diff --git a/client/src/components/Configuration/common/SettingRow.tsx b/client/src/components/Configuration/common/SettingRow.tsx new file mode 100644 index 000000000..100884cfc --- /dev/null +++ b/client/src/components/Configuration/common/SettingRow.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { cn } from '../../../lib/cn'; +import { useMediaQuery } from '../../../hooks/useMediaQuery'; +import { InfoTooltip } from './InfoTooltip'; + +const PHONE_QUERY = '(max-width: 767px)'; + +/** Grows a button's clickable box to 44px without changing layout. */ +export const PHONE_HIT_AREA = '[&_button]:relative [&_button]:after:absolute [&_button]:after:-inset-2.5 [&_button]:after:content-[\'\']'; + +export const settingDescriptionId = (controlId: string) => `${controlId}-description`; + +export interface SettingRowProps { + /** Id of the form control; the label points at it */ + controlId: string; + label: React.ReactNode; + /** Always visible; wraps, never truncates */ + description?: React.ReactNode; + /** Long detail only, after the label */ + tooltip?: string; + onMobileTooltipClick?: (text: string) => void; + /** E.g. the Platform Managed chip, after the label */ + badge?: React.ReactNode; + control?: React.ReactNode; + /** link: a right-aligned text link, 44px tall on phones */ + controlSize?: 'switch' | 'select' | 'select-compact' | 'link'; + /** A select drops below the text on phones */ + stackControlOnPhone?: boolean; + /** Full-width content under the row */ + children?: React.ReactNode; + disabled?: boolean; +} + +/** Label and visible description on the left, the control on the right (Core 2.2). */ +export function SettingRow({ + controlId, label, description, tooltip, onMobileTooltipClick, badge, control, controlSize = 'switch', + stackControlOnPhone = false, children, disabled = false, +}: SettingRowProps) { + const phone = useMediaQuery(PHONE_QUERY); + const isSelect = controlSize === 'select' || controlSize === 'select-compact'; + const stacked = phone && stackControlOnPhone && isSelect; + const controlClass = cn( + 'shrink-0 pt-0.5', + controlSize === 'switch' && phone && PHONE_HIT_AREA, + controlSize === 'link' && phone && '[&>*]:inline-flex [&>*]:min-h-[44px] [&>*]:items-center', + isSelect && (stacked ? 'w-full [&>*]:min-h-[44px] [&>*]:w-full' : phone ? 'w-28 [&>*]:min-h-[44px] [&>*]:w-full' : 'w-60 [&>*]:w-full'), + ); + return ( + <div className={cn(phone ? 'px-4 py-3.5' : 'px-5 py-4', disabled && 'opacity-60')}> + <div className={cn('flex justify-between', stacked ? 'flex-col gap-3' : 'items-start', phone ? 'gap-3' : 'gap-6')}> + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-1.5"> + <label htmlFor={controlId} className="text-sm font-medium leading-5 text-foreground">{label}</label> + {tooltip && ( + <span className={cn('inline-flex', phone && PHONE_HIT_AREA)}> + <InfoTooltip text={tooltip} onMobileClick={onMobileTooltipClick} /> + </span> + )} + {badge} + </div> + {description && ( + <p id={settingDescriptionId(controlId)} className="mt-1 text-[13px] leading-[19.5px] text-muted-foreground">{description}</p> + )} + </div> + {control && <div className={controlClass}>{control}</div>} + </div> + {children && <div className="mt-2.5">{children}</div>} + </div> + ); +} + +export default SettingRow; diff --git a/client/src/components/Configuration/common/SettingsSection.tsx b/client/src/components/Configuration/common/SettingsSection.tsx new file mode 100644 index 000000000..f7e5208e0 --- /dev/null +++ b/client/src/components/Configuration/common/SettingsSection.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { cn } from '../../../lib/cn'; +import { useContainerWidth } from '../../../hooks/useContainerWidth'; + +const TWO_COLUMN_MIN_WIDTH = 720; + +export interface SettingsSectionProps { + /** Anchor id; also prefixes the heading id */ + id: string; + title: string; + description?: React.ReactNode; + /** Extra content under the description (chips, notes) */ + aside?: React.ReactNode; + children: React.ReactNode; +} + +/** A settings group: title and description beside (or above) a card of rows (Core 2.1). */ +export function SettingsSection({ id, title, description, aside, children }: SettingsSectionProps) { + const [measureRef, width] = useContainerWidth<HTMLDivElement>(); + const columns = width !== null && width >= TWO_COLUMN_MIN_WIDTH; + return ( + <section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-24"> + <div ref={measureRef} data-testid={`settings-section-${id}`} data-layout={columns ? 'columns' : 'stacked'} + className={cn(columns ? 'grid grid-cols-[240px_minmax(0,1fr)] items-start gap-10' : 'flex flex-col gap-2.5')}> + <div> + <h2 id={`${id}-title`} className="font-display text-base font-semibold leading-[22px]">{title}</h2> + {description && <p className="mt-1 text-[13px] leading-[19.5px] text-muted-foreground">{description}</p>} + {aside && <div className="mt-2.5">{aside}</div>} + </div> + <div className="divide-y divide-border/60 rounded-ui border border-border bg-card">{children}</div> + </div> + </section> + ); +} + +export default SettingsSection; diff --git a/client/src/components/Configuration/common/__tests__/SettingNote.test.tsx b/client/src/components/Configuration/common/__tests__/SettingNote.test.tsx new file mode 100644 index 000000000..49c57f2e9 --- /dev/null +++ b/client/src/components/Configuration/common/__tests__/SettingNote.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SettingNote } from '../SettingNote'; + +describe('SettingNote', () => { + test('renders its text in both tones', () => { + render(<><SettingNote tone="info">Info text</SettingNote><SettingNote tone="warning">Warning text</SettingNote></>); + expect(screen.getByText('Info text')).toBeInTheDocument(); + expect(screen.getByText('Warning text')).toHaveClass('text-warning'); + }); +}); diff --git a/client/src/components/Configuration/common/__tests__/SettingRow.test.tsx b/client/src/components/Configuration/common/__tests__/SettingRow.test.tsx new file mode 100644 index 000000000..db5e28ccf --- /dev/null +++ b/client/src/components/Configuration/common/__tests__/SettingRow.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SettingRow, settingDescriptionId } from '../SettingRow'; +import { Switch } from '../../../ui'; + +describe('SettingRow', () => { + test('labels its control and describes it', () => { + render( + <SettingRow controlId="subtitles" label="Subtitles" description="SRT files when available." + control={<Switch id="subtitles" aria-describedby={settingDescriptionId('subtitles')} checked={false} />} /> + ); + expect(screen.getByRole('checkbox', { name: 'Subtitles' })).toHaveAccessibleDescription('SRT files when available.'); + }); + + test('renders children under the row and a badge after the label', () => { + render(<SettingRow controlId="x" label="X" badge={<span>Platform Managed</span>}><p>nested</p></SettingRow>); + expect(screen.getByText('Platform Managed')).toBeInTheDocument(); + expect(screen.getByText('nested')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Configuration/common/__tests__/SettingsSection.test.tsx b/client/src/components/Configuration/common/__tests__/SettingsSection.test.tsx new file mode 100644 index 000000000..c5a0eefe6 --- /dev/null +++ b/client/src/components/Configuration/common/__tests__/SettingsSection.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SettingsSection } from '../SettingsSection'; +import { useContainerWidth } from '../../../../hooks/useContainerWidth'; + +jest.mock('../../../../hooks/useContainerWidth', () => ({ useContainerWidth: jest.fn() })); + +describe('SettingsSection', () => { + test('is a labelled section with its anchor id', () => { + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 900]); + render(<SettingsSection id="naming" title="Naming" description="How files are named."><div>row</div></SettingsSection>); + expect(screen.getByRole('region', { name: 'Naming' })).toHaveAttribute('id', 'naming'); + expect(screen.getByText('How files are named.')).toBeInTheDocument(); + }); + + test('uses two columns from 720px and stacks below', () => { + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 900]); + const { rerender } = render(<SettingsSection id="a" title="A"><div>row</div></SettingsSection>); + expect(screen.getByTestId('settings-section-a')).toHaveAttribute('data-layout', 'columns'); + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 500]); + rerender(<SettingsSection id="a" title="A"><div>row</div></SettingsSection>); + expect(screen.getByTestId('settings-section-a')).toHaveAttribute('data-layout', 'stacked'); + }); +}); diff --git a/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts b/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts index 557e2a413..6c1831eca 100644 --- a/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts +++ b/client/src/components/Configuration/hooks/__tests__/useConfigSave.test.ts @@ -1209,76 +1209,3 @@ test('schedule errors preserve the draft and clear when the affected field is ed act(() => result.current.clearFieldErrors({ autoRemovalFrequency: '0 18 * * *' })); expect(result.current.fieldErrors.autoRemovalFrequency).toBeUndefined(); }); - -test('hands a default subfolder change that moves downloads to the reorganize instead of failing', async () => { - const setSnackbar = jest.fn(); - const change = { type: 'defaultSubfolder', value: 'TV' }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - json: async () => ({ error: 'Review the move', reorganizeRequired: true, change }), - }); - const { result } = renderHook(() => useConfigSave({ - token: 'token', config: DEFAULT_CONFIG, setInitialConfig: jest.fn(), setSnackbar, - hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), - })); - - await act(async () => { expect(await result.current.saveConfig()).toBe(false); }); - - expect(result.current.reorganizeChange).toEqual(change); - expect(setSnackbar).not.toHaveBeenCalled(); - act(() => result.current.clearReorganizeChange()); - expect(result.current.reorganizeChange).toBeNull(); -}); - -test('reads the saved default subfolder back when the reorganize dialog closes', async () => { - const setInitialConfig = jest.fn(); - const change = { type: 'defaultSubfolder', value: 'TV' }; - (global.fetch as jest.Mock) - .mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'Review the move', reorganizeRequired: true, change }) }) - // The move was undone on the server: the default is back to Kids. - .mockResolvedValueOnce({ ok: true, json: async () => ({ defaultSubfolder: 'Kids' }) }); - const { result } = renderHook(() => useConfigSave({ - token: 'token', config: DEFAULT_CONFIG, setInitialConfig, setSnackbar: jest.fn(), - hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), - })); - await act(async () => { await result.current.saveConfig(); }); - - let saved: string | null = null; - await act(async () => { saved = await result.current.finishReorganize(); }); - - expect(saved).toBe('Kids'); - expect(result.current.reorganizeChange).toBeNull(); - expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe('/getconfig'); - const updater = setInitialConfig.mock.calls[0][0]; - expect(updater({ ...DEFAULT_CONFIG, defaultSubfolder: 'TV' })).toMatchObject({ defaultSubfolder: 'Kids' }); -}); - -test('reads the saved default subfolder back for a move that ended after the dialog closed', async () => { - const setInitialConfig = jest.fn(); - (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => ({ defaultSubfolder: 'Kids' }) }); - const { result } = renderHook(() => useConfigSave({ - token: 'token', config: DEFAULT_CONFIG, setInitialConfig, setSnackbar: jest.fn(), - hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), - })); - - let saved: string | null = null; - await act(async () => { saved = await result.current.readBackDefaultSubfolder(); }); - - expect(saved).toBe('Kids'); - expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe('/getconfig'); - const updater = setInitialConfig.mock.calls[0][0]; - expect(updater({ ...DEFAULT_CONFIG, defaultSubfolder: 'TV' })).toMatchObject({ defaultSubfolder: 'Kids' }); -}); - -test('finishing without a pending reorganize reads nothing', async () => { - const { result } = renderHook(() => useConfigSave({ - token: 'token', config: DEFAULT_CONFIG, setInitialConfig: jest.fn(), setSnackbar: jest.fn(), - hasPlexServerConfigured: false, checkPlexConnection: jest.fn(), - })); - - let saved: string | null = 'x'; - await act(async () => { saved = await result.current.finishReorganize(); }); - - expect(saved).toBeNull(); - expect(global.fetch).not.toHaveBeenCalled(); -}); diff --git a/client/src/components/Configuration/hooks/useConfigSave.ts b/client/src/components/Configuration/hooks/useConfigSave.ts index 790f63c26..6d9764805 100644 --- a/client/src/components/Configuration/hooks/useConfigSave.ts +++ b/client/src/components/Configuration/hooks/useConfigSave.ts @@ -2,8 +2,6 @@ import { useCallback, useState } from 'react'; import { SCHEDULE_FIELDS, ScheduleFieldErrors } from '../schedules'; import { ConfigState, SnackbarState } from '../types'; import { CONFIG_UPDATED_EVENT } from '../../../hooks/useConfig'; -import { ReorganizeChange } from '../../../types/reorganize'; -import { reorganizeChangeOf } from '../../shared/Reorganize/reorganizeErrors'; interface UseConfigSaveParams { token: string | null; @@ -17,8 +15,6 @@ interface UseConfigSaveParams { interface SaveFailure { error: string; fieldErrors: ScheduleFieldErrors; - /** A default subfolder change that moves downloads, to review in the reorganize dialog */ - reorganizeChange: ReorganizeChange | null; } async function getSaveError(response: Response): Promise<SaveFailure> { @@ -29,12 +25,12 @@ async function getSaveError(response: Response): Promise<SaveFailure> { if (typeof body?.fieldErrors?.[key] === 'string') fieldErrors[key] = body.fieldErrors[key]; } if (typeof body?.error === 'string' && body.error.trim()) { - return { error: body.error, fieldErrors, reorganizeChange: reorganizeChangeOf(body) }; + return { error: body.error, fieldErrors }; } } catch { // Fall through when the server didn't return JSON. } - return { error: 'Failed to save configuration', fieldErrors: {}, reorganizeChange: null }; + return { error: 'Failed to save configuration', fieldErrors: {} }; } export const useConfigSave = ({ @@ -47,36 +43,6 @@ export const useConfigSave = ({ }: UseConfigSaveParams) => { const [isSaving, setIsSaving] = useState(false); const [fieldErrors, setFieldErrors] = useState<ScheduleFieldErrors>({}); - const [reorganizeChange, setReorganizeChange] = useState<ReorganizeChange | null>(null); - const clearReorganizeChange = useCallback(() => setReorganizeChange(null), []); - - /** - * Read the saved default subfolder back into the saved baseline: the server - * undoes a reorganize's change when no video could be moved, so it is read - * rather than assumed, when the review closes and again when the move ends. - * Resolves to the saved value, or null when the read failed. - */ - const readBackDefaultSubfolder = useCallback(async (): Promise<string | null> => { - if (!token) return null; - try { - const response = await fetch('/getconfig', { headers: { 'x-access-token': token } }); - if (!response.ok) return null; - const data = await response.json(); - const saved = typeof data?.defaultSubfolder === 'string' ? data.defaultSubfolder : ''; - setInitialConfig((current) => (current ? { ...current, defaultSubfolder: saved } : current)); - return saved; - } catch { - return null; - } - }, [token, setInitialConfig]); - - /** Close out the reorganize dialog, reading the saved default back when it was for one. */ - const finishReorganize = useCallback(async (): Promise<string | null> => { - const change = reorganizeChange; - setReorganizeChange(null); - if (!change || change.type !== 'defaultSubfolder') return null; - return readBackDefaultSubfolder(); - }, [reorganizeChange, readBackDefaultSubfolder]); const clearFieldErrors = (updates: Partial<ConfigState>) => { setFieldErrors((current) => { const next = { ...current }; @@ -102,10 +68,6 @@ export const useConfigSave = ({ if (!response.ok) { const failure = await getSaveError(response); setFieldErrors(failure.fieldErrors); - if (failure.reorganizeChange) { - setReorganizeChange(failure.reorganizeChange); - return false; - } setSnackbar({ open: true, message: failure.error, @@ -152,9 +114,5 @@ export const useConfigSave = ({ isSaving, fieldErrors, clearFieldErrors, - reorganizeChange, - clearReorganizeChange, - finishReorganize, - readBackDefaultSubfolder, }; }; diff --git a/client/src/components/Configuration/schedules.ts b/client/src/components/Configuration/schedules.ts index 731a019c9..f7fe32052 100644 --- a/client/src/components/Configuration/schedules.ts +++ b/client/src/components/Configuration/schedules.ts @@ -18,7 +18,7 @@ export const SCHEDULE_FIELDS = [ group: 'sync', label: 'Automatic downloads', description: 'Check enabled channels and playlists for new videos and download them.', - settingsPath: 'core', + settingsPath: 'core#downloads', settingsLabel: 'Core settings', enabledKey: 'channelAutoDownload', disabledText: 'Automatic downloads are off, so this schedule is idle until you turn them on.', diff --git a/client/src/components/Configuration/sections/CoreSettingsSection.tsx b/client/src/components/Configuration/sections/CoreSettingsSection.tsx index 60d0435f9..affb4126c 100644 --- a/client/src/components/Configuration/sections/CoreSettingsSection.tsx +++ b/client/src/components/Configuration/sections/CoreSettingsSection.tsx @@ -1,48 +1,20 @@ -import React, { ChangeEvent, useState } from 'react'; -import { - SelectChangeEvent, - Accordion, - AccordionSummary, - AccordionDetails, - FormControl, - InputLabel, - Select, - MenuItem, - FormControlLabel, - TextField, - Grid, - Box, - Chip, - Switch, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, - Button, - Collapse, - CircularProgress, - Link, - Typography, -} from '../../ui'; -import { ConfigurationCard } from '../common/ConfigurationCard'; -import { InfoTooltip } from '../common/InfoTooltip'; +import React, { useEffect, useState } from 'react'; +import { Link as RouterLink, useLocation } from 'react-router-dom'; +import { LIBRARY_FOLDERS_PATH } from '../../../utils/libraryLayouts'; +import { Chip, MenuItem, Select, SelectChangeEvent, Switch } from '../../ui'; +import { Film, Info } from '../../../lib/icons'; +import { useMediaQuery } from '../../../hooks/useMediaQuery'; +import { cn } from '../../../lib/cn'; import SubtitleLanguageSelector from '../SubtitleLanguageSelector'; +import { SettingsSection } from '../common/SettingsSection'; +import { SettingRow, settingDescriptionId } from '../common/SettingRow'; +import { SettingNote } from '../common/SettingNote'; +import { LibraryFoldersCard } from './components/LibraryFoldersCard'; +import { FlatStructureDialog } from './components/FlatStructureDialog'; +import { ScheduleSummary } from './components/ScheduleSummary'; import { VideoFilenameTemplate } from './components/VideoFilenameTemplate'; -import { LibraryFoldersList } from './components/LibraryFoldersList'; -import { SubfolderAutocomplete } from '../../shared/SubfolderAutocomplete'; -import { ManageSubfoldersDialog } from '../../shared/ManageSubfoldersDialog'; -import { AddSubfolderDialog } from '../../shared/AddSubfolderDialog'; -import { Plus as AddIcon, Settings as SettingsIcon } from '../../../lib/icons'; -import { useSubfolders } from '../../../hooks/useSubfolders'; -import { useLibraryFolders } from '../../../hooks/useLibraryFolders'; -import { ConfigState, DeploymentEnvironment, PlatformManagedState } from '../types'; import { getChannelFilesOptions } from '../helpers'; -import { ScheduleSummary } from './components/ScheduleSummary'; - -const DEFAULT_LAYOUT_CHANGE_NOTE = - 'One of these folders saves videos and the other saves TV shows, so the downloaded videos of the channels that use ' - + 'the default subfolder move. When you save, you review the move first.'; +import { ConfigState, DeploymentEnvironment, PlatformManagedState } from '../types'; interface CoreSettingsSectionProps { config: ConfigState; @@ -55,798 +27,181 @@ interface CoreSettingsSectionProps { onFilenameTemplatePreviewSuccess?: (prefix: string) => void; } -export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ - config, - deploymentEnvironment, - isPlatformManaged, - onConfigChange, - onMobileTooltipClick, - token, - filenameTemplateSaveRequirement, - onFilenameTemplatePreviewSuccess, -}) => { - // Fetch available subfolders - const { subfolders, loading: subfoldersLoading, createSubfolder } = useSubfolders(token); - const libraryFolders = useLibraryFolders(token); - const { layoutOf } = libraryFolders; - - // State for confirmation dialog when setting default subfolder - const [manageOpen, setManageOpen] = useState(false); - const [addSubfolderOpen, setAddSubfolderOpen] = useState(false); - const [pendingDefaultSubfolder, setPendingDefaultSubfolder] = useState<string | null>(null); - // True when the pending value came from the Add Subfolder dialog (already persisted); - // the confirmation dialog copy changes so Cancel doesn't read as undoing the add. - const [pendingIsNewSubfolder, setPendingIsNewSubfolder] = useState(false); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [affectedChannels, setAffectedChannels] = useState<{ count: number; channelNames: string[] }>({ count: 0, channelNames: [] }); - const [loadingAffectedChannels, setLoadingAffectedChannels] = useState(false); - const [showAffectedList, setShowAffectedList] = useState(false); - - // Handle default subfolder change with confirmation - const handleDefaultSubfolderChange = async ( - newValue: string | null, - meta?: { isNewlyCreated?: boolean } - ) => { - const currentValue = config.defaultSubfolder || ''; - const newValueNormalized = newValue || ''; - - // No change - if (currentValue === newValueNormalized) { - return; - } - - // Show dialog immediately with loading state - setPendingDefaultSubfolder(newValue); - setPendingIsNewSubfolder(meta?.isNewlyCreated === true); - setShowConfirmDialog(true); - setLoadingAffectedChannels(true); - setAffectedChannels({ count: 0, channelNames: [] }); - - // Fetch affected channels count - try { - const response = await fetch('/api/channels/using-default-subfolder', { - headers: { 'x-access-token': token || '' }, - }); - if (response.ok) { - const data = await response.json(); - setAffectedChannels(data); - } - } catch (err) { - console.error('Failed to fetch affected channels:', err); - setAffectedChannels({ count: 0, channelNames: [] }); - } finally { - setLoadingAffectedChannels(false); - } - }; - - // Between a Videos and a TV folder, saving moves the downloaded videos of the channels on the default. - const defaultLayoutChanges = pendingDefaultSubfolder !== null - && layoutOf(config.defaultSubfolder || '') !== layoutOf(pendingDefaultSubfolder || ''); - - const handleConfirmDefaultSubfolder = () => { - onConfigChange({ defaultSubfolder: pendingDefaultSubfolder || '' }); - setShowConfirmDialog(false); - setPendingDefaultSubfolder(null); - setPendingIsNewSubfolder(false); - setShowAffectedList(false); - }; +type BooleanConfigKey = { [K in keyof ConfigState]: ConfigState[K] extends boolean ? K : never }[keyof ConfigState]; - const handleCancelDefaultSubfolder = () => { - setShowConfirmDialog(false); - setPendingDefaultSubfolder(null); - setPendingIsNewSubfolder(false); - setShowAffectedList(false); - }; - - // Page-level Add Subfolder action: persist the new name, then offer to set - // it as the default via the confirmation dialog - const handleAddSubfolderFromPage = (name: string) => { - setAddSubfolderOpen(false); - createSubfolder(name).catch((err) => { - console.error('Failed to persist subfolder:', err); - }); - handleDefaultSubfolderChange(name, { isNewlyCreated: true }); - }; - - const [pendingFlatDefault, setPendingFlatDefault] = useState<boolean | null>(null); - const [showFlatConfirmDialog, setShowFlatConfirmDialog] = useState(false); - const [flatAffectedChannels, setFlatAffectedChannels] = useState<{ count: number; channelNames: string[] } | null>(null); - const [loadingFlatAffectedChannels, setLoadingFlatAffectedChannels] = useState(false); - const [showFlatAffectedList, setShowFlatAffectedList] = useState(false); - - const handleFlatDefaultChange = async (event: ChangeEvent<HTMLInputElement>) => { - const newValue = event.target.checked; - if (newValue === config.defaultSkipVideoFolder) { - return; - } - - setPendingFlatDefault(newValue); - setShowFlatConfirmDialog(true); - setLoadingFlatAffectedChannels(true); - setFlatAffectedChannels(null); - - try { - const response = await fetch('/api/channels/using-global-file-structure', { - headers: { 'x-access-token': token || '' }, - }); - if (response.ok) { - const data = await response.json(); - setFlatAffectedChannels(data); - } - } catch (err) { - console.error('Failed to fetch affected channels:', err); - setFlatAffectedChannels(null); - } finally { - setLoadingFlatAffectedChannels(false); - } - }; - - const handleConfirmFlatDefault = () => { - onConfigChange({ defaultSkipVideoFolder: pendingFlatDefault === true }); - setShowFlatConfirmDialog(false); - setPendingFlatDefault(null); - setShowFlatAffectedList(false); - }; - - const handleCancelFlatDefault = () => { - setShowFlatConfirmDialog(false); - setPendingFlatDefault(null); - setShowFlatAffectedList(false); - }; - - const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => { - const { name, value } = event.target; - let parsedValue: any = value; - - if (name === 'channelFilesToDownload') { - parsedValue = Number(value); - } - - onConfigChange({ [name]: parsedValue }); - }; - - const handleCheckboxChange = (event: ChangeEvent<HTMLInputElement>) => { - onConfigChange({ [event.target.name]: event.target.checked }); - }; - - const handleChannelFilesChange = (event: SelectChangeEvent<string>) => { - onConfigChange({ channelFilesToDownload: Number(event.target.value) }); - }; +const PHONE_QUERY = '(max-width: 767px)'; +const RESOLUTIONS = [['2160', '4K (2160p)'], ['1440', '1440p'], ['1080', '1080p'], ['720', '720p'], ['480', '480p'], ['360', '360p']]; +const CODECS = [['default', 'Default (no preference)'], ['h264', 'H.264/AVC (best compatibility)'], ['h265', 'H.265/HEVC (balanced)']]; +const MEDIA_FILES: Array<{ key: 'writeVideoNfoFiles' | 'writeChannelPosters' | 'writeVideoFanart' | 'writeBackdropImages'; label: string; description: string }> = [ + { key: 'writeVideoNfoFiles', label: 'Video .nfo files', description: 'Metadata for Kodi, Jellyfin and Emby. Episodes in TV shows folders always get one.' }, + { key: 'writeChannelPosters', label: 'Channel poster.jpg', description: 'Copies the channel thumbnail into each channel folder.' }, + { key: 'writeVideoFanart', label: 'Video fanart', description: 'A -fanart.jpg per video. Some Plex clients (NVIDIA Shield) use it as the background.' }, + { key: 'writeBackdropImages', label: 'Backdrop images', description: 'A backdrop.jpg in video and channel folders, for Jellyfin and Emby.' }, +]; +const NAMING_TV_NOTE = 'TV shows folders always use Season folders, SxxEyy file names and plain episode titles.'; +/** Settings > Core: downloads, media server files, naming, interface, advanced (Core spec). */ +export const CoreSettingsSection: React.FC<CoreSettingsSectionProps> = ({ + config, deploymentEnvironment, isPlatformManaged, onConfigChange, onMobileTooltipClick, token, + filenameTemplateSaveRequirement, onFilenameTemplatePreviewSuccess, +}) => { + const { hash } = useLocation(); + const phone = useMediaQuery(PHONE_QUERY); + const [pendingFlat, setPendingFlat] = useState<boolean | null>(null); + const elfhosted = deploymentEnvironment.platform?.toLowerCase() === 'elfhosted'; + + useEffect(() => { + const id = hash.slice(1); + if (!id) return; + document.getElementById(id)?.scrollIntoView?.({ block: 'start' }); + }, [hash]); + + const toggle = (key: BooleanConfigKey) => (event: React.ChangeEvent<HTMLInputElement>) => onConfigChange({ [key]: event.target.checked }); + const switchFor = (key: BooleanConfigKey, extra: Partial<React.ComponentProps<typeof Switch>> = {}) => ( + <Switch id={key} name={key} checked={config[key]} onChange={toggle(key)} aria-describedby={settingDescriptionId(key)} {...extra} /> + ); + const describedBy = (controlId: string) => ({ 'aria-describedby': settingDescriptionId(controlId) }); return ( - <ConfigurationCard - title="Core Settings" - > - <Grid container spacing={2} className="mt-2"> - <Grid item xs={12}> - <Accordion defaultExpanded style={{ border: 'var(--border-weight) solid var(--border)', borderRadius: 'var(--radius-ui)' }}> - <AccordionSummary> - <Typography variant="subtitle2" style={{ fontWeight: 700 }}> - General Settings - </Typography> - </AccordionSummary> - <AccordionDetails> - <Grid container spacing={3} alignItems="center"> - <Grid item xs={12} md={6}> - <Box className="flex items-center"> - <FormControlLabel - control={ - <Switch - name="channelVideosHotLoad" - checked={config.channelVideosHotLoad} - onChange={handleCheckboxChange} - /> - } - label="Enable Hot Loading" - /> - <InfoTooltip - text="When enabled, channel lists, channel videos, and download history use infinite hot loading. When disabled, they use page-by-page controls." - onMobileClick={onMobileTooltipClick} - /> - </Box> - </Grid> - - <Grid item xs={12} md={6}> - <Box className="flex items-center"> - <FormControlLabel - control={ - <Switch - name="subtitlesEnabled" - checked={config.subtitlesEnabled} - onChange={handleCheckboxChange} - /> - } - label="Enable Subtitle Downloads" - /> - <InfoTooltip - text="Download subtitles in SRT format when available. Manual subtitles are preferred, with auto-generated subtitles as fallback." - onMobileClick={onMobileTooltipClick} - /> - </Box> - </Grid> - - <Grid item xs={12} md={6}> - <Box className="flex items-center"> - <FormControlLabel - control={ - <Switch - name="channelAutoDownload" - checked={config.channelAutoDownload} - onChange={handleCheckboxChange} - /> - } - label="Enable Automatic Downloads" - /> - <InfoTooltip - text="Globally enable or disable automatic scheduled downloading of videos from your channels and playlists. Only enabled channel tabs and auto-download enabled playlists will be checked and downloaded." - onMobileClick={onMobileTooltipClick} - /> - </Box> - </Grid> - - {config.subtitlesEnabled && ( - <Grid item xs={12} md={6}> - <Box className="flex items-start"> - <SubtitleLanguageSelector - value={config.subtitleLanguage} - onChange={(value) => onConfigChange({ subtitleLanguage: value })} - /> - <Box className="flex items-center min-h-[48px] mt-5"> - <InfoTooltip - text="Select one or more subtitle languages. Subtitles will be downloaded when available; videos without subtitles will still download successfully." - onMobileClick={onMobileTooltipClick} - /> - </Box> - </Box> - </Grid> - )} - </Grid> - </AccordionDetails> - </Accordion> - </Grid> - - <Grid item xs={12}> - <Accordion defaultExpanded style={{ border: 'var(--border-weight) solid var(--border)', borderRadius: 'var(--radius-ui)' }}> - <AccordionSummary> - <Typography variant="subtitle2" style={{ fontWeight: 700 }}> - Download Settings - </Typography> - </AccordionSummary> - <AccordionDetails> - <Grid container spacing={2}> - <Grid item xs={12} md={6}> - <ScheduleSummary scheduleKey="channelDownloadFrequency" value={config.channelDownloadFrequency} /> - </Grid> - - <Grid item xs={12} md={6}> - <FormControl fullWidth> - <InputLabel>Files to Download per Channel/Playlist</InputLabel> - <Box className="flex items-center gap-1"> - <Select - value={config.channelFilesToDownload} - onChange={handleChannelFilesChange} - label="Videos to Download per Channel Tab" - className="flex-1 min-w-0" - > - {getChannelFilesOptions(config.channelFilesToDownload).map(count => ( - <MenuItem key={count} value={count}> - {count} {count === 1 ? 'video' : 'videos'} - </MenuItem> - ))} - </Select> - <InfoTooltip - text="How many videos Youtarr will attempt to download per channel tab and per playlist when downloads run (channels: newest uploads; playlists: most recently added). Already downloaded videos will be skipped." - onMobileClick={onMobileTooltipClick} - /> - </Box> - </FormControl> - </Grid> - - <Grid item xs={12} md={6}> - <FormControl fullWidth> - <InputLabel>Preferred Resolution</InputLabel> - <Box className="flex items-center gap-1"> - <Select - value={config.preferredResolution} - onChange={(e: SelectChangeEvent<string>) => - onConfigChange({ preferredResolution: e.target.value }) - } - label="Preferred Resolution" - className="flex-1 min-w-0" - > - <MenuItem value="2160">4K (2160p)</MenuItem> - <MenuItem value="1440">1440p</MenuItem> - <MenuItem value="1080">1080p</MenuItem> - <MenuItem value="720">720p</MenuItem> - <MenuItem value="480">480p</MenuItem> - <MenuItem value="360">360p</MenuItem> - </Select> - <InfoTooltip - text="The resolution we will try to download from YouTube. Note that this is not guaranteed as YouTube may not have your preferred resolution available. YouTube only provides H.264 MP4 up to 1080p. Selecting 1440p or 2160p (4K) will use VP9 or AV1 (remuxed into MP4), which older Plex clients (Apple TV HD, iOS, older Rokus) may need to transcode." - onMobileClick={onMobileTooltipClick} - /> - </Box> - {(config.preferredResolution === '1440' || config.preferredResolution === '2160') && ( - <Box component="span" className="text-xs text-muted-foreground"> - 1440p+ uses VP9/AV1 (remuxed into MP4). Older Plex clients without native VP9/AV1 decode may transcode. Select H.264 codec below for best compatibility (caps at 1080p). - </Box> - )} - </FormControl> - </Grid> - - <Grid item xs={12} md={6}> - <FormControl fullWidth> - <InputLabel>Preferred Video Codec</InputLabel> - <Box className="flex items-center gap-1"> - <Select - value={config.videoCodec} - onChange={(e: SelectChangeEvent<string>) => - onConfigChange({ videoCodec: e.target.value }) - } - label="Preferred Video Codec" - className="flex-1 min-w-0" - > - <MenuItem value="default">Default (No Preference)</MenuItem> - <MenuItem value="h264">H.264/AVC (Best Compatibility)</MenuItem> - <MenuItem value="h265">H.265/HEVC (Balanced)</MenuItem> - </Select> - <InfoTooltip - text="Select your preferred video codec. Youtarr will download this codec when available, and fall back if it is not. H.264 is recommended for Apple TV and maximum device compatibility, but YouTube does not provide H.264 above 1080p so selecting it effectively caps downloads at 1080p regardless of the resolution preference above. Default lets YouTube pick the best codec (typically VP9 or AV1 at 1440p+)." - onMobileClick={onMobileTooltipClick} - /> - </Box> - <Box component="span" className="text-xs text-muted-foreground"> - Note: H.264 offers maximum compatibility (Apple TV HD, iOS, older Rokus direct-play) but YouTube caps H.264 at 1080p, so it will override any 1440p/2160p preference. - </Box> - </FormControl> - </Grid> - - <Grid item xs={12}> - <Accordion style={{ border: 'var(--border-weight) solid var(--border)', borderRadius: 'var(--radius-ui)' }}> - <AccordionSummary> - <Typography variant="body2" style={{ fontWeight: 600 }}> - Jellyfin / Kodi / Emby Setting Information - </Typography> - </AccordionSummary> - <AccordionDetails> - <Typography variant="body2" style={{ marginBottom: 8 }}> - Control generation of metadata and artwork files that help Kodi, Emby and Jellyfin index your downloads cleanly. - </Typography> - <Typography variant="body2" style={{ fontWeight: 500, marginBottom: 8 }}> - For best results: - </Typography> - <Typography variant="body2"> - • Add your download library as Content Type: <strong>Movies</strong> - <br /> - • Under Metadata Readers/Savers, select <strong>Nfo</strong> to read the .nfo files - <br /> - • Uncheck all metadata downloaders since we provide metadata via .nfo files - </Typography> - </AccordionDetails> - </Accordion> - </Grid> - - <Grid item xs={12} md={6} className="mt-3"> - <FormControl> - <FormControlLabel - control={ - <Switch - name="writeVideoNfoFiles" - checked={config.writeVideoNfoFiles} - onChange={handleCheckboxChange} - /> - } - label={ - <Box className="flex items-center"> - Generate video .nfo files - <InfoTooltip - text="Create .nfo metadata alongside each download so Kodi, Emby and Jellyfin can import videos with full details." - onMobileClick={onMobileTooltipClick} - /> - </Box> - } - /> - </FormControl> - </Grid> - - <Grid item xs={12} md={6} className="mt-3"> - <FormControl> - <FormControlLabel - control={ - <Switch - name="writeChannelPosters" - checked={config.writeChannelPosters} - onChange={handleCheckboxChange} - /> - } - label={ - <Box className="flex items-center"> - Copy channel poster.jpg files - <InfoTooltip - text="Copy channel thumbnails into each channel folder as poster.jpg for media server compatibility." - onMobileClick={onMobileTooltipClick} - /> - </Box> - } - /> - </FormControl> - </Grid> - - <Grid item xs={12} md={6} className="mt-3"> - <FormControl> - <FormControlLabel - control={ - <Switch - name="writeVideoFanart" - checked={config.writeVideoFanart} - onChange={handleCheckboxChange} - /> - } - label={ - <Box className="flex items-center"> - Create video fanart files - <InfoTooltip - text="Create -fanart.jpg files for each video with the video thumbnail. Some Plex clients like NVIDIA Shield use this as the background preview instead of the poster." - onMobileClick={onMobileTooltipClick} - /> - </Box> - } - /> - </FormControl> - </Grid> - - <Grid item xs={12} md={6} className="mt-3"> - <FormControl> - <FormControlLabel - control={ - <Switch - name="writeBackdropImages" - checked={config.writeBackdropImages} - onChange={handleCheckboxChange} - /> - } - label={ - <Box className="flex items-center"> - Create backdrop images - <InfoTooltip - text="Generates `backdrop` image files and places them in the video and channel directories for use by Emby and Jellyfin" - onMobileClick={onMobileTooltipClick} - /> - </Box> - } - /> - </FormControl> - </Grid> - - <Grid item xs={12} md={6} className="mt-3"> - <FormControl> - <FormControlLabel - control={ - <Switch - name="prefixChannelNameInTitle" - checked={config.prefixChannelNameInTitle} - onChange={handleCheckboxChange} - /> - } - label={ - <Box className="flex items-center"> - Prefix channel name in embedded video title - <InfoTooltip - text="Write the MP4's embedded title as 'Channel - Title'. Plex shows this tag as the video title. Turn it off for a Plex TV Shows library, where the channel is already the show name. Only applies to new downloads; existing files are not re-tagged." - onMobileClick={onMobileTooltipClick} - /> - </Box> - } - /> - </FormControl> - </Grid> - </Grid> - </AccordionDetails> - </Accordion> - </Grid> - - <Grid item xs={12}> - <Accordion defaultExpanded style={{ border: 'var(--border-weight) solid var(--border)', borderRadius: 'var(--radius-ui)' }}> - <AccordionSummary> - <Typography variant="subtitle2" style={{ fontWeight: 700 }}> - File Structure Settings - </Typography> - </AccordionSummary> - <AccordionDetails> - <Grid container spacing={2}> - <Grid item xs={12}> - <FormControl fullWidth> - <InputLabel htmlFor="youtubeOutputDirectory" className="flex items-center gap-2"> - YouTube Output Directory - <Chip label="Docker Volume" size="small" /> - </InputLabel> - <TextField - id="youtubeOutputDirectory" - fullWidth - name="youtubeOutputDirectory" - value={config.youtubeOutputDirectory} - onChange={handleInputChange} - disabled={true} - helperText={ - deploymentEnvironment.platform?.toLowerCase() === "elfhosted" - ? "This path is configured by your platform deployment and cannot be changed here." - : "Configured via YOUTUBE_OUTPUT_DIR environment variable. Edit .env and restart to change." - } - /> - </FormControl> - </Grid> - - <Grid item xs={12} md={6}> - <Box className="flex items-start"> - <SubfolderAutocomplete - mode="global" - value={config.defaultSubfolder || null} - onChange={handleDefaultSubfolderChange} - subfolders={subfolders} - loading={subfoldersLoading} - label="Default Subfolder" - helperText="Default download location for channels using 'Default Subfolder'" - showAddAction={false} - layoutOf={layoutOf} - /> - <Box className="flex items-center min-h-[48px] mt-5"> - <InfoTooltip - text="Set the default download location for untracked channels and channels using 'Default Subfolder'. Leave empty to download to the root directory by default." - onMobileClick={onMobileTooltipClick} - /> - </Box> - </Box> - <Box className="mt-1 flex flex-wrap items-center gap-2"> - <Button - variant="text" - size="sm" - startIcon={<AddIcon size={14} />} - onClick={() => setAddSubfolderOpen(true)} - > - Add Subfolder - </Button> - <Button - variant="text" - size="sm" - startIcon={<SettingsIcon size={14} />} - onClick={() => setManageOpen(true)} - > - Manage Subfolders - </Button> - </Box> - <AddSubfolderDialog - open={addSubfolderOpen} - onClose={() => setAddSubfolderOpen(false)} - onAdd={handleAddSubfolderFromPage} - existingSubfolders={subfolders} - /> - <ManageSubfoldersDialog - open={manageOpen} - onClose={() => setManageOpen(false)} - token={token} - /> - </Grid> - - <Grid item xs={12} md={6}> - <Box className="flex items-center md:mt-5 md:min-h-[48px]"> - <FormControlLabel - control={ - <Switch - name="useTmpForDownloads" - checked={config.useTmpForDownloads} - onChange={handleCheckboxChange} - disabled={isPlatformManaged.useTmpForDownloads} - /> - } - label={ - <Box className="flex items-center gap-2"> - Use external temp directory - {isPlatformManaged.useTmpForDownloads && ( - <Chip - label={deploymentEnvironment.platform?.toLowerCase() === "elfhosted" ? "Managed by Elfhosted" : "Platform Managed"} - size="small" - /> - )} - </Box> - } - /> - <InfoTooltip - text={ - isPlatformManaged.useTmpForDownloads - ? 'This setting is managed by your platform deployment and cannot be changed.' - : 'Controls where downloads are staged before moving to final location. When enabled, uses external /tmp path (useful for slow network storage). When disabled, uses a hidden .youtarr_tmp/ folder in your output directory (faster for local/SSD storage). Both options hide in-progress files from media servers.' - } - onMobileClick={onMobileTooltipClick} - /> - </Box> - </Grid> - - <Grid item xs={12} md={6}> - <Box className="flex items-center"> - <FormControlLabel - control={ - <Switch - name="defaultSkipVideoFolder" - checked={config.defaultSkipVideoFolder} - onChange={handleFlatDefaultChange} - /> - } - label="Flat file structure by default" - /> - <InfoTooltip - text="When enabled, new downloads are saved directly in each channel folder instead of individual per-video subfolders. Channels can override this in their own settings (Flat or Video subfolders). Only affects new downloads; existing files are not moved." - onMobileClick={onMobileTooltipClick} - /> - </Box> - </Grid> - - <Grid item xs={12}> - <Box className="border-t pt-3"> - <LibraryFoldersList library={libraryFolders} token={token} /> - </Box> - </Grid> - - <Grid item xs={12}> - <Box className="border-t pt-3"> - <VideoFilenameTemplate - value={config.videoFilenamePrefix} - onChange={(newValue) => onConfigChange({ videoFilenamePrefix: newValue })} - token={token} - saveRequirement={filenameTemplateSaveRequirement} - onPreviewSuccess={onFilenameTemplatePreviewSuccess} - channelPrefixEnabled={config.prefixChannelNameInTitle} - /> - </Box> - </Grid> - </Grid> - </AccordionDetails> - </Accordion> - </Grid> - </Grid> - - {/* Confirmation Dialog for Default Subfolder */} - <Dialog open={showConfirmDialog} onClose={handleCancelDefaultSubfolder}> - <DialogTitle> - {pendingIsNewSubfolder ? 'Set New Subfolder as Default?' : 'Set Default Subfolder?'} - </DialogTitle> - <DialogContent> - {pendingIsNewSubfolder && ( - <DialogContentText className="mb-2"> - Subfolder <strong>{`__${pendingDefaultSubfolder}`}</strong> has been created and is - available anywhere subfolders can be selected. - </DialogContentText> - )} - <DialogContentText> - {pendingIsNewSubfolder - ? 'Would you also like to make it the default subfolder? This will affect where videos are downloaded for:' - : 'Setting a default subfolder will affect where videos are downloaded for:'} - </DialogContentText> - <Box component="ul" className="mt-2 pl-4"> - <li>Untracked channels (manual URL downloads)</li> - <li>Channels configured to use "Default Subfolder"</li> - </Box> - - {/* Affected channels section */} - <Box className="mt-4 mb-4"> - {loadingAffectedChannels ? ( - <Box className="flex items-center gap-2"> - <CircularProgress size={16} /> - <span>Checking affected channels...</span> - </Box> - ) : affectedChannels.count === 0 ? ( - <DialogContentText> - No tracked channels are currently using Default Subfolder. - </DialogContentText> - ) : ( - <> - <DialogContentText> - {affectedChannels.count} tracked channel{affectedChannels.count !== 1 ? 's' : ''} configured to use Default Subfolder. - </DialogContentText> - <Link - component="button" - variant="body2" - onClick={() => setShowAffectedList(!showAffectedList)} - className="mt-1 block cursor-pointer" - > - {showAffectedList ? 'Hide affected channels ▲' : 'Show affected channels ▼'} - </Link> - <Collapse in={showAffectedList}> - <Box - component="ul" - className="mt-2 pl-4 max-h-[200px] overflow-auto bg-muted/50 rounded py-2" - > - {affectedChannels.channelNames.map((name, index) => ( - <li key={index}>{name}</li> - ))} - </Box> - </Collapse> - </> + <div className="flex flex-col"> + <p className="text-sm text-muted-foreground">How Youtarr downloads videos, and the files it writes next to them.</p> + <div className="mt-6 flex flex-col gap-8 lg:gap-12"> + <LibraryFoldersCard token={token} config={config} isPlatformManaged={isPlatformManaged} + deploymentEnvironment={deploymentEnvironment} onMobileTooltipClick={onMobileTooltipClick} /> + + <SettingsSection id="downloads" title="Downloads" description="What Youtarr downloads, and when."> + <SettingRow controlId="channelAutoDownload" label="Automatic downloads" + description="Check enabled channel tabs and auto-download playlists for new videos on a schedule." + control={switchFor('channelAutoDownload')}> + <ScheduleSummary scheduleKey="channelDownloadFrequency" value={config.channelDownloadFrequency} /> + {!config.channelAutoDownload && <p className="mt-1 text-[13px] text-muted-foreground">The schedule is idle until you turn this on.</p>} + </SettingRow> + <SettingRow controlId="channelFilesToDownload" label="Videos per channel tab and playlist" controlSize="select-compact" + description="Newest uploads per channel tab, latest additions per playlist. Videos you already have are skipped." + control={( + <Select id="channelFilesToDownload" size="small" inputProps={describedBy('channelFilesToDownload')} + value={config.channelFilesToDownload} + onChange={(event: SelectChangeEvent<string>) => onConfigChange({ channelFilesToDownload: Number(event.target.value) })}> + {getChannelFilesOptions(config.channelFilesToDownload).map((count) => ( + <MenuItem key={count} value={count}>{count} {count === 1 ? 'video' : 'videos'}</MenuItem> + ))} + </Select> + )} /> + <SettingRow controlId="preferredResolution" label="Preferred resolution" controlSize="select-compact" + description="Youtarr takes the closest resolution YouTube has." + control={( + <Select id="preferredResolution" size="small" inputProps={describedBy('preferredResolution')} + value={config.preferredResolution} + onChange={(event: SelectChangeEvent<string>) => onConfigChange({ preferredResolution: event.target.value })}> + {RESOLUTIONS.map(([value, label]) => <MenuItem key={value} value={value}>{label}</MenuItem>)} + </Select> + )}> + {(config.preferredResolution === '1440' || config.preferredResolution === '2160') && ( + <SettingNote tone="warning"> + 1440p and 4K come as VP9 or AV1 (remuxed into MP4). Older Plex clients without VP9 or AV1 decoding may transcode. + </SettingNote> )} - </Box> - - <DialogContentText> - Videos will be downloaded to channel folders in:{' '} - <strong> - {pendingDefaultSubfolder ? `__${pendingDefaultSubfolder}` : 'the root directory'} - </strong> - </DialogContentText> - <DialogContentText className="mt-2" style={{ fontStyle: 'italic' }}> - {defaultLayoutChanges ? DEFAULT_LAYOUT_CHANGE_NOTE : 'Existing videos will not be moved.'} - </DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={handleCancelDefaultSubfolder}> - {pendingIsNewSubfolder ? "Don't Set as Default" : 'Cancel'} - </Button> - <Button onClick={handleConfirmDefaultSubfolder} variant="contained" color="primary"> - Set as Default - </Button> - </DialogActions> - </Dialog> - - <Dialog open={showFlatConfirmDialog} onClose={handleCancelFlatDefault}> - <DialogTitle>Change default file structure?</DialogTitle> - <DialogContent> - <DialogContentText> - {pendingFlatDefault - ? 'New downloads for channels using the global setting will be saved directly in the channel folder (flat structure, no per-video subfolders).' - : 'New downloads for channels using the global setting will be saved in individual per-video subfolders.'} - </DialogContentText> - - <Box className="mt-4 mb-4"> - {loadingFlatAffectedChannels ? ( - <Box className="flex items-center gap-2"> - <CircularProgress size={16} /> - <span>Checking affected channels...</span> - </Box> - ) : flatAffectedChannels === null ? ( - <DialogContentText style={{ color: 'var(--warning)' }}> - Could not determine how many channels are affected. You can still continue, but the - affected channel count is unknown. - </DialogContentText> - ) : flatAffectedChannels.count === 0 ? ( - <DialogContentText> - No tracked channels are currently using the global setting. - </DialogContentText> - ) : ( - <> - <DialogContentText> - {flatAffectedChannels.count} tracked channel{flatAffectedChannels.count !== 1 ? 's' : ''} follow{flatAffectedChannels.count === 1 ? 's' : ''} the global setting and will be affected. - </DialogContentText> - <Link - component="button" - variant="body2" - onClick={() => setShowFlatAffectedList(!showFlatAffectedList)} - className="mt-1 block cursor-pointer" - > - {showFlatAffectedList ? 'Hide affected channels ▲' : 'Show affected channels ▼'} - </Link> - <Collapse in={showFlatAffectedList}> - <Box - component="ul" - className="mt-2 pl-4 max-h-[200px] overflow-auto bg-muted/50 rounded py-2" - > - {flatAffectedChannels.channelNames.map((name, index) => ( - <li key={index}>{name}</li> - ))} - </Box> - </Collapse> - </> + </SettingRow> + <SettingRow controlId="videoCodec" label="Preferred video codec" controlSize="select" stackControlOnPhone + description="Used when YouTube has it; otherwise Youtarr falls back." + tooltip="Default lets YouTube pick the best codec (typically VP9 or AV1 at 1440p and above)." onMobileTooltipClick={onMobileTooltipClick} + control={( + <Select id="videoCodec" size="small" inputProps={describedBy('videoCodec')} + value={config.videoCodec} + onChange={(event: SelectChangeEvent<string>) => onConfigChange({ videoCodec: event.target.value })}> + {CODECS.map(([value, label]) => <MenuItem key={value} value={value}>{label}</MenuItem>)} + </Select> + )}> + <SettingNote tone="info"> + H.264 direct-plays on the most devices (Apple TV HD, iOS, older Rokus), but YouTube only offers it up to 1080p, so it + overrides a 1440p or 4K resolution. + </SettingNote> + </SettingRow> + <SettingRow controlId="subtitlesEnabled" label="Subtitles" + description="SRT files when available: manual subtitles first, auto-generated as a fallback." + control={switchFor('subtitlesEnabled')}> + {config.subtitlesEnabled && ( + <div className="mt-1 rounded-ui border border-border bg-background px-3.5 py-3"> + <p className="text-[13px] font-medium">Languages</p> + <SubtitleLanguageSelector value={config.subtitleLanguage} onChange={(value) => onConfigChange({ subtitleLanguage: value })} /> + <p className="mt-2 text-[13px] text-muted-foreground">Videos without subtitles in these languages still download.</p> + </div> )} - </Box> - - <DialogContentText> - Previously downloaded videos are not affected. Existing files will not be moved or renamed; only new downloads use the new structure. - </DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={handleCancelFlatDefault}>Cancel</Button> - <Button onClick={handleConfirmFlatDefault} variant="contained" color="primary" disabled={loadingFlatAffectedChannels}> - Confirm - </Button> - </DialogActions> - </Dialog> - </ConfigurationCard> + </SettingRow> + </SettingsSection> + + <SettingsSection id="media-server-files" title="Media server files" + description="Extra files saved next to each download so media servers show full details and artwork."> + {MEDIA_FILES.map((row) => ( + <SettingRow key={row.key} controlId={row.key} label={row.label} description={row.description} control={switchFor(row.key)} /> + ))} + <div className={cn('flex items-start gap-2 bg-muted/30 text-[13px] text-muted-foreground', phone ? 'px-4 py-3.5' : 'px-5 py-4')}> + <Info size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-info" /> + <span> + Which library type a folder needs depends on its layout: <strong className="text-foreground">Movies</strong> or{' '} + <strong className="text-foreground">Other Videos</strong> for Videos folders, <strong className="text-foreground">TV Shows</strong> or{' '} + <strong className="text-foreground">Shows</strong> for TV shows folders.{phone ? '' : ' Library folders shows the setup for every folder and checks your servers.'}{' '} + <RouterLink to={LIBRARY_FOLDERS_PATH} className="text-primary underline max-md:inline-flex max-md:min-h-[44px] max-md:items-center">Open Library folders</RouterLink> + </span> + </div> + </SettingsSection> + + <SettingsSection id="naming" title="Naming" + description={phone ? `How files in Videos folders are named. ${NAMING_TV_NOTE}` : 'How files in Videos folders are named and organized.'} + aside={phone ? undefined : ( + <> + <Chip icon={<Film size={12} aria-hidden="true" />} label="Videos folders only" size="small" variant="outlined" /> + <p className="mt-2.5 text-[13px] text-muted-foreground">{NAMING_TV_NOTE}</p> + </> + )}> + <SettingRow controlId="defaultSkipVideoFolder" label="Flat file structure by default" + description="Save new downloads directly in the channel folder instead of a folder per video. Channels can override this." + control={( + <Switch id="defaultSkipVideoFolder" name="defaultSkipVideoFolder" checked={config.defaultSkipVideoFolder} + aria-describedby={settingDescriptionId('defaultSkipVideoFolder')} + onChange={(event) => { if (event.target.checked !== config.defaultSkipVideoFolder) setPendingFlat(event.target.checked); }} /> + )} /> + <SettingRow controlId="prefixChannelNameInTitle" label="Channel name in embedded title" + description={'Write the MP4 title as "Channel - Title"; Plex shows it as the video title. New downloads only.'} + control={switchFor('prefixChannelNameInTitle')} /> + <SettingRow controlId="videoFilenamePrefix" label="Video filename template" + description={( + <>How yt-dlp names video files and per-video folders. Youtarr always adds <code className="font-mono">[VIDEO_ID].EXT</code> to file + names and <code className="font-mono">- VIDEO_ID</code> to folder names so it can find your videos again. New downloads only.</> + )} + controlSize="link" + control={( + <a href="https://github.com/yt-dlp/yt-dlp#output-template" target="_blank" rel="noopener noreferrer" className="text-[13px] text-primary underline"> + {phone ? 'yt-dlp docs' : 'yt-dlp output template docs'} + </a> + )}> + <VideoFilenameTemplate inputId="videoFilenamePrefix" value={config.videoFilenamePrefix} + onChange={(value) => onConfigChange({ videoFilenamePrefix: value })} token={token} + saveRequirement={filenameTemplateSaveRequirement} onPreviewSuccess={onFilenameTemplatePreviewSuccess} /> + </SettingRow> + </SettingsSection> + + <SettingsSection id="interface" title="Interface" description="Applies to everyone who uses this Youtarr."> + <SettingRow controlId="channelVideosHotLoad" label="Infinite scrolling (hot loading)" + description="Channel lists, channel videos and download history load more as you scroll. Off: page-by-page controls." + control={switchFor('channelVideosHotLoad')} /> + </SettingsSection> + + <SettingsSection id="advanced" title="Advanced" description="Download staging. Doesn't change where finished files go."> + <SettingRow controlId="useTmpForDownloads" label="External temp directory" + description="Download to /tmp first, then move finished files into the library. Faster with slow network storage. Off: a hidden .youtarr_tmp folder inside the downloads folder." + tooltip={isPlatformManaged.useTmpForDownloads + ? 'This setting is managed by your platform deployment and cannot be changed.' + : 'Both options hide unfinished downloads from media servers. Off is faster on local or SSD storage.'} + onMobileTooltipClick={onMobileTooltipClick} + badge={isPlatformManaged.useTmpForDownloads ? <Chip label={elfhosted ? 'Managed by Elfhosted' : 'Platform Managed'} size="small" /> : undefined} + control={switchFor('useTmpForDownloads', { disabled: isPlatformManaged.useTmpForDownloads })} /> + </SettingsSection> + </div> + + <FlatStructureDialog open={pendingFlat !== null} turningOn={pendingFlat === true} token={token} + onCancel={() => setPendingFlat(null)} + onConfirm={() => { onConfigChange({ defaultSkipVideoFolder: pendingFlat === true }); setPendingFlat(null); }} /> + </div> ); }; diff --git a/client/src/components/Configuration/sections/PlexIntegrationSection.tsx b/client/src/components/Configuration/sections/PlexIntegrationSection.tsx index ba8dc16f2..a663717de 100644 --- a/client/src/components/Configuration/sections/PlexIntegrationSection.tsx +++ b/client/src/components/Configuration/sections/PlexIntegrationSection.tsx @@ -1,4 +1,6 @@ import React, { ChangeEvent, useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { LIBRARY_FOLDERS_PATH } from '../../../utils/libraryLayouts'; import { TextField, Grid, @@ -14,7 +16,6 @@ import { import { ConfigurationAccordion } from '../common/ConfigurationAccordion'; import { InfoTooltip } from '../common/InfoTooltip'; import { ConfigState, PlatformManagedState, PlexConnectionStatus } from '../types'; -import { PlexSubfolderMappings } from './PlexSubfolderMappings'; import { PlexLibrary } from '../../../utils/plexLibraries'; import { DefaultPlexLibraryDisplay } from './components/DefaultPlexLibraryDisplay'; import { PlexPlaylistScopeControl } from './components/PlexPlaylistScopeControl'; @@ -31,7 +32,6 @@ interface PlexIntegrationSectionProps { onOpenLibrarySelector: () => void; onOpenPlexAuthDialog: () => void; onMobileTooltipClick?: (text: string) => void; - token?: string | null; } export const PlexIntegrationSection: React.FC<PlexIntegrationSectionProps> = ({ @@ -46,7 +46,6 @@ export const PlexIntegrationSection: React.FC<PlexIntegrationSectionProps> = ({ onOpenLibrarySelector, onOpenPlexAuthDialog, onMobileTooltipClick, - token = null, }) => { // Auto-expand the playlist-scope disclosure when a test reveals an unclaimed // server, since those users must change the scope for playlists to appear. @@ -313,13 +312,10 @@ export const PlexIntegrationSection: React.FC<PlexIntegrationSectionProps> = ({ </Grid> <Grid item xs={12}> - <PlexSubfolderMappings - mappings={config.plexSubfolderLibraryMappings ?? []} - onMappingsChange={(mappings) => onConfigChange({ plexSubfolderLibraryMappings: mappings })} - token={token} - plexConnectionStatus={plexConnectionStatus} - plexLibraries={plexLibraries} - /> + <p className="text-sm text-muted-foreground"> + Each library folder's refresh library is set on its page in Library folders.{' '} + <RouterLink to={LIBRARY_FOLDERS_PATH} className="text-primary underline max-md:inline-flex max-md:min-h-[44px] max-md:items-center">Open Library folders</RouterLink> + </p> </Grid> <Grid item xs={12}> diff --git a/client/src/components/Configuration/sections/PlexSubfolderMappings.tsx b/client/src/components/Configuration/sections/PlexSubfolderMappings.tsx deleted file mode 100644 index a685a4fe4..000000000 --- a/client/src/components/Configuration/sections/PlexSubfolderMappings.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React, { useState } from 'react'; -import { useSubfolders } from '../../../hooks/useSubfolders'; -import { stripSubfolderPrefix } from '../../../utils/subfolderDisplay'; -import { - Alert, - Box, - Button, - CircularProgress, - Divider, - FormControl, - IconButton, - InputLabel, - MenuItem, - Select, - SelectChangeEvent, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - Typography, -} from '../../ui'; -import { Add as AddIcon, Delete as DeleteIcon } from '../../../lib/icons'; -import { PlexConnectionStatus } from '../types'; -import { PlexLibrary, resolveLibraryDisplay } from '../../../utils/plexLibraries'; -import { PlexLibraryLabel } from './components/PlexLibraryLabel'; - -export interface PlexSubfolderMapping { - subfolder: string | null; - libraryId: string | null; -} - -interface PlexSubfolderMappingsProps { - mappings: PlexSubfolderMapping[]; - onMappingsChange: (mappings: PlexSubfolderMapping[]) => void; - token: string | null; - plexConnectionStatus: PlexConnectionStatus; - plexLibraries: PlexLibrary[]; -} - -/** - * Internal sentinel for the "Root folder" Select option. - * Uses a long namespaced string that cannot collide with any real subfolder - * value: the API returns subfolders with the `__` prefix convention, so a - * channel would need sub_folder = 'ROOT_LIBRARY_MAPPING__' to collide here, - * which is effectively impossible in practice. - */ -const ROOT_SELECT_VALUE = '__YOUTARR_ROOT_LIBRARY_MAPPING__'; - -/** Convert a Select value back to the stored mapping subfolder (null for root). */ -const selectValueToSubfolder = (value: string): string | null => - value === ROOT_SELECT_VALUE ? null : stripSubfolderPrefix(value); - -/** Format a mapping's subfolder for display in the table. */ -const formatMappingSubfolder = (subfolder: string | null): string => - subfolder ? `__${subfolder}` : 'Root folder'; - -export const PlexSubfolderMappings: React.FC<PlexSubfolderMappingsProps> = ({ - mappings, - onMappingsChange, - token, - plexConnectionStatus, - plexLibraries, -}) => { - const [newSubfolder, setNewSubfolder] = useState<string>(''); - const [newLibraryId, setNewLibraryId] = useState<string>(''); - const [showAddForm, setShowAddForm] = useState(false); - - const isConnected = plexConnectionStatus === 'connected'; - - const { subfolders, loading: loadingData, error: fetchErrorObj } = useSubfolders(isConnected ? token : null); - - const isMappingDuplicate = (subfolder: string | null): boolean => - mappings.some((m) => m.subfolder === subfolder); - - const handleAddMapping = () => { - if (!newSubfolder || !newLibraryId) return; - - const subfolder = selectValueToSubfolder(newSubfolder); - if (isMappingDuplicate(subfolder)) return; - - onMappingsChange([...mappings, { subfolder, libraryId: newLibraryId }]); - setNewSubfolder(''); - setNewLibraryId(''); - setShowAddForm(false); - }; - - const handleDeleteMapping = (subfolder: string | null) => { - onMappingsChange(mappings.filter((m) => m.subfolder !== subfolder)); - }; - - const isAddDisabled = - !newSubfolder || - !newLibraryId || - isMappingDuplicate(selectValueToSubfolder(newSubfolder)); - - // When disconnected and no mappings exist yet, the whole section is unnecessary noise. - // When disconnected but mappings exist, keep the table visible so users can delete entries. - if (!isConnected && mappings.length === 0) { - return null; - } - - return ( - <Box className="mt-4"> - <Divider className="mb-4" /> - <Box className="flex items-center justify-between mb-2"> - <Typography variant="subtitle2"> - Per-Subfolder Library Mappings - </Typography> - {!showAddForm && ( - <Tooltip title={!isConnected ? 'Connect to Plex to add new mappings' : ''}> - <span> - <Button - size="small" - startIcon={<AddIcon />} - onClick={() => setShowAddForm(true)} - data-testid="add-mapping-button" - disabled={loadingData || !isConnected} - > - Add Mapping - </Button> - </span> - </Tooltip> - )} - </Box> - <Typography variant="caption" color="text.secondary" className="block mb-4"> - Map each subfolder to a specific Plex library. Subfolders without a mapping use the - default library selected above. - </Typography> - - {loadingData && ( - <Box className="flex items-center gap-2 mb-4"> - <CircularProgress size={16} /> - <Typography variant="caption" color="text.secondary"> - Loading subfolders... - </Typography> - </Box> - )} - - {fetchErrorObj && ( - <Alert severity="warning" className="mb-4"> - Could not load channel subfolders. Check your connection and try refreshing. - </Alert> - )} - - {mappings.length > 0 && ( - <Table size="small"> - <TableHead> - <TableRow> - <TableCell>Subfolder</TableCell> - <TableCell>Plex Library</TableCell> - <TableCell style={{ width: 48, paddingLeft: 4, paddingRight: 4 }} /> - </TableRow> - </TableHead> - <TableBody> - {mappings.map((mapping) => { - const display = resolveLibraryDisplay(plexLibraries, mapping.libraryId ?? ''); - return ( - <TableRow key={`${mapping.subfolder === null ? '\x00root' : mapping.subfolder}-${mapping.libraryId}`}> - <TableCell> - <Typography variant="body2" className="font-mono"> - {formatMappingSubfolder(mapping.subfolder)} - </Typography> - </TableCell> - <TableCell> - <PlexLibraryLabel display={display} /> - </TableCell> - <TableCell style={{ width: 48, paddingLeft: 4, paddingRight: 4 }}> - <IconButton - size="small" - onClick={() => handleDeleteMapping(mapping.subfolder)} - aria-label={`Remove mapping for ${formatMappingSubfolder(mapping.subfolder)}`} - data-testid={`delete-mapping-${mapping.subfolder ?? 'root'}`} - > - <DeleteIcon size={16} /> - </IconButton> - </TableCell> - </TableRow> - ); - })} - </TableBody> - </Table> - )} - - {mappings.length === 0 && !showAddForm && !loadingData && ( - <Typography variant="body2" color="text.secondary" className="block mb-4"> - No per-subfolder mappings configured. All downloads will refresh the default library. - </Typography> - )} - - {showAddForm && isConnected && ( - <Box className="flex flex-wrap items-start gap-3 mt-2"> - <FormControl style={{ minWidth: 160 }}> - <InputLabel id="new-mapping-subfolder-label">Subfolder</InputLabel> - <Select - labelId="new-mapping-subfolder-label" - label="Subfolder" - value={newSubfolder} - onChange={(e: SelectChangeEvent) => setNewSubfolder(e.target.value)} - data-testid="new-mapping-subfolder-select" - disabled={loadingData} - > - <MenuItem value={ROOT_SELECT_VALUE}>Root folder</MenuItem> - {subfolders.map((folder) => ( - <MenuItem - key={folder} - value={folder} - disabled={isMappingDuplicate(selectValueToSubfolder(folder))} - > - {folder} - </MenuItem> - ))} - </Select> - </FormControl> - - <FormControl style={{ minWidth: 180 }}> - <InputLabel id="new-mapping-library-label">Plex Library</InputLabel> - <Select - labelId="new-mapping-library-label" - label="Plex Library" - value={newLibraryId} - onChange={(e: SelectChangeEvent) => setNewLibraryId(e.target.value)} - data-testid="new-mapping-library-select" - disabled={loadingData} - > - {plexLibraries.map((lib) => ( - <MenuItem key={lib.id} value={lib.id}> - {lib.title} - </MenuItem> - ))} - </Select> - </FormControl> - - <Button - variant="contained" - size="small" - onClick={handleAddMapping} - disabled={isAddDisabled} - data-testid="confirm-add-mapping-button" - className="h-10" - > - Add - </Button> - <Button - size="small" - onClick={() => { - setShowAddForm(false); - setNewSubfolder(''); - setNewLibraryId(''); - }} - className="h-10" - > - Cancel - </Button> - </Box> - )} - </Box> - ); -}; diff --git a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx index 7e1544e3b..42466bd35 100644 --- a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx +++ b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx @@ -4,8 +4,56 @@ import { expect, fn, userEvent, within } from 'storybook/test'; import React, { useState } from 'react'; import { http, HttpResponse } from 'msw'; import { DEFAULT_CONFIG } from '../../../../config/configSchema'; +import type { LibraryFoldersResponse } from '../../../../types/tvShows'; +import type { LibraryCheckResponse } from '../../../../types/libraryCheck'; +import type { MediaServerStatus } from '../../../../types/playlist'; import { CoreSettingsSection } from '../CoreSettingsSection'; +const LIBRARY_FOLDERS: LibraryFoldersResponse = { + folders: [ + { + name: '', layout: 'videos', isDefault: true, hasFiles: true, channels: 2, + channelsChosen: 0, channelsFollowing: 2, playlists: 1, titleShows: 0, + layoutChangeNeedsReview: true, makeDefaultNeedsReview: false, + }, + { + name: 'Movies', layout: 'videos', isDefault: false, hasFiles: true, channels: 1, + channelsChosen: 1, playlists: 0, titleShows: 0, layoutChangeNeedsReview: true, makeDefaultNeedsReview: false, + }, + { + name: 'Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 1, + channelsChosen: 1, playlists: 0, titleShows: 0, layoutChangeNeedsReview: false, makeDefaultNeedsReview: true, + }, + ], +}; + +const plexLibrary = (id: string, name: string, type: 'videos' | 'tv', location: string) => ( + { id, name, type, location, relation: 'exact' as const } +); + +const LIBRARY_CHECK: LibraryCheckResponse = { + servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null, downloadsPath: '/data/youtube' }], + folders: [ + { + name: '', layout: 'videos', hasFiles: true, channels: 2, + servers: [{ serverType: 'plex', status: 'ok', libraries: [plexLibrary('3', 'YouTube', 'videos', '/data/youtube')], issues: [] }], + }, + { + name: 'Movies', layout: 'videos', hasFiles: true, channels: 1, + servers: [{ serverType: 'plex', status: 'ok', libraries: [plexLibrary('3', 'YouTube', 'videos', '/data/youtube')], issues: [] }], + }, + { + name: 'Shows', layout: 'tv', hasFiles: false, channels: 1, + servers: [{ + serverType: 'plex', status: 'missing', libraries: [], issues: [], + plexMapping: { mappedLibraryId: null, suggestedLibraryId: null }, + }], + }, + ], +}; + +const MEDIA_SERVERS: MediaServerStatus = { plex: true, jellyfin: false, emby: false }; + const meta: Meta<typeof CoreSettingsSection> = { title: 'Components/Configuration/Sections/CoreSettingsSection', component: CoreSettingsSection, @@ -13,31 +61,21 @@ const meta: Meta<typeof CoreSettingsSection> = { parameters: { msw: { handlers: [ - http.get('/api/channels/subfolders', () => - HttpResponse.json(['Movies', 'Shows']) - ), - http.get('/api/library-folders', () => - HttpResponse.json({ - folders: [ - { name: '', layout: 'videos', isDefault: true, hasFiles: true, channels: 2 }, - { name: 'Movies', layout: 'videos', isDefault: false, hasFiles: false, channels: 1 }, - { name: 'Shows', layout: 'tv', isDefault: false, hasFiles: false, channels: 0 }, - ], - }) - ), + http.get('/api/library-folders', () => HttpResponse.json(LIBRARY_FOLDERS)), + http.get('/api/library-folders/check', () => HttpResponse.json(LIBRARY_CHECK)), + http.get('/api/mediaservers/status', () => HttpResponse.json(MEDIA_SERVERS)), ], }, }, render: (args) => { const [config, setConfig] = useState({ ...DEFAULT_CONFIG, - youtubeOutputDirectory: '/downloads/youtube', + youtubeOutputDirectory: '/data/youtube', channelAutoDownload: false, channelDownloadFrequency: '0 0 * * *', channelFilesToDownload: 3, preferredResolution: '1080', videoCodec: 'default', - defaultSubfolder: '', }); return ( <CoreSettingsSection @@ -61,7 +99,7 @@ type Story = StoryObj<typeof CoreSettingsSection>; export const ToggleAutoDownloads: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const checkbox = await canvas.findByRole('checkbox', { name: /enable automatic downloads/i }); + const checkbox = await canvas.findByRole('checkbox', { name: 'Automatic downloads' }); await userEvent.click(checkbox); await expect(checkbox).toBeChecked(); diff --git a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx index ec3da4517..835331fca 100644 --- a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx @@ -1,1483 +1,141 @@ import React from 'react'; -import { screen, waitFor, within } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; import { CoreSettingsSection } from '../CoreSettingsSection'; -import { renderWithProviders } from '../../../../test-utils'; -import { ConfigState, DeploymentEnvironment, PlatformManagedState } from '../../types'; import { DEFAULT_CONFIG } from '../../../../config/configSchema'; - -// Mock useSubfolders hook to prevent network requests -const mockCreateSubfolder = jest.fn(() => Promise.resolve()); -jest.mock('../../../../hooks/useSubfolders', () => ({ - useSubfolders: () => ({ - subfolders: ['__Sports', '__Music', '__Tech'], - loading: false, - error: null, - refetch: jest.fn(), - createSubfolder: mockCreateSubfolder, - deleteSubfolder: jest.fn(() => Promise.resolve()), - }), -})); - -// Mock useLibraryFolders hook to prevent network requests -const mockLayouts: Record<string, string> = {}; -jest.mock('../../../../hooks/useLibraryFolders', () => ({ - useLibraryFolders: () => ({ - folders: [{ name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 0 }], - loading: false, - error: null, - layoutOf: (name: string) => mockLayouts[name] ?? 'videos', - refetch: () => Promise.resolve(), - setFolderLayout: () => Promise.resolve(), - }), -})); - -// Mock useLibraryCheck hook to prevent network requests -jest.mock('../../../../hooks/useLibraryCheck', () => ({ - useLibraryCheck: () => ({ - data: null, loading: false, error: null, refetch: () => Promise.resolve(), applyPlexMapping: () => Promise.resolve(), - }), +import type { ConfigState, DeploymentEnvironment, PlatformManagedState } from '../../types'; + +jest.mock('axios', () => ({ get: jest.fn() })); +const axios = require('axios'); +jest.mock('../components/LibraryFoldersCard', () => ({ LibraryFoldersCard: () => { + const React = require('react'); + return React.createElement('section', { 'aria-label': 'Library folders card' }); +} })); +jest.mock('../../hooks/useFilenamePreview', () => ({ + useFilenamePreview: () => ({ run: jest.fn(), loading: false, error: null, data: null, isStale: () => false }), })); -// Mock SubtitleLanguageSelector to simplify testing -jest.mock('../../SubtitleLanguageSelector', () => ({ - __esModule: true, - default: function MockSubtitleLanguageSelector(props: { - value: string; - onChange: (value: string) => void; - }) { - const React = require('react'); - return React.createElement('div', { 'data-testid': 'subtitle-language-selector' }, - React.createElement('input', { - 'data-testid': 'subtitle-language-input', - value: props.value, - onChange: (e: React.ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value) - }) +function renderCore({ config = {}, onConfigChange = jest.fn(), isPlatformManaged = {}, deploymentEnvironment = {}, route = '/settings/core' }: { + config?: Partial<ConfigState>; onConfigChange?: jest.Mock; isPlatformManaged?: Partial<PlatformManagedState>; + deploymentEnvironment?: Partial<DeploymentEnvironment>; route?: string; +} = {}) { + return render( + <MemoryRouter initialEntries={[route]}> + <CoreSettingsSection + config={{ ...DEFAULT_CONFIG, ...config }} + onConfigChange={onConfigChange} + isPlatformManaged={{ plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false, ...isPlatformManaged }} + deploymentEnvironment={{ isWsl: false, ...deploymentEnvironment }} + token="token" + /> + </MemoryRouter> + ); +} + +const mockFlatCount = (data: { count: number; channelNames: string[] }) => axios.get.mockResolvedValue({ data }); + +describe('CoreSettingsSection', () => { + test('renders the sections in order with their anchors', () => { + renderCore(); + expect(screen.getAllByRole('region').map((region) => region.id).filter(Boolean)).toEqual( + ['downloads', 'media-server-files', 'naming', 'interface', 'advanced'] ); - } -})); - -// Mock SubfolderAutocomplete for dialog tests -jest.mock('../../../shared/SubfolderAutocomplete', () => ({ - SubfolderAutocomplete: function MockSubfolderAutocomplete(props: { - value: string | null; - onChange: (value: string | null, meta?: { isNewlyCreated?: boolean }) => void; - label: string; - }) { - const React = require('react'); - return React.createElement('div', { 'data-testid': 'subfolder-autocomplete' }, - React.createElement('button', { - 'data-testid': 'trigger-subfolder-change', - onClick: () => props.onChange('NewFolder') - }, 'Change Subfolder') - ); - } -})); - -const createConfig = (overrides: Partial<ConfigState> = {}): ConfigState => ({ - ...DEFAULT_CONFIG, - youtubeOutputDirectory: '/data/youtube', - channelDownloadFrequency: '0 */6 * * *', // Every 6 hours - ...overrides, -}); - -const createDeploymentEnvironment = ( - overrides: Partial<DeploymentEnvironment> = {} -): DeploymentEnvironment => ({ - platform: null, - isWsl: false, - ...overrides, -}); - -const createPlatformManagedState = ( - overrides: Partial<PlatformManagedState> = {} -): PlatformManagedState => ({ - plexUrl: false, - authEnabled: false, - useTmpForDownloads: false, - ytdlpUpdates: false, - ...overrides, -}); - -const createSectionProps = ( - overrides: Partial<React.ComponentProps<typeof CoreSettingsSection>> = {} -): React.ComponentProps<typeof CoreSettingsSection> => ({ - config: createConfig(), - deploymentEnvironment: createDeploymentEnvironment(), - isPlatformManaged: createPlatformManagedState(), - onConfigChange: jest.fn(), - onMobileTooltipClick: jest.fn(), - token: 'test-token', - ...overrides, -}); - -describe('CoreSettingsSection Component', () => { - beforeEach(() => { - jest.clearAllMocks(); - // Jest's resetMocks wipes the implementation before each test; restore it - mockCreateSubfolder.mockResolvedValue(undefined); }); - describe('Component Rendering', () => { - test('renders without crashing', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('Core Settings')).toBeInTheDocument(); - }); - - test('renders title and section headers', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('Core Settings')).toBeInTheDocument(); - expect(screen.getByText('General Settings')).toBeInTheDocument(); - expect(screen.getByText('Download Settings')).toBeInTheDocument(); - expect(screen.getByText('File Structure Settings')).toBeInTheDocument(); - }); - - test('allows collapsing and expanding download settings accordion', async () => { - const user = userEvent.setup(); - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - const downloadSettingsTrigger = screen.getByRole('button', { name: /Download Settings/i }); - expect(downloadSettingsTrigger).toHaveAttribute('data-state', 'open'); - - await user.click(downloadSettingsTrigger); - expect(downloadSettingsTrigger).toHaveAttribute('data-state', 'closed'); - - await user.click(downloadSettingsTrigger); - expect(downloadSettingsTrigger).toHaveAttribute('data-state', 'open'); - }); - - test('renders media server information accordion collapsed by default', async () => { - const user = userEvent.setup(); - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - const infoTrigger = await screen.findByRole('button', { name: /Jellyfin \/ Kodi \/ Emby Setting Information/i }); - expect(infoTrigger).toHaveAttribute('data-state', 'closed'); - }); - - test('renders the library folder layouts in File Structure Settings', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByRole('button', { name: 'Layout for Main folder' })).toBeInTheDocument(); - }); + test('no longer edits the default folder or lists library folders', () => { + renderCore(); + expect(screen.queryByLabelText(/Default Subfolder/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Manage Subfolders/ })).not.toBeInTheDocument(); + expect(screen.queryByText('Jellyfin / Kodi / Emby Setting Information')).not.toBeInTheDocument(); }); - describe('Create video fanart files Checkbox', () => { - test('renders Create video fanart files checkbox', async () => { - const user = userEvent.setup(); - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - - expect(screen.getByRole('checkbox', { name: /Create video fanart files/i })).toBeInTheDocument(); - }); - - test('checkbox reflects writeVideoFanart state when false', async () => { - const user = userEvent.setup(); - const props = createSectionProps({ - config: createConfig({ writeVideoFanart: false }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - - const checkbox = screen.getByRole('checkbox', { name: /Create video fanart files/i }); - expect(checkbox).not.toBeChecked(); - }); - - test('checkbox reflects writeVideoFanart state when true', async () => { - const user = userEvent.setup(); - const props = createSectionProps({ - config: createConfig({ writeVideoFanart: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - - const checkbox = screen.getByRole('checkbox', { name: /Create video fanart files/i }); - expect(checkbox).toBeChecked(); - }); - - test('calls onConfigChange when checkbox is toggled from false to true', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ writeVideoFanart: false }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - - const checkbox = screen.getByRole('checkbox', { name: /Create video fanart files/i }); - await user.click(checkbox); - - expect(onConfigChange).toHaveBeenCalledTimes(1); - expect(onConfigChange).toHaveBeenCalledWith({ writeVideoFanart: true }); - }); - - test('calls onConfigChange when checkbox is toggled from true to false', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ writeVideoFanart: true }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - - const checkbox = screen.getByRole('checkbox', { name: /Create video fanart files/i }); - await user.click(checkbox); - - expect(onConfigChange).toHaveBeenCalledTimes(1); - expect(onConfigChange).toHaveBeenCalledWith({ writeVideoFanart: false }); - }); - - test('renders helper text for video fanart', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - // Verify the checkbox label is present with the info tooltip - const checkboxLabel = screen.getByText('Create video fanart files'); - expect(checkboxLabel).toBeInTheDocument(); - }); + test('the flat structure switch only changes after confirming, and names TV folders', async () => { + mockFlatCount({ count: 2, channelNames: ['A', 'B'] }); + const onConfigChange = jest.fn(); + renderCore({ onConfigChange }); + await userEvent.click(screen.getByRole('checkbox', { name: 'Flat file structure by default' })); + expect(onConfigChange).not.toHaveBeenCalled(); + expect(await screen.findByText("Channels in TV shows folders aren't affected: their episodes always go straight into Season folders.")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled()); + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })); + expect(onConfigChange).toHaveBeenCalledWith({ defaultSkipVideoFolder: true }); }); - describe('Create backdrop images Checkbox', () => { - test('renders Create backdrop images checkbox', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - expect(screen.getByRole('checkbox', { name: /Create backdrop images/i })).toBeInTheDocument(); - }); - - test('checkbox reflects writeBackdropImages state when false', async () => { - const props = createSectionProps({ - config: createConfig({ writeBackdropImages: false }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const checkbox = screen.getByRole('checkbox', { name: /Create backdrop images/i }); - expect(checkbox).not.toBeChecked(); - }); - - test('checkbox reflects writeBackdropImages state when true', async () => { - const props = createSectionProps({ - config: createConfig({ writeBackdropImages: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const checkbox = screen.getByRole('checkbox', { name: /Create backdrop images/i }); - expect(checkbox).toBeChecked(); - }); - - test('calls onConfigChange when checkbox is toggled from false to true', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ writeBackdropImages: false }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const checkbox = screen.getByRole('checkbox', { name: /Create backdrop images/i }); - await user.click(checkbox); - - expect(onConfigChange).toHaveBeenCalledTimes(1); - expect(onConfigChange).toHaveBeenCalledWith({ writeBackdropImages: true }); - }); - - test('calls onConfigChange when checkbox is toggled from true to false', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ writeBackdropImages: true }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const checkbox = screen.getByRole('checkbox', { name: /Create backdrop images/i }); - await user.click(checkbox); - - expect(onConfigChange).toHaveBeenCalledTimes(1); - expect(onConfigChange).toHaveBeenCalledWith({ writeBackdropImages: false }); - }); + test('cancelling the flat structure confirm leaves the setting alone', async () => { + mockFlatCount({ count: 0, channelNames: [] }); + const onConfigChange = jest.fn(); + renderCore({ onConfigChange }); + await userEvent.click(screen.getByRole('checkbox', { name: 'Flat file structure by default' })); + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + expect(onConfigChange).not.toHaveBeenCalled(); }); - describe('Prefix channel name in embedded video title Checkbox', () => { - const label = /Prefix channel name in embedded video title/i; - - test('is checked by default', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - expect(screen.getByRole('checkbox', { name: label })).toBeChecked(); - }); - - test('reflects prefixChannelNameInTitle state when false', () => { - const props = createSectionProps({ - config: createConfig({ prefixChannelNameInTitle: false }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - expect(screen.getByRole('checkbox', { name: label })).not.toBeChecked(); - }); - - test('calls onConfigChange with false when toggled off', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ prefixChannelNameInTitle: true }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await user.click(screen.getByRole('checkbox', { name: label })); - - expect(onConfigChange).toHaveBeenCalledTimes(1); - expect(onConfigChange).toHaveBeenCalledWith({ prefixChannelNameInTitle: false }); - }); - - test('calls onConfigChange with true when toggled on', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ prefixChannelNameInTitle: false }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await user.click(screen.getByRole('checkbox', { name: label })); - - expect(onConfigChange).toHaveBeenCalledTimes(1); - expect(onConfigChange).toHaveBeenCalledWith({ prefixChannelNameInTitle: true }); - }); + test('turning the flat structure off sends false after confirming', async () => { + mockFlatCount({ count: 0, channelNames: [] }); + const onConfigChange = jest.fn(); + renderCore({ config: { defaultSkipVideoFolder: true }, onConfigChange }); + await userEvent.click(screen.getByRole('checkbox', { name: 'Flat file structure by default' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled()); + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })); + expect(onConfigChange).toHaveBeenCalledWith({ defaultSkipVideoFolder: false }); }); - describe('YouTube Output Directory Field', () => { - test('renders YouTube Output Directory field', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByLabelText(/YouTube Output Directory/i)).toBeInTheDocument(); - }); - - test('displays the current output directory value', () => { - const props = createSectionProps({ - config: createConfig({ youtubeOutputDirectory: '/custom/path' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const input = screen.getByLabelText(/YouTube Output Directory/i) as HTMLInputElement; - expect(input).toHaveValue('/custom/path'); - }); - - test('field is always disabled', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const input = screen.getByLabelText(/YouTube Output Directory/i); - expect(input).toBeDisabled(); - }); - - test('shows Docker Volume chip', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const chips = screen.getAllByText('Docker Volume'); - expect(chips.length).toBeGreaterThan(0); - }); - - test('shows standard helper text for non-Elfhosted deployments', () => { - const props = createSectionProps({ - deploymentEnvironment: createDeploymentEnvironment({ platform: null }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText(/Configured via YOUTUBE_OUTPUT_DIR environment variable/i)).toBeInTheDocument(); - }); - - test('shows Elfhosted-specific helper text when platform is Elfhosted', () => { - const props = createSectionProps({ - deploymentEnvironment: createDeploymentEnvironment({ platform: 'Elfhosted' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText(/This path is configured by your platform deployment/i)).toBeInTheDocument(); - }); - - test('shows Elfhosted-specific helper text case-insensitively', () => { - const props = createSectionProps({ - deploymentEnvironment: createDeploymentEnvironment({ platform: 'ELFHOSTED' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText(/This path is configured by your platform deployment/i)).toBeInTheDocument(); - }); + test('a media server file switch writes its own config key', async () => { + const onConfigChange = jest.fn(); + renderCore({ config: { writeVideoNfoFiles: false }, onConfigChange }); + await userEvent.click(screen.getByRole('checkbox', { name: 'Video .nfo files' })); + expect(onConfigChange).toHaveBeenCalledWith({ writeVideoNfoFiles: true }); }); - describe('Enable Automatic Downloads Checkbox', () => { - test('renders Enable Automatic Downloads checkbox', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i })).toBeInTheDocument(); - }); - - test('checkbox reflects channelAutoDownload state when false', () => { - const props = createSectionProps({ - config: createConfig({ channelAutoDownload: false }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const checkbox = screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i }); - expect(checkbox).not.toBeChecked(); - }); - - test('checkbox reflects channelAutoDownload state when true', () => { - const props = createSectionProps({ - config: createConfig({ channelAutoDownload: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const checkbox = screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i }); - expect(checkbox).toBeChecked(); - }); - - test('calls onConfigChange when checkbox is toggled', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ channelAutoDownload: false }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const checkbox = screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i }); - await user.click(checkbox); - - expect(onConfigChange).toHaveBeenCalledTimes(1); - expect(onConfigChange).toHaveBeenCalledWith({ channelAutoDownload: true }); - }); - - test('has InfoTooltip with correct text', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - // InfoTooltip is rendered but we're not testing its internals - expect(screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i })).toBeInTheDocument(); - }); + test('4K shows the VP9/AV1 resolution warning', () => { + renderCore({ config: { preferredResolution: '2160' } }); + expect(screen.getByText(/1440p and 4K come as VP9 or AV1/)).toBeInTheDocument(); }); - describe('Download schedule summary', () => { - test('shows the frequency and links to Scheduling even when downloads are disabled', () => { - renderWithProviders(<CoreSettingsSection {...createSectionProps()} />); - expect(screen.getByText(/Schedule: Every 6 hours/)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Edit schedule' })).toHaveAttribute( - 'href', '/settings/scheduling#channelDownloadFrequency' - ); - }); + test('1080p shows no resolution warning', () => { + renderCore({ config: { preferredResolution: '1080' } }); + expect(screen.queryByText(/1440p and 4K come as VP9 or AV1/)).not.toBeInTheDocument(); }); - describe('Files to Download per Channel Select', () => { - test('renders Files to Download per Channel select', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText(/Files to Download per Channel/i)).toBeInTheDocument(); - }); - - test('displays current value', () => { - const props = createSectionProps({ - config: createConfig({ channelFilesToDownload: 5 }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('5 videos')).toBeInTheDocument(); - }); - - test('calls onConfigChange when value is changed', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ channelFilesToDownload: 3 }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const selectButton = screen.getByRole('button', { name: '3 videos' }); - await user.click(selectButton); - - const option = await screen.findByRole('option', { name: '7 videos' }); - await user.click(option); - - expect(onConfigChange).toHaveBeenCalledWith({ channelFilesToDownload: 7 }); - }); - - test('displays options 1-10 for default values', async () => { - const user = userEvent.setup(); - const props = createSectionProps({ - config: createConfig({ channelFilesToDownload: 3 }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const selectButton = screen.getByRole('button', { name: '3 videos' }); - await user.click(selectButton); - - expect(await screen.findByRole('option', { name: '1 video' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: '5 videos' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: '10 videos' })).toBeInTheDocument(); - }); - - test('includes current value if greater than 10', async () => { - const user = userEvent.setup(); - const props = createSectionProps({ - config: createConfig({ channelFilesToDownload: 15 }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const selectButton = screen.getByRole('button', { name: '15 videos' }); - await user.click(selectButton); - - expect(await screen.findByRole('option', { name: '15 videos' })).toBeInTheDocument(); - }); - - test('displays singular "video" for value of 1', () => { - const props = createSectionProps({ - config: createConfig({ channelFilesToDownload: 1 }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('1 video')).toBeInTheDocument(); - }); - - test('displays plural "videos" for values greater than 1', () => { - const props = createSectionProps({ - config: createConfig({ channelFilesToDownload: 2 }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('2 videos')).toBeInTheDocument(); - }); + test('the codec info note is always shown', () => { + renderCore(); + expect(screen.getByText(/H\.264 direct-plays on the most devices/)).toBeInTheDocument(); }); - describe('Preferred Resolution Select', () => { - test('renders Preferred Resolution select', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const labels = screen.getAllByText('Preferred Resolution'); - expect(labels.length).toBeGreaterThan(0); - }); - - test('displays current resolution value', () => { - const props = createSectionProps({ - config: createConfig({ preferredResolution: '1080' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('1080p')).toBeInTheDocument(); - }); - - test('calls onConfigChange when resolution is changed', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ preferredResolution: '1080' }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const selectButton = screen.getByRole('button', { name: '1080p' }); - await user.click(selectButton); - - const option = await screen.findByRole('option', { name: '4K (2160p)' }); - await user.click(option); - - expect(onConfigChange).toHaveBeenCalledWith({ preferredResolution: '2160' }); - }); - - test('displays all resolution options', async () => { - const user = userEvent.setup(); - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - const selectButton = screen.getByRole('button', { name: '1080p' }); - await user.click(selectButton); - - expect(await screen.findByRole('option', { name: '4K (2160p)' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: '1440p' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: '1080p' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: '720p' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: '480p' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: '360p' })).toBeInTheDocument(); - }); + test('selects are described by their row description', () => { + renderCore(); + expect(screen.getByRole('button', { name: 'Preferred resolution' })) + .toHaveAccessibleDescription('Youtarr takes the closest resolution YouTube has.'); }); - describe('Preferred Video Codec Select', () => { - test('renders Preferred Video Codec select', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const labels = screen.getAllByText('Preferred Video Codec'); - expect(labels.length).toBeGreaterThan(0); - }); - - test('displays current codec value', () => { - const props = createSectionProps({ - config: createConfig({ videoCodec: 'h264' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('H.264/AVC (Best Compatibility)')).toBeInTheDocument(); - }); - - test('calls onConfigChange when codec is changed', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ videoCodec: 'default' }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const selectButton = screen.getByRole('button', { name: 'Default (No Preference)' }); - await user.click(selectButton); - - const option = await screen.findByRole('option', { name: 'H.265/HEVC (Balanced)' }); - await user.click(option); - - expect(onConfigChange).toHaveBeenCalledWith({ videoCodec: 'h265' }); - }); - - test('displays all codec options', async () => { - const user = userEvent.setup(); - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - const selectButton = screen.getByRole('button', { name: 'Default (No Preference)' }); - await user.click(selectButton); - - expect(await screen.findByRole('option', { name: 'Default (No Preference)' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'H.264/AVC (Best Compatibility)' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'H.265/HEVC (Balanced)' })).toBeInTheDocument(); - }); - - test('displays helper text about codec preferences', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText(/YouTube caps H\.264 at 1080p/i)).toBeInTheDocument(); - }); + test('the videos per channel tab select stores a number', async () => { + const onConfigChange = jest.fn(); + renderCore({ config: { channelFilesToDownload: 3 }, onConfigChange }); + await userEvent.click(screen.getByRole('button', { name: 'Videos per channel tab and playlist' })); + await userEvent.click(await screen.findByRole('option', { name: '7 videos' })); + expect(onConfigChange).toHaveBeenCalledWith({ channelFilesToDownload: 7 }); }); - describe('Use external temp directory Checkbox', () => { - test('renders Use external temp directory checkbox', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByRole('checkbox', { name: /Use external temp directory/i })).toBeInTheDocument(); - }); - - test('checkbox reflects useTmpForDownloads state', () => { - const props = createSectionProps({ - config: createConfig({ useTmpForDownloads: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const checkbox = screen.getByRole('checkbox', { name: /Use external temp directory/i }); - expect(checkbox).toBeChecked(); - }); - - test('calls onConfigChange when checkbox is toggled', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ useTmpForDownloads: false }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const checkbox = screen.getByRole('checkbox', { name: /Use external temp directory/i }); - await user.click(checkbox); - - expect(onConfigChange).toHaveBeenCalledWith({ useTmpForDownloads: true }); - }); - - test('checkbox is disabled when platform managed', () => { - const props = createSectionProps({ - isPlatformManaged: createPlatformManagedState({ useTmpForDownloads: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const checkbox = screen.getByRole('checkbox', { name: /Use external temp directory/i }); - expect(checkbox).toBeDisabled(); - }); - - test('checkbox is enabled when not platform managed', () => { - const props = createSectionProps({ - isPlatformManaged: createPlatformManagedState({ useTmpForDownloads: false }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const checkbox = screen.getByRole('checkbox', { name: /Use external temp directory/i }); - expect(checkbox).not.toBeDisabled(); - }); - - test('shows platform managed chip when managed', () => { - const props = createSectionProps({ - isPlatformManaged: createPlatformManagedState({ useTmpForDownloads: true }), - deploymentEnvironment: createDeploymentEnvironment({ platform: 'TestPlatform' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('Platform Managed')).toBeInTheDocument(); - }); - - test('shows Elfhosted-specific chip when Elfhosted managed', () => { - const props = createSectionProps({ - isPlatformManaged: createPlatformManagedState({ useTmpForDownloads: true }), - deploymentEnvironment: createDeploymentEnvironment({ platform: 'Elfhosted' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('Managed by Elfhosted')).toBeInTheDocument(); - }); - - test('does not show platform managed chip when not managed', () => { - const props = createSectionProps({ - isPlatformManaged: createPlatformManagedState({ useTmpForDownloads: false }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.queryByText('Platform Managed')).not.toBeInTheDocument(); - expect(screen.queryByText('Managed by Elfhosted')).not.toBeInTheDocument(); - }); + test('subtitle languages nest under the switch', () => { + renderCore({ config: { subtitlesEnabled: true } }); + expect(screen.getByText('Videos without subtitles in these languages still download.')).toBeInTheDocument(); }); - describe('Flat File Structure Default Checkbox', () => { - test('renders unchecked by default', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - expect(toggle).not.toBeChecked(); - }); - - test('renders checked when defaultSkipVideoFolder is true', () => { - const props = createSectionProps({ config: createConfig({ defaultSkipVideoFolder: true }) }); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - expect(toggle).toBeChecked(); - }); - - describe('confirmation dialog', () => { - let mockFetch: jest.SpyInstance; - - beforeEach(() => { - mockFetch = jest.spyOn(global, 'fetch'); - }); - - afterEach(() => { - mockFetch.mockRestore(); - }); - - test('calls onConfigChange with defaultSkipVideoFolder true when toggled on', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - } as unknown as Response); - - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText('Change default file structure?'); - const confirmButton = screen.getByRole('button', { name: 'Confirm' }); - await waitFor(() => expect(confirmButton).toBeEnabled()); - await user.click(confirmButton); - - expect(props.onConfigChange).toHaveBeenCalledWith({ defaultSkipVideoFolder: true }); - }); - - test('does not call onConfigChange when the confirmation is cancelled', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - } as unknown as Response); - - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText('Change default file structure?'); - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - await user.click(cancelButton); - - expect(props.onConfigChange).not.toHaveBeenCalled(); - expect(screen.queryByText('Change default file structure?')).not.toBeInTheDocument(); - }); - - test('shows the affected channel count from the API', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 3, channelNames: ['A', 'B', 'C'] }) - } as unknown as Response); - - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText(/3 tracked channels/); - - expect(mockFetch).toHaveBeenCalledWith( - '/api/channels/using-global-file-structure', - { headers: { 'x-access-token': 'test-token' } } - ); - }); - - test('states that previously downloaded videos are not affected', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - } as unknown as Response); - - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText('Change default file structure?'); - expect(screen.getByText(/Previously downloaded videos are not affected/)).toBeInTheDocument(); - }); - - test('describes per-video subfolders when disabling', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - } as unknown as Response); - - const props = createSectionProps({ config: createConfig({ defaultSkipVideoFolder: true }) }); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText('Change default file structure?'); - expect( - screen.getByText(/New downloads for channels using the global setting will be saved in individual per-video subfolders\./) - ).toBeInTheDocument(); - }); - - test('disables Confirm while the affected-channel lookup is loading', async () => { - const user = userEvent.setup(); - mockFetch.mockReturnValue(new Promise(() => {})); - - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText('Change default file structure?'); - expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled(); - }); - - test('shows a warning instead of a zero count when the lookup fails', async () => { - const user = userEvent.setup(); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mockFetch.mockRejectedValue(new Error('Network error')); - - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText('Change default file structure?'); - await screen.findByText(/Could not determine how many channels are affected/); - expect( - screen.queryByText('No tracked channels are currently using the global setting.') - ).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled(); - - consoleSpy.mockRestore(); - }); - - test('shows a warning instead of a zero count when the lookup returns non-OK', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - json: jest.fn().mockResolvedValueOnce({ count: 0, channelNames: [] }) - } as unknown as Response); - - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - const toggle = screen.getByRole('checkbox', { name: /Flat file structure by default/i }); - await user.click(toggle); - - await screen.findByText('Change default file structure?'); - await screen.findByText(/Could not determine how many channels are affected/); - expect( - screen.queryByText('No tracked channels are currently using the global setting.') - ).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled(); - }); - }); - }); - - describe('Enable Subtitle Downloads Checkbox', () => { - test('renders Enable Subtitle Downloads checkbox', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByRole('checkbox', { name: /Enable Subtitle Downloads/i })).toBeInTheDocument(); - }); - - test('checkbox reflects subtitlesEnabled state', () => { - const props = createSectionProps({ - config: createConfig({ subtitlesEnabled: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const checkbox = screen.getByRole('checkbox', { name: /Enable Subtitle Downloads/i }); - expect(checkbox).toBeChecked(); - }); - - test('calls onConfigChange when checkbox is toggled', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ subtitlesEnabled: false }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const checkbox = screen.getByRole('checkbox', { name: /Enable Subtitle Downloads/i }); - await user.click(checkbox); - - expect(onConfigChange).toHaveBeenCalledWith({ subtitlesEnabled: true }); - }); - }); - - describe('Subtitle Language Selector', () => { - test('does not render when subtitles are disabled', () => { - const props = createSectionProps({ - config: createConfig({ subtitlesEnabled: false }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.queryByTestId('subtitle-language-selector')).not.toBeInTheDocument(); - }); - - test('renders when subtitles are enabled', () => { - const props = createSectionProps({ - config: createConfig({ subtitlesEnabled: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByTestId('subtitle-language-selector')).toBeInTheDocument(); - }); - - test('passes current subtitle language value to selector', () => { - const props = createSectionProps({ - config: createConfig({ subtitlesEnabled: true, subtitleLanguage: 'es,fr' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const input = screen.getByTestId('subtitle-language-input') as HTMLInputElement; - expect(input).toHaveValue('es,fr'); - }); - - test('calls onConfigChange when subtitle language changes', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ subtitlesEnabled: true, subtitleLanguage: 'en' }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const input = screen.getByTestId('subtitle-language-input'); - // Trigger a change event - await user.type(input, 'x'); - - // Check that onConfigChange was called (since we have a mocked component, - // just verify it gets called with subtitleLanguage property) - expect(onConfigChange).toHaveBeenCalled(); - const calls = onConfigChange.mock.calls; - const hasSubtitleLanguageUpdate = calls.some(call => 'subtitleLanguage' in call[0]); - expect(hasSubtitleLanguageUpdate).toBe(true); - }); - }); - - describe('InfoTooltip Integration', () => { - test('calls onMobileTooltipClick when provided', () => { - const onMobileTooltipClick = jest.fn(); - const props = createSectionProps({ onMobileTooltipClick }); - renderWithProviders(<CoreSettingsSection {...props} />); - // InfoTooltip components are present with onMobileTooltipClick prop - expect(screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i })).toBeInTheDocument(); - }); - - test('works without onMobileTooltipClick prop', () => { - const props = createSectionProps({ onMobileTooltipClick: undefined }); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByText('Core Settings')).toBeInTheDocument(); - }); - }); - - describe('Integration Tests', () => { - test('enabling subtitles shows language selector', async () => { - const props = createSectionProps({ - config: createConfig({ subtitlesEnabled: false }) - }); - const { rerender } = renderWithProviders(<CoreSettingsSection {...props} />); - - expect(screen.queryByTestId('subtitle-language-selector')).not.toBeInTheDocument(); - - // Simulate enabling subtitles - rerender( - <CoreSettingsSection - {...props} - config={createConfig({ subtitlesEnabled: true })} - /> - ); - - expect(screen.getByTestId('subtitle-language-selector')).toBeInTheDocument(); - }); - - test('handles multiple configuration changes', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ onConfigChange }); - renderWithProviders(<CoreSettingsSection {...props} />); - - // Toggle auto downloads - const autoDownloadCheckbox = screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i }); - await user.click(autoDownloadCheckbox); - expect(onConfigChange).toHaveBeenCalledWith({ channelAutoDownload: true }); - - // Toggle subtitles - const subtitlesCheckbox = screen.getByRole('checkbox', { name: /Enable Subtitle Downloads/i }); - await user.click(subtitlesCheckbox); - expect(onConfigChange).toHaveBeenCalledWith({ subtitlesEnabled: true }); - - // Toggle tmp downloads - const tmpCheckbox = screen.getByRole('checkbox', { name: /Use external temp directory/i }); - await user.click(tmpCheckbox); - expect(onConfigChange).toHaveBeenCalledWith({ useTmpForDownloads: true }); - - expect(onConfigChange).toHaveBeenCalledTimes(3); - }); - }); - - describe('Edge Cases', () => { - test('handles empty youtubeOutputDirectory', () => { - const props = createSectionProps({ - config: createConfig({ youtubeOutputDirectory: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - const input = screen.getByLabelText(/YouTube Output Directory/i) as HTMLInputElement; - expect(input).toHaveValue(''); - }); - - test('describes a custom expression in words', () => { - renderWithProviders(<CoreSettingsSection {...createSectionProps({ - config: createConfig({ channelDownloadFrequency: '15 9 * * 1-5' }) - })} />); - expect(screen.getByText(/At 09:15, Monday through Friday/)).toBeInTheDocument(); - }); - - test('handles all checkboxes unchecked', () => { - const props = createSectionProps({ - config: createConfig({ - channelAutoDownload: false, - subtitlesEnabled: false, - useTmpForDownloads: false - }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const autoDownload = screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i }); - const subtitles = screen.getByRole('checkbox', { name: /Enable Subtitle Downloads/i }); - const tmp = screen.getByRole('checkbox', { name: /Use external temp directory/i }); - - expect(autoDownload).not.toBeChecked(); - expect(subtitles).not.toBeChecked(); - expect(tmp).not.toBeChecked(); - }); - - test('handles all checkboxes checked', () => { - const props = createSectionProps({ - config: createConfig({ - channelAutoDownload: true, - subtitlesEnabled: true, - useTmpForDownloads: true - }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - const autoDownload = screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i }); - const subtitles = screen.getByRole('checkbox', { name: /Enable Subtitle Downloads/i }); - const tmp = screen.getByRole('checkbox', { name: /Use external temp directory/i }); - - expect(autoDownload).toBeChecked(); - expect(subtitles).toBeChecked(); - expect(tmp).toBeChecked(); - }); + test('the temp directory switch is disabled and badged when platform managed', () => { + renderCore({ isPlatformManaged: { useTmpForDownloads: true }, deploymentEnvironment: { platform: 'elfhosted' } }); + expect(screen.getByRole('checkbox', { name: 'External temp directory' })).toBeDisabled(); + expect(screen.getByText('Managed by Elfhosted')).toBeInTheDocument(); }); - describe('Accessibility', () => { - test('all select fields have accessible labels', () => { - const props = createSectionProps({ - config: createConfig({ channelAutoDownload: true }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - expect(screen.getByRole('link', { name: 'Edit schedule' })).toBeInTheDocument(); - expect(screen.getAllByText(/Files to Download per Channel/i).length).toBeGreaterThan(0); - expect(screen.getAllByText('Preferred Resolution').length).toBeGreaterThan(0); - expect(screen.getAllByText('Preferred Video Codec').length).toBeGreaterThan(0); - }); - - test('all checkboxes have accessible labels', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - expect(screen.getByRole('checkbox', { name: /Enable Automatic Downloads/i })).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: /Use external temp directory/i })).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: /Enable Subtitle Downloads/i })).toBeInTheDocument(); - }); - - test('text input has accessible label', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - expect(screen.getByLabelText(/YouTube Output Directory/i)).toBeInTheDocument(); - }); - }); - - describe('Default Subfolder Confirmation Dialog', () => { - let mockFetch: jest.SpyInstance; - - beforeEach(() => { - mockFetch = jest.spyOn(global, 'fetch'); - }); - - afterEach(() => { - mockFetch.mockRestore(); - }); - - const openSubfolderDialog = async (user: ReturnType<typeof userEvent.setup>) => { - // Click the button that triggers the onChange with a new folder value - const triggerButton = screen.getByTestId('trigger-subfolder-change'); - await user.click(triggerButton); - }; - - test('shows loading state while fetching affected channels', async () => { - const user = userEvent.setup(); - // Create a promise that we control to simulate loading state - let resolvePromise: (value: Response) => void; - const pendingPromise = new Promise<Response>((resolve) => { - resolvePromise = resolve; - }); - mockFetch.mockReturnValue(pendingPromise); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - // Wait for dialog to appear, then check loading state - await screen.findByText('Set Default Subfolder?'); - expect(screen.getByText('Checking affected channels...')).toBeInTheDocument(); - - // Resolve the promise to clean up - resolvePromise!({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - } as unknown as Response); - }); - - test('says existing videos stay put between folders with the same layout', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) }); - renderWithProviders(<CoreSettingsSection {...createSectionProps({ config: createConfig({ defaultSubfolder: '' }) })} />); - - await openSubfolderDialog(user); - - expect(await screen.findByText('Existing videos will not be moved.')).toBeInTheDocument(); - }); - - test('says videos move when the new default has the other layout', async () => { - const user = userEvent.setup(); - mockLayouts.NewFolder = 'tv'; - mockFetch.mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) }); - try { - renderWithProviders(<CoreSettingsSection {...createSectionProps({ config: createConfig({ defaultSubfolder: '' }) })} />); - - await openSubfolderDialog(user); - - expect(await screen.findByText(/the downloaded videos of the channels that use the default subfolder move/)).toBeInTheDocument(); - expect(screen.queryByText('Existing videos will not be moved.')).not.toBeInTheDocument(); - } finally { - delete mockLayouts.NewFolder; - } - }); - - test('shows "No tracked channels" message when count is 0', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - }); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - // Wait for the message to appear - await screen.findByText('No tracked channels are currently using Default Subfolder.'); - }); - - test('shows channel count when channels are affected', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ - count: 3, - channelNames: ['Channel A', 'Channel B', 'Channel C'] - }) - }); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - // Wait for the count message - await screen.findByText('3 tracked channels configured to use Default Subfolder.'); - }); - - test('shows singular "channel" when count is 1', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ - count: 1, - channelNames: ['Only Channel'] - }) - }); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - await screen.findByText('1 tracked channel configured to use Default Subfolder.'); - }); - - test('expands and collapses channel list on click', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ - count: 2, - channelNames: ['Channel A', 'Channel B'] - }) - }); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - // Wait for dialog to load - await screen.findByText('2 tracked channels configured to use Default Subfolder.'); - - // Initially should show "Show" link (collapsed state) - expect(screen.getByText('Show affected channels ▼')).toBeInTheDocument(); - - // Click to expand - const expandLink = screen.getByText('Show affected channels ▼'); - await user.click(expandLink); - - // Should now show "Hide" link (expanded state) - expect(screen.getByText('Hide affected channels ▲')).toBeInTheDocument(); - - // Channel names should be visible when expanded - expect(screen.getByText('Channel A')).toBeVisible(); - expect(screen.getByText('Channel B')).toBeVisible(); - - // Click to collapse - const collapseLink = screen.getByText('Hide affected channels ▲'); - await user.click(collapseLink); - - // Should show "Show" link again (collapsed state) - expect(screen.getByText('Show affected channels ▼')).toBeInTheDocument(); - }); - - test('calls onConfigChange with new subfolder on confirm', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - }); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - // Wait for dialog to load - await screen.findByText('No tracked channels are currently using Default Subfolder.'); - - // Click confirm - const confirmButton = screen.getByRole('button', { name: 'Set as Default' }); - await user.click(confirmButton); - - expect(onConfigChange).toHaveBeenCalledWith({ defaultSubfolder: 'NewFolder' }); - }); - - test('closes dialog on cancel without calling onConfigChange', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - }); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - // Wait for dialog to load - await screen.findByText('No tracked channels are currently using Default Subfolder.'); - - // Click cancel - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - await user.click(cancelButton); - - // Should not have called onConfigChange - expect(onConfigChange).not.toHaveBeenCalled(); - - // Dialog should be closed - expect(screen.queryByText('Set Default Subfolder?')).not.toBeInTheDocument(); - }); - - test('handles fetch error gracefully', async () => { - const user = userEvent.setup(); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mockFetch.mockRejectedValue(new Error('Network error')); - - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - // Should show zero count (fallback) after error - await screen.findByText('No tracked channels are currently using Default Subfolder.'); - - consoleSpy.mockRestore(); - }); - - describe('when triggered by adding a new subfolder', () => { - // Drives the page-level Add Subfolder action: open the dialog, enter a - // name, and submit (scoped with within() because the page action and the - // dialog submit button share the accessible name 'Add Subfolder'). - const openAddSubfolderDialog = async (user: ReturnType<typeof userEvent.setup>) => { - await user.click(screen.getByRole('button', { name: 'Add Subfolder' })); - const dialog = await screen.findByRole('dialog'); - await user.type(within(dialog).getByLabelText('Subfolder Name'), 'NewFolder'); - await user.click(within(dialog).getByRole('button', { name: 'Add Subfolder' })); - }; - - beforeEach(() => { - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ count: 0, channelNames: [] }) - }); - }); - - test('renders Add Subfolder and Manage Subfolders as separate page actions', () => { - const props = createSectionProps(); - renderWithProviders(<CoreSettingsSection {...props} />); - - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage Subfolders' })).toBeInTheDocument(); - }); - - test('persists the new subfolder via createSubfolder', async () => { - const user = userEvent.setup(); - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openAddSubfolderDialog(user); - - await waitFor(() => expect(mockCreateSubfolder).toHaveBeenCalledWith('NewFolder')); - }); - - test('shows new-subfolder title and creation notice', async () => { - const user = userEvent.setup(); - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openAddSubfolderDialog(user); - - await screen.findByText('Set New Subfolder as Default?'); - expect( - screen.getByText(/has been created and is available anywhere subfolders can be selected/) - ).toBeInTheDocument(); - }); - - test('"Don\'t Set as Default" closes the dialog without changing config', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openAddSubfolderDialog(user); - - await screen.findByText('Set New Subfolder as Default?'); - await user.click(screen.getByRole('button', { name: "Don't Set as Default" })); - - expect(onConfigChange).not.toHaveBeenCalled(); - expect(screen.queryByText('Set New Subfolder as Default?')).not.toBeInTheDocument(); - }); - - test('"Set as Default" applies the new subfolder as default', async () => { - const user = userEvent.setup(); - const onConfigChange = jest.fn(); - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }), - onConfigChange - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openAddSubfolderDialog(user); - - await screen.findByText('Set New Subfolder as Default?'); - await user.click(screen.getByRole('button', { name: 'Set as Default' })); - - expect(onConfigChange).toHaveBeenCalledWith({ defaultSubfolder: 'NewFolder' }); - }); - - test('selecting an existing subfolder keeps the standard title and Cancel button', async () => { - const user = userEvent.setup(); - const props = createSectionProps({ - config: createConfig({ defaultSubfolder: '' }) - }); - renderWithProviders(<CoreSettingsSection {...props} />); - - await openSubfolderDialog(user); - - await screen.findByText('Set Default Subfolder?'); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect( - screen.queryByText(/has been created and is available anywhere subfolders can be selected/) - ).not.toBeInTheDocument(); - }); - }); + test('scrolls to the section named in the URL hash', () => { + // jest.setup.ts stubs scrollIntoView on HTMLElement.prototype, which shadows Element.prototype + const scrollIntoView = jest.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => {}); + try { + renderCore({ route: '/settings/core#naming' }); + expect(scrollIntoView.mock.instances[0]).toBe(screen.getByRole('region', { name: 'Naming' })); + } finally { + scrollIntoView.mockRestore(); + } }); }); diff --git a/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.story.tsx b/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.story.tsx index f852d7253..e1c017d89 100644 --- a/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.story.tsx +++ b/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.story.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from 'storybook/test'; import React, { useState } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { DEFAULT_CONFIG } from '../../../../config/configSchema'; import { PlexIntegrationSection } from '../PlexIntegrationSection'; import { PlatformManagedState } from '../../types'; @@ -8,6 +9,7 @@ import { PlatformManagedState } from '../../types'; const meta: Meta<typeof PlexIntegrationSection> = { title: 'Components/Configuration/Sections/PlexIntegrationSection', component: PlexIntegrationSection, + decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>], render: (args) => { const [config, setConfig] = useState({ ...DEFAULT_CONFIG, diff --git a/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx b/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx index 440c312bf..91b4af572 100644 --- a/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx @@ -1042,4 +1042,13 @@ describe('PlexIntegrationSection Component', () => { expect(link).toHaveAttribute('target', '_blank'); }); }); + + describe('Library folders pointer', () => { + test('points at Library folders for each folder\'s refresh library', () => { + renderWithProviders(<PlexIntegrationSection {...createSectionProps()} />); + expect(screen.queryByText(/Per-Subfolder Library Mappings/)).not.toBeInTheDocument(); + expect(screen.getByText(/Each library folder's refresh library is set on its page in Library folders\./)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open Library folders' })).toHaveAttribute('href', '/settings/library'); + }); + }); }); diff --git a/client/src/components/Configuration/sections/__tests__/PlexSubfolderMappings.test.tsx b/client/src/components/Configuration/sections/__tests__/PlexSubfolderMappings.test.tsx deleted file mode 100644 index 79d977e62..000000000 --- a/client/src/components/Configuration/sections/__tests__/PlexSubfolderMappings.test.tsx +++ /dev/null @@ -1,455 +0,0 @@ -import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; -import { PlexSubfolderMappings, PlexSubfolderMapping } from '../PlexSubfolderMappings'; -import { renderWithProviders } from '../../../../test-utils'; -import { PlexConnectionStatus } from '../../types'; -import { useSubfolders } from '../../../../hooks/useSubfolders'; - -jest.mock('../../../../hooks/useSubfolders', () => ({ - useSubfolders: jest.fn(), -})); - -const mockUseSubfolders = useSubfolders as jest.MockedFunction<typeof useSubfolders>; - -const MOCK_LIBRARIES = [ - { id: '1', title: 'YouTube' }, - { id: '2', title: 'Kids Shows' }, -]; - -const MOCK_SUBFOLDERS = ['__kids', '__music']; - -const DEFAULT_PROPS = { - mappings: [] as PlexSubfolderMapping[], - onMappingsChange: jest.fn(), - token: 'test-token', - plexConnectionStatus: 'connected' as PlexConnectionStatus, - plexLibraries: MOCK_LIBRARIES, -}; - -describe('PlexSubfolderMappings', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockUseSubfolders.mockReturnValue({ - subfolders: MOCK_SUBFOLDERS, - loading: false, - error: null, - refetch: jest.fn(), - createSubfolder: jest.fn(), - deleteSubfolder: jest.fn(), - }); - }); - - describe('when plexConnectionStatus is not connected', () => { - test('renders nothing when status is not_tested and no mappings exist', () => { - renderWithProviders( - <PlexSubfolderMappings {...DEFAULT_PROPS} plexConnectionStatus="not_tested" /> - ); - expect(screen.queryByText('Per-Subfolder Library Mappings')).not.toBeInTheDocument(); - }); - - test('renders nothing when status is not_connected and no mappings exist', () => { - renderWithProviders( - <PlexSubfolderMappings {...DEFAULT_PROPS} plexConnectionStatus="not_connected" /> - ); - expect(screen.queryByText('Per-Subfolder Library Mappings')).not.toBeInTheDocument(); - }); - - test('shows existing mappings with delete buttons when disconnected', () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - plexConnectionStatus="not_connected" - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - expect(screen.getByText('Per-Subfolder Library Mappings')).toBeInTheDocument(); - expect(screen.getByText('__kids')).toBeInTheDocument(); - expect(screen.getByTestId('delete-mapping-kids')).toBeInTheDocument(); - }); - - test('shows "Library ID:" fallback for library title when disconnected and libraries are empty', () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - plexConnectionStatus="not_connected" - plexLibraries={[]} - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - expect(screen.getByText('Library ID: 2')).toBeInTheDocument(); - // No duplicated "(id: 2)" when id is already the primary display - expect(screen.queryByText('(id: 2)')).not.toBeInTheDocument(); - }); - - test('still shows the resolved library title when disconnected if libraries prop was populated earlier', () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - plexConnectionStatus="not_connected" - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - expect(screen.getByText('Kids Shows')).toBeInTheDocument(); - expect(screen.getByText('(id: 2)')).toBeInTheDocument(); - }); - - test('Add Mapping button is disabled when disconnected with existing mappings', () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - plexConnectionStatus="not_connected" - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - expect(screen.getByTestId('add-mapping-button')).toBeDisabled(); - }); - }); - - describe('when plexConnectionStatus is connected', () => { - test('shows the section title "Per-Subfolder Library Mappings"', async () => { - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - await waitFor(() => { - expect(screen.getByText('Per-Subfolder Library Mappings')).toBeInTheDocument(); - }); - }); - - test('shows "No per-subfolder mappings configured" when mappings array is empty', async () => { - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - await waitFor(() => { - expect( - screen.getByText(/No per-subfolder mappings configured/) - ).toBeInTheDocument(); - }); - }); - - test('shows the "Add Mapping" button', async () => { - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - }); - - test('calls useSubfolders with null token when not connected', () => { - renderWithProviders( - <PlexSubfolderMappings {...DEFAULT_PROPS} plexConnectionStatus="not_connected" /> - ); - expect(mockUseSubfolders).toHaveBeenCalledWith(null); - }); - }); - - describe('loading and error states', () => { - test('shows loading indicator while the subfolder fetch is in progress', () => { - mockUseSubfolders.mockReturnValue({ - subfolders: [], - loading: true, - error: null, - refetch: jest.fn(), - createSubfolder: jest.fn(), - deleteSubfolder: jest.fn(), - }); - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - expect(screen.getByText(/Loading subfolders/)).toBeInTheDocument(); - }); - - test('shows error alert when the subfolder fetch fails', async () => { - mockUseSubfolders.mockReturnValue({ - subfolders: [], - loading: false, - error: new Error('Network error'), - refetch: jest.fn(), - createSubfolder: jest.fn(), - deleteSubfolder: jest.fn(), - }); - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - await waitFor(() => { - expect( - screen.getByText(/Could not load channel subfolders/) - ).toBeInTheDocument(); - }); - }); - - test('still shows mapping rows with resolved library titles when the subfolder fetch fails', async () => { - mockUseSubfolders.mockReturnValue({ - subfolders: [], - loading: false, - error: new Error('Subfolders failed'), - refetch: jest.fn(), - createSubfolder: jest.fn(), - deleteSubfolder: jest.fn(), - }); - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - await waitFor(() => { - expect( - screen.getByText(/Could not load channel subfolders/) - ).toBeInTheDocument(); - }); - expect(screen.getByText('Kids Shows')).toBeInTheDocument(); - expect(screen.getByText('(id: 2)')).toBeInTheDocument(); - }); - }); - - describe('existing mappings display', () => { - test('renders a table row for each mapping showing subfolder name with __ prefix', async () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - await waitFor(() => { - expect(screen.getByText('__kids')).toBeInTheDocument(); - }); - }); - - test('renders "Root folder" label for a mapping with subfolder: null', async () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={[{ subfolder: null, libraryId: '1' }]} - /> - ); - await waitFor(() => { - expect(screen.getByText('Root folder')).toBeInTheDocument(); - }); - }); - - test('renders the library title with an "(id: X)" suffix for resolved libraries', async () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - await waitFor(() => { - expect(screen.getByText('Kids Shows')).toBeInTheDocument(); - }); - expect(screen.getByText('(id: 2)')).toBeInTheDocument(); - }); - }); - - describe('adding a mapping', () => { - test('clicking "Add Mapping" shows the subfolder and library selects', async () => { - const user = userEvent.setup(); - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - - expect(screen.getByRole('button', { name: 'Subfolder' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Plex Library' })).toBeInTheDocument(); - }); - - test('clicking "Cancel" hides the form', async () => { - const user = userEvent.setup(); - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - expect(screen.getByRole('button', { name: 'Subfolder' })).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(screen.queryByRole('button', { name: 'Subfolder' })).not.toBeInTheDocument(); - }); - - test('"Add" button is disabled when no subfolder is selected', async () => { - const user = userEvent.setup(); - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - - expect(screen.getByTestId('confirm-add-mapping-button')).toBeDisabled(); - }); - - test('"Add" button is disabled when no library is selected', async () => { - const user = userEvent.setup(); - renderWithProviders(<PlexSubfolderMappings {...DEFAULT_PROPS} />); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.queryByText(/Loading subfolders/)).not.toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - - await user.click(screen.getByRole('button', { name: 'Subfolder' })); - await user.click(screen.getByRole('option', { name: '__kids' })); - - expect(screen.getByTestId('confirm-add-mapping-button')).toBeDisabled(); - }); - - test('"Add" button is disabled when the selected subfolder is already mapped (duplicate)', async () => { - const user = userEvent.setup(); - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={[{ subfolder: 'kids', libraryId: '1' }]} - /> - ); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.queryByText(/Loading subfolders/)).not.toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - - await user.click(screen.getByRole('button', { name: 'Subfolder' })); - - const kidsOption = screen.getByRole('option', { name: '__kids' }); - expect(kidsOption).toHaveAttribute('data-disabled'); - }); - - test('selecting a subfolder and library and clicking "Add" calls onMappingsChange with the new mapping appended', async () => { - const user = userEvent.setup(); - const onMappingsChange = jest.fn(); - renderWithProviders( - <PlexSubfolderMappings {...DEFAULT_PROPS} onMappingsChange={onMappingsChange} /> - ); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.queryByText(/Loading subfolders/)).not.toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - - await user.click(screen.getByRole('button', { name: 'Subfolder' })); - await user.click(screen.getByRole('option', { name: '__kids' })); - - await user.click(screen.getByRole('button', { name: 'Plex Library' })); - await user.click(screen.getByRole('option', { name: 'YouTube' })); - - await user.click(screen.getByTestId('confirm-add-mapping-button')); - - expect(onMappingsChange).toHaveBeenCalledWith([{ subfolder: 'kids', libraryId: '1' }]); - }); - - test('adding a root-folder mapping stores subfolder: null', async () => { - const user = userEvent.setup(); - const onMappingsChange = jest.fn(); - renderWithProviders( - <PlexSubfolderMappings {...DEFAULT_PROPS} onMappingsChange={onMappingsChange} /> - ); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.queryByText(/Loading subfolders/)).not.toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - - await user.click(screen.getByRole('button', { name: 'Subfolder' })); - await user.click(screen.getByRole('option', { name: 'Root folder' })); - - await user.click(screen.getByRole('button', { name: 'Plex Library' })); - await user.click(screen.getByRole('option', { name: 'YouTube' })); - - await user.click(screen.getByTestId('confirm-add-mapping-button')); - - expect(onMappingsChange).toHaveBeenCalledWith([{ subfolder: null, libraryId: '1' }]); - }); - - test('the stored subfolder value has no __ prefix', async () => { - const user = userEvent.setup(); - const onMappingsChange = jest.fn(); - renderWithProviders( - <PlexSubfolderMappings {...DEFAULT_PROPS} onMappingsChange={onMappingsChange} /> - ); - - await waitFor(() => { - expect(screen.getByTestId('add-mapping-button')).toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.queryByText(/Loading subfolders/)).not.toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('add-mapping-button')); - - await user.click(screen.getByRole('button', { name: 'Subfolder' })); - await user.click(screen.getByRole('option', { name: '__music' })); - - await user.click(screen.getByRole('button', { name: 'Plex Library' })); - await user.click(screen.getByRole('option', { name: 'Kids Shows' })); - - await user.click(screen.getByTestId('confirm-add-mapping-button')); - - const calledWith = onMappingsChange.mock.calls[0][0] as PlexSubfolderMapping[]; - expect(calledWith[0].subfolder).toBe('music'); - }); - }); - - describe('deleting a mapping', () => { - test('clicking the delete button for a mapping calls onMappingsChange with that mapping removed', async () => { - const user = userEvent.setup(); - const onMappingsChange = jest.fn(); - const mappings: PlexSubfolderMapping[] = [ - { subfolder: 'kids', libraryId: '2' }, - { subfolder: 'music', libraryId: '1' }, - ]; - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={mappings} - onMappingsChange={onMappingsChange} - /> - ); - - await waitFor(() => { - expect(screen.getByTestId('delete-mapping-kids')).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('delete-mapping-kids')); - - expect(onMappingsChange).toHaveBeenCalledWith([{ subfolder: 'music', libraryId: '1' }]); - }); - - test('the delete button data-testid is delete-mapping-kids for subfolder kids', async () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={[{ subfolder: 'kids', libraryId: '2' }]} - /> - ); - await waitFor(() => { - expect(screen.getByTestId('delete-mapping-kids')).toBeInTheDocument(); - }); - }); - - test('the delete button data-testid is delete-mapping-root for null subfolder', async () => { - renderWithProviders( - <PlexSubfolderMappings - {...DEFAULT_PROPS} - mappings={[{ subfolder: null, libraryId: '1' }]} - /> - ); - await waitFor(() => { - expect(screen.getByTestId('delete-mapping-root')).toBeInTheDocument(); - }); - }); - }); -}); diff --git a/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx b/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx index 952894922..6f1cb7da8 100644 --- a/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx @@ -83,7 +83,7 @@ test('explains that an idle schedule waits for its feature and links to where to renderWithProviders(<SchedulingSection {...props} />); const downloads = within(screen.getByRole('region', { name: 'Automatic downloads' })); expect(downloads.getByText(/Automatic downloads are off, so this schedule is idle/)).toBeInTheDocument(); - expect(downloads.getByRole('link', { name: 'Turn on in Core settings' })).toHaveAttribute('href', '/settings/core'); + expect(downloads.getByRole('link', { name: 'Turn on in Core settings' })).toHaveAttribute('href', '/settings/core#downloads'); }); test('disabled video removal still allows scheduling empty-folder cleanup', () => { diff --git a/client/src/components/Configuration/sections/components/DefaultPlexLibraryDisplay.tsx b/client/src/components/Configuration/sections/components/DefaultPlexLibraryDisplay.tsx index ebc5ad2dc..35937f21c 100644 --- a/client/src/components/Configuration/sections/components/DefaultPlexLibraryDisplay.tsx +++ b/client/src/components/Configuration/sections/components/DefaultPlexLibraryDisplay.tsx @@ -9,7 +9,7 @@ import { import { PlexLibraryLabel } from './PlexLibraryLabel'; const DEFAULT_LIBRARY_TOOLTIP = - 'Youtarr refreshes this library after downloads to the root folder, or to any subfolder that does not have its own mapping below.'; + 'Youtarr refreshes this library after downloads to the main folder, and any library folder whose page in Library folders does not choose another library.'; interface DefaultPlexLibraryDisplayProps { libraries: PlexLibrary[]; diff --git a/client/src/components/Configuration/sections/components/FlatStructureDialog.tsx b/client/src/components/Configuration/sections/components/FlatStructureDialog.tsx new file mode 100644 index 000000000..1bf87d73e --- /dev/null +++ b/client/src/components/Configuration/sections/components/FlatStructureDialog.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useId, useState } from 'react'; +import axios from 'axios'; +import { + Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, +} from '../../../ui'; + +interface AffectedChannels { + count: number; + channelNames: string[]; +} + +export interface FlatStructureDialogProps { + open: boolean; + /** The switch is being turned on (flat) or off (per-video folders) */ + turningOn: boolean; + token: string | null; + onConfirm: () => void; + onCancel: () => void; +} + +/** Confirm a change of the global flat-structure default (Core 5). */ +export function FlatStructureDialog({ open, turningOn, token, onConfirm, onCancel }: FlatStructureDialogProps) { + const titleId = useId(); + const [affected, setAffected] = useState<AffectedChannels | null>(null); + const [loading, setLoading] = useState(false); + const [showList, setShowList] = useState(false); + + useEffect(() => { + if (!open) return undefined; + let cancelled = false; + setLoading(true); + setAffected(null); + setShowList(false); + axios.get<AffectedChannels>('/api/channels/using-global-file-structure', { headers: { 'x-access-token': token || '' } }) + .then((response) => { if (!cancelled) setAffected(response.data); }) + .catch(() => { if (!cancelled) setAffected(null); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [open, token]); + + return ( + <Dialog open={open} onClose={onCancel} aria-labelledby={titleId}> + <DialogTitle id={titleId}>Change default file structure?</DialogTitle> + <DialogContent> + <DialogContentText> + {turningOn + ? 'New downloads for channels using the global setting will be saved directly in the channel folder (flat structure, no per-video subfolders).' + : 'New downloads for channels using the global setting will be saved in individual per-video subfolders.'} + </DialogContentText> + <div className="my-4"> + {loading ? ( + <span className="flex items-center gap-2"><CircularProgress size={16} />Checking affected channels...</span> + ) : affected === null ? ( + <DialogContentText className="text-warning"> + Could not determine how many channels are affected. You can still continue, but the affected channel count is unknown. + </DialogContentText> + ) : affected.count === 0 ? ( + <DialogContentText>No tracked channels are currently using the global setting.</DialogContentText> + ) : ( + <> + <DialogContentText> + {affected.count} tracked channel{affected.count !== 1 ? 's' : ''} follow{affected.count === 1 ? 's' : ''} the global setting and will be affected. + </DialogContentText> + <button type="button" onClick={() => setShowList((shown) => !shown)} + className="mt-1 text-sm text-primary underline max-md:inline-flex max-md:min-h-[44px] max-md:items-center"> + {showList ? 'Hide affected channels' : 'Show affected channels'} + </button> + {showList && ( + <ul className="mt-2 max-h-[200px] overflow-auto rounded bg-muted/50 py-2 pl-6 text-sm"> + {affected.channelNames.map((name, index) => <li key={`${index}-${name}`}>{name}</li>)} + </ul> + )} + </> + )} + </div> + <DialogContentText>Channels in TV shows folders aren't affected: their episodes always go straight into Season folders.</DialogContentText> + <DialogContentText className="mt-2"> + Previously downloaded videos are not affected. Existing files will not be moved or renamed; only new downloads use the new structure. + </DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={onCancel}>Cancel</Button> + <Button variant="contained" onClick={onConfirm} disabled={loading}>Confirm</Button> + </DialogActions> + </Dialog> + ); +} diff --git a/client/src/components/Configuration/sections/components/LibraryFoldersCard.tsx b/client/src/components/Configuration/sections/components/LibraryFoldersCard.tsx new file mode 100644 index 000000000..0818073e1 --- /dev/null +++ b/client/src/components/Configuration/sections/components/LibraryFoldersCard.tsx @@ -0,0 +1,296 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowRight, CheckCircle, ChevronRight, Film, Info, Library, Loader2, Tv, Warning, XCircle } from '../../../../lib/icons'; +import { Button } from '../../../ui'; +import { cn } from '../../../../lib/cn'; +import type { LibraryFolder } from '../../../../types/tvShows'; +import type { ConfigState, DeploymentEnvironment, PlatformManagedState } from '../../types'; +import { useLibraryFolders } from '../../../../hooks/useLibraryFolders'; +import { useLibraryCheck } from '../../../../hooks/useLibraryCheck'; +import { useMediaServerStatus } from '../../../../hooks/useMediaServerStatus'; +import { useContainerWidth } from '../../../../hooks/useContainerWidth'; +import { useNow } from '../../hooks/useNow'; +import { LayoutChip } from '../../../shared/LayoutChip'; +import { InfoTooltip } from '../../common/InfoTooltip'; +import { PHONE_HIT_AREA } from '../../common/SettingRow'; +import { AttentionItem, SERVER_NAMES, SERVER_ORDER, buildAttention, checkStatus, serversOf } from '../../../../utils/libraryAttention'; +import { LIBRARY_FOLDERS_PATH, libraryFolderLabel, libraryFolderUrl } from '../../../../utils/libraryLayouts'; +import { noLibraryLine } from '../../../LibraryFolders/mediaServerText'; +import { NO_SERVERS_LEAD, NO_SERVERS_TAIL, checkCell } from './libraryCardText'; + +const WIDE = 900; +const MEDIUM = 520; +const MAX_ROWS = 3; +const INCLUDE: Array<'usage'> = ['usage']; +const CELL_ICONS = { spinner: Loader2, info: Info, check: CheckCircle, alert: Warning, x: XCircle }; +const CELL_TONES = { + muted: 'text-muted-foreground', success: 'text-success', warning: 'text-warning font-semibold', + destructive: 'text-destructive', foreground: 'text-foreground', +}; +/** 44px targets for inline text buttons and links on phones */ +const PHONE_TARGET = 'max-md:inline-flex max-md:min-h-[44px] max-md:items-center'; +const DOWNLOADS_SUB = { + elfhosted: 'This path is configured by your platform deployment and cannot be changed here.', + dataPath: 'Set by the DATA_PATH environment variable.', + docker: 'Docker volume, set by YOUTUBE_OUTPUT_DIR. Edit .env and restart to change it.', +}; + +type CardSize = 'wide' | 'medium' | 'narrow'; +type FolderItem = Extract<AttentionItem, { kind: 'folder' }>; + +export interface LibraryFoldersCardProps { + token: string | null; + config: ConfigState; + isPlatformManaged: PlatformManagedState; + deploymentEnvironment: DeploymentEnvironment; + onMobileTooltipClick?: (text: string) => void; +} + +interface Fact { + label: string; + value: React.ReactNode; + sub?: React.ReactNode; + /** The narrow card keeps this sub line (the others drop theirs) */ + keepSubWhenNarrow?: boolean; +} + +/** Core's summary of the library folders, linking into the Library folders page (Core 3). */ +export function LibraryFoldersCard({ token, config, isPlatformManaged, deploymentEnvironment, onMobileTooltipClick }: LibraryFoldersCardProps) { + const [measureRef, width] = useContainerWidth<HTMLDivElement>(); + const size: CardSize = width === null || width >= WIDE ? 'wide' : width >= MEDIUM ? 'medium' : 'narrow'; + const narrow = size === 'narrow'; + const now = useNow(); + const library = useLibraryFolders(token, { include: INCLUDE }); + const check = useLibraryCheck(token); + const { status: serverStatus, loading: serverStatusLoading } = useMediaServerStatus(token); + const configured = SERVER_ORDER.filter((type) => serverStatus[type]); + const checkState = { data: check.data, loading: check.loading, error: check.error, lastCheckedAt: check.lastCheckedAt }; + const attention = buildAttention(library.folders, checkState, configured); + const status = checkStatus(checkState, configured); + // Servers the check reported, else the configured ones: "not set up" names only servers neither knows. + const known = serversOf(checkState, configured).map((server) => server.serverType); + const cell = checkCell({ + status, attentionCount: attention.length, configured: known, now, timeZone: deploymentEnvironment.timezone ?? null, + serversKnown: !serverStatusLoading || check.data !== null, + }); + const CellIcon = CELL_ICONS[cell.icon]; + const loadingFolders = !library.loaded || (library.loading && library.folders.length === 0); + const defaultFolder = library.folders.find((folder) => folder.isDefault) ?? null; + const onlyMain = !loadingFolders && library.folders.length === 1; + const videoCount = library.folders.filter((folder) => folder.layout === 'videos').length; + const tvCount = library.folders.length - videoCount; + const downloadsSub = deploymentEnvironment.platform?.toLowerCase() === 'elfhosted' ? DOWNLOADS_SUB.elfhosted + : isPlatformManaged.youtubeOutputDirectory ? DOWNLOADS_SUB.dataPath : DOWNLOADS_SUB.docker; + const tvDefault = defaultFolder?.layout === 'tv'; + const retryCheck = cell.retry && ( + <button type="button" onClick={() => { void check.refetch(); }} className={cn('text-primary underline', PHONE_TARGET)}>Try again</button> + ); + const serverLinks = SERVER_ORDER.map((type, index) => ( + <React.Fragment key={type}> + {index > 0 ? (index === SERVER_ORDER.length - 1 ? ' or ' : ', ') : ''} + <Link to={`/settings/${type}`} className={cn('text-primary underline', PHONE_TARGET)}>{SERVER_NAMES[type]}</Link> + </React.Fragment> + )); + const checkSub = cell.linksServers ? <>{NO_SERVERS_LEAD}{serverLinks}{NO_SERVERS_TAIL}</> + : cell.sub || cell.retry ? <>{cell.sub}{cell.sub && cell.retry ? ' ' : ''}{retryCheck}</> : undefined; + + const facts: Fact[] = [ + { + label: 'Downloads folder', + value: <span className="font-mono text-[13px]">{config.youtubeOutputDirectory || 'Not set'}</span>, + sub: downloadsSub, + }, + { + label: 'Default folder', + value: loadingFolders ? <span className="block h-4 w-24 animate-pulse rounded bg-muted" /> : ( + <span className="inline-flex flex-wrap items-center gap-1.5 font-medium"> + <span>{libraryFolderLabel(defaultFolder?.name ?? '')}</span> + <LayoutChip layout={defaultFolder?.layout ?? 'videos'} /> + {!onlyMain && !narrow && defaultFolder && ( + <Link to={libraryFolderUrl(defaultFolder.name)} className={cn('text-[13px] font-normal text-primary underline', PHONE_TARGET)}>Change</Link> + )} + </span> + ), + sub: loadingFolders ? undefined : 'Where channels set to the default folder go, and the fallback for downloads with no more specific folder.' + + `${tvDefault ? " Each channel you don't subscribe to becomes its own show." : ''}`, + }, + { + label: 'Folders', + value: loadingFolders ? <span className="block h-4 w-16 animate-pulse rounded bg-muted" /> + : onlyMain ? 'Main folder only' + : <span><span className="font-display text-[22px]">{library.folders.length}</span> library {library.folders.length === 1 ? 'folder' : 'folders'}</span>, + sub: loadingFolders ? undefined : onlyMain + ? 'Add a folder for each kind of content you want to keep apart, like __Kids or __TV Shows.' + : ( + <span className="flex flex-wrap gap-x-3"> + <span className="inline-flex items-center gap-1"><Film size={12} aria-hidden="true" />{videoCount} Videos {videoCount === 1 ? 'folder' : 'folders'}</span> + <span className="inline-flex items-center gap-1 text-info"><Tv size={12} aria-hidden="true" />{tvCount} TV {tvCount === 1 ? 'folder' : 'folders'}</span> + </span> + ), + }, + { + label: 'Media server check', + value: ( + <span className={cn('inline-flex items-center gap-1.5', CELL_TONES[cell.tone])}> + <CellIcon size={14} aria-hidden="true" className={cell.icon === 'spinner' ? 'animate-spin' : undefined} />{cell.value} + </span> + ), + sub: checkSub, + keepSubWhenNarrow: true, + }, + ]; + + const attentionShown = check.data && attention.length > 0 && !library.error; + const iconBox = ( + <span className={cn('inline-flex shrink-0 items-center justify-center rounded-ui border border-primary/40 text-primary', narrow ? 'h-9 w-9' : 'h-10 w-10')}> + <Library size={18} aria-hidden="true" /> + </span> + ); + const heading = <h2 id="library-folders-title" className="font-display text-lg font-semibold leading-6">Library folders</h2>; + const lead = ( + <p className={cn('max-w-[640px] text-sm text-muted-foreground', narrow ? 'mt-2.5' : 'mt-1')}> + Library folders decide where downloads are saved. Each folder's layout, <span className="text-foreground">Videos</span>{' '} + (Youtarr's existing movie-style layout) or <span className="text-foreground">TV shows</span>, decides how its files are + named and which library type Plex, Jellyfin, Emby and Kodi need to show it. + </p> + ); + const manageLink = ( + <Link to={LIBRARY_FOLDERS_PATH} + className={cn('inline-flex shrink-0 items-center justify-center gap-1.5 rounded-ui bg-primary px-3 text-sm font-medium text-primary-foreground', + narrow ? 'mt-3.5 min-h-[44px] w-full' : 'h-9')}> + Manage library folders<ArrowRight size={14} aria-hidden="true" /> + </Link> + ); + + return ( + <section id="library-folders" aria-labelledby="library-folders-title" aria-busy={loadingFolders || undefined} + className="scroll-mt-24 rounded-ui border border-border bg-card"> + <div ref={measureRef}> + {narrow ? ( + <div className="p-4"> + <div className="flex items-center gap-3">{iconBox}{heading}</div> + {lead} + {manageLink} + </div> + ) : ( + <div className="flex items-start justify-between gap-6 px-6 pb-5 pt-6"> + <div className="flex min-w-0 gap-3"> + {iconBox} + <div className="min-w-0">{heading}{lead}</div> + </div> + {manageLink} + </div> + )} + {library.error ? ( + <p className={cn('flex items-center gap-2 border-t border-border/60 py-4 text-[13px]', narrow ? 'px-4' : 'px-6')}> + <Warning size={15} aria-hidden="true" className="text-warning" /> + <span className="flex-1">Couldn't load library folders: {library.error}.</span> + <Button variant="text" onClick={() => { void library.refetch(); }} className="max-md:min-h-[44px]">Try again</Button> + </p> + ) : narrow ? ( + <dl className="mx-4 mb-4 divide-y divide-border rounded-ui border border-border"> + {facts.map((fact) => ( + <div key={fact.label} className="grid min-h-[44px] grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-0.5 px-3 py-2.5"> + <dt className="flex items-center text-[13px] text-muted-foreground"> + {fact.label} + {fact.label === 'Downloads folder' && ( + <span className={cn('inline-flex', PHONE_HIT_AREA)}> + <InfoTooltip text={downloadsSub} onMobileClick={onMobileTooltipClick} /> + </span> + )} + </dt> + <dd className="min-w-0 text-right [overflow-wrap:anywhere]">{fact.value}</dd> + {fact.keepSubWhenNarrow && fact.sub && <dd className="col-span-2 text-xs text-muted-foreground">{fact.sub}</dd>} + </div> + ))} + </dl> + ) : ( + <dl className={cn('grid border-t border-border/60', size === 'wide' ? 'grid-cols-[1.25fr_1.3fr_1fr_1.15fr] divide-x divide-border/60' : 'grid-cols-2')}> + {facts.map((fact) => ( + <div key={fact.label} className="px-6 py-4"> + <dt className="text-[11px] font-semibold uppercase tracking-[.06em] text-muted-foreground">{fact.label}</dt> + <dd className="mt-1.5 [overflow-wrap:anywhere]">{fact.value}</dd> + {fact.sub && <dd className="mt-1.5 text-xs leading-[18px] text-muted-foreground">{fact.sub}</dd>} + </div> + ))} + </dl> + )} + {attentionShown && ( + <div className={cn('border-t border-border/60', narrow ? 'px-4 pb-4 pt-3' : 'px-6 py-4')}> + <p className="text-[11px] font-semibold uppercase tracking-[.06em] text-muted-foreground">Needs attention</p> + <ul className="mt-2 flex flex-col gap-2"> + {attention.slice(0, MAX_ROWS).map((item) => ( + <li key={item.key}><AttentionRow item={item} size={size} folders={library.folders} /></li> + ))} + </ul> + {attention.length > MAX_ROWS && ( + <Link to={LIBRARY_FOLDERS_PATH} className={cn('mt-2 inline-block text-[13px] text-primary underline', PHONE_TARGET)}> + And {attention.length - MAX_ROWS} more on Library folders + </Link> + )} + </div> + )} + </div> + </section> + ); +} + +const ROW_COLUMNS: Record<CardSize, string> = { + wide: 'grid-cols-[16px_240px_minmax(0,1fr)_auto]', + medium: 'grid-cols-[16px_minmax(0,1fr)_auto]', + narrow: 'grid-cols-[16px_minmax(0,1fr)_16px]', +}; +const ROW_CHIP = 'inline-flex h-[22px] items-center rounded-ui border px-1.5 text-xs'; + +/** One Needs attention row: line 1 names the folder or library, line 2 its server chips or reach (Core 3.3, 3.5). */ +function AttentionRow({ item, size, folders }: { item: AttentionItem; size: CardSize; folders: LibraryFolder[] }) { + const target = item.kind === 'library' ? item.folders[0] : item.folder; + const narrow = size === 'narrow'; + const message = size === 'wide' && item.kind === 'folder' ? folderMessage(item, folders) : null; + return ( + <Link to={libraryFolderUrl(target)} + className={cn('grid min-h-[44px] items-center gap-3 rounded-ui border border-border px-3 py-2 hover:bg-muted/40', ROW_COLUMNS[size])}> + <Warning size={16} aria-hidden="true" className="text-warning" /> + <span className={cn('flex min-w-0 gap-1.5', narrow ? 'flex-col' : 'flex-wrap items-center')}> + {item.kind === 'library' ? ( + <> + <span className="flex flex-wrap items-center gap-1.5"> + <span className="font-medium">{item.serverName} library {item.libraryName}</span> + <span className={cn(ROW_CHIP, 'border-warning text-warning')}>{item.short}</span> + </span> + <span className="text-[13px] text-muted-foreground">affects {item.folders.length} {item.folders.length === 1 ? 'folder' : 'folders'}</span> + </> + ) : ( + <> + <span className="flex flex-wrap items-center gap-1.5"> + <span className="font-medium">{item.label}</span> + <LayoutChip layout={item.layout} /> + </span> + <span className="flex flex-wrap items-center gap-1.5"> + {item.servers.map((server) => ( + <span key={server.serverType} className={cn(ROW_CHIP, + server.display === 'noLibrary' ? 'border-destructive text-destructive' : 'border-warning text-warning')}> + {server.name}: {server.display === 'noLibrary' ? 'No library' : `${server.issueCount} ${server.issueCount === 1 ? 'issue' : 'issues'}`} + </span> + ))} + </span> + </> + )} + </span> + {size === 'wide' && <span title={message ?? undefined} className="truncate text-[13px] text-muted-foreground">{message}</span>} + <span className="inline-flex items-center gap-1 text-[13px] text-primary"> + {narrow ? null : 'Review'}<ChevronRight size={16} aria-hidden="true" /> + </span> + </Link> + ); +} + +/** The first issue of the first server with issues, else the first server's missing library. */ +function folderMessage(item: FolderItem, folders: LibraryFolder[]): string { + const issue = item.servers.find((server) => server.display === 'issues')?.firstMessage; + if (issue) return issue; + const folder = folders.find((entry) => entry.name === item.folder); + return folder ? noLibraryLine(item.servers[0], folder) : item.text; +} + +export default LibraryFoldersCard; diff --git a/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx b/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx deleted file mode 100644 index 438249243..000000000 --- a/client/src/components/Configuration/sections/components/LibraryFoldersList.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { useState } from 'react'; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - MenuItem, - Select, - Typography, -} from '../../../ui'; -import { LIBRARY_FOLDERS_UPDATED_EVENT, type UseLibraryFoldersResult } from '../../../../hooks/useLibraryFolders'; -import { libraryFolderLabel } from '../../../../utils/libraryLayouts'; -import { LibraryFolder, LibraryLayout } from '../../../../types/tvShows'; -import { MainFolderTvDialog } from './MainFolderTvDialog'; -import { ReorganizeDialog, useReorganizeRequest, isReorganizeRequired } from '../../../shared/Reorganize'; -import { LibraryCheckNotes } from '../../../shared/LibraryCheck/LibraryCheckNotes'; -import { useLibraryCheck } from '../../../../hooks/useLibraryCheck'; -import { folderKey } from '../../../../utils/libraryLayouts'; -import { LibraryCheckFolder, LibraryCheckServer } from '../../../../types/libraryCheck'; - -interface LibraryFoldersListProps { - /** The section's useLibraryFolders result, shared so the page loads the folders once */ - library: UseLibraryFoldersResult; - token: string | null; -} - -const MAIN_FOLDER = ''; -const CHANGE_FAILED = 'Failed to change the folder layout'; -const EXPLANATION = - "Each folder's layout must match its media server library: Videos for a Plex Other Videos or Jellyfin/Emby Movies library, TV shows for a TV library."; -const HAS_FILES_CAPTION = 'Holds downloaded videos: changing its layout moves them, and you review the move first.'; -const MEDIA_SERVER_CHECK_CAPTION = 'Under each folder: the media server libraries that hold it, and what to fix.'; -const DEFAULT_TV_NOTE = - "The default subfolder is a TV folder, so downloads from channels you haven't subscribed to are each saved as their own TV show."; - -const LAYOUT_OPTIONS: { value: LibraryLayout; label: string }[] = [ - { value: 'videos', label: 'Videos' }, - { value: 'tv', label: 'TV shows' }, -]; - -function channelCountText(count: number): string { - return count === 1 ? '1 channel' : `${count} channels`; -} - -interface LibraryFolderRowProps { - folder: LibraryFolder; - disabled: boolean; - onLayoutChange: (folder: LibraryFolder, layout: LibraryLayout) => void; - /** The library check's report for this folder, once loaded */ - check?: { report: LibraryCheckFolder; servers: LibraryCheckServer[] } | null; - onApplyPlexMapping?: (folder: string, libraryId: string) => Promise<void>; -} - -const LibraryFolderRow: React.FC<LibraryFolderRowProps> = ({ folder, disabled, onLayoutChange, check, onApplyPlexMapping }) => { - const label = libraryFolderLabel(folder.name); - - return ( - <li className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"> - <Box className="min-w-0"> - <Box className="flex flex-wrap items-center gap-2"> - <Typography variant="body2" className="font-medium break-all"> - {label} - </Typography> - {folder.isDefault && <Chip label="Default" size="small" variant="outlined" color="primary" />} - </Box> - <Typography variant="caption" color="text.secondary" className="block"> - {channelCountText(folder.channels)} - </Typography> - {folder.hasFiles && ( - <Typography variant="caption" color="text.secondary" className="block"> - {HAS_FILES_CAPTION} - </Typography> - )} - {check && ( - <Box className="mt-1"> - <LibraryCheckNotes - folder={check.report} - servers={check.servers} - onApplyPlexMapping={onApplyPlexMapping} - problemsOnly={folder.layout !== 'tv'} - /> - </Box> - )} - </Box> - <Select - size="small" - value={folder.layout} - disabled={disabled} - onValueChange={(next) => onLayoutChange(folder, next as LibraryLayout)} - inputProps={{ 'aria-label': `Layout for ${label}` }} - className="w-full sm:w-40 shrink-0" - > - {LAYOUT_OPTIONS.map((option) => ( - <MenuItem key={option.value} value={option.value}> - {option.label} - </MenuItem> - ))} - </Select> - </li> - ); -}; - -/** Library folders with their layouts (Videos or TV shows). */ -export const LibraryFoldersList: React.FC<LibraryFoldersListProps> = ({ library, token }) => { - const { folders, loading, error, refetch, setFolderLayout } = library; - const [changing, setChanging] = useState(false); - const [changeError, setChangeError] = useState<string | null>(null); - const [confirmMainTv, setConfirmMainTv] = useState(false); - // A layout change that moves downloaded files is reviewed in the reorganize dialog. - const reorganize = useReorganizeRequest(); - const libraryCheck = useLibraryCheck(token); - const checkServers = libraryCheck.data?.servers ?? []; - // Folders in use: TV folders, and Videos folders that hold files or channels. - const checkFor = (folder: LibraryFolder) => { - if (checkServers.length === 0 || (folder.layout !== 'tv' && !folder.hasFiles && folder.channels === 0)) return null; - const report = libraryCheck.data?.folders.find((entry) => folderKey(entry.name) === folderKey(folder.name)); - return report ? { report, servers: checkServers } : null; - }; - - const applyLayout = async (name: string, layout: LibraryLayout) => { - setChanging(true); - try { - await setFolderLayout(name, layout); - setChangeError(null); - } catch (err: unknown) { - if (isReorganizeRequired(err)) { - setChangeError(null); - reorganize.review(err.change); - return; - } - setChangeError(err instanceof Error && err.message ? err.message : CHANGE_FAILED); - } finally { - setChanging(false); - } - }; - - // Channels change layout with their folder, so every listener refetches. - const announceFolderChange = () => window.dispatchEvent(new Event(LIBRARY_FOLDERS_UPDATED_EVENT)); - - const handleReorganizeClosed = () => { - reorganize.close(); - announceFolderChange(); - }; - - const handleLayoutChange = (folder: LibraryFolder, layout: LibraryLayout) => { - if (layout === folder.layout) return; - if (folder.name === MAIN_FOLDER && layout === 'tv') { - setConfirmMainTv(true); - return; - } - void applyLayout(folder.name, layout); - }; - - const handleConfirmMainTv = async () => { - await applyLayout(MAIN_FOLDER, 'tv'); - setConfirmMainTv(false); - }; - - const defaultIsTv = folders.some((folder) => folder.isDefault && folder.layout === 'tv'); - - return ( - <Box className="flex flex-col gap-3"> - <Typography variant="subtitle2" className="font-bold"> - Library folders - </Typography> - <Typography variant="caption" color="text.secondary"> - {EXPLANATION} - </Typography> - - {changeError && ( - <Alert severity="error" onClose={() => setChangeError(null)}> - {changeError} - </Alert> - )} - - {error && ( - <Alert - severity="error" - action={ - <Button size="small" variant="text" onClick={() => void refetch()}> - Retry - </Button> - } - > - {error} - </Alert> - )} - - {checkServers.length > 0 && ( - <Box className="flex flex-wrap items-center gap-2"> - <Typography variant="caption" color="text.secondary"> - {MEDIA_SERVER_CHECK_CAPTION} - </Typography> - <Button size="small" variant="text" loading={libraryCheck.loading} onClick={() => void libraryCheck.refetch()}> - Check again - </Button> - </Box> - )} - {libraryCheck.error && <Alert severity="warning">{libraryCheck.error}</Alert>} - - {loading && folders.length === 0 ? ( - <Box className="flex items-center gap-2 text-sm text-muted-foreground"> - <CircularProgress size={16} /> - <span>Loading library folders...</span> - </Box> - ) : ( - folders.length > 0 && ( - <Box component="ul" className="m-0 list-none divide-y divide-border border-y border-border p-0"> - {folders.map((folder) => ( - <LibraryFolderRow - key={folder.name || 'main-folder'} - folder={folder} - disabled={changing} - onLayoutChange={handleLayoutChange} - check={checkFor(folder)} - onApplyPlexMapping={libraryCheck.applyPlexMapping} - /> - ))} - </Box> - ) - )} - - {defaultIsTv && <Alert severity="info">{DEFAULT_TV_NOTE}</Alert>} - - <MainFolderTvDialog - open={confirmMainTv} - busy={changing} - onCancel={() => setConfirmMainTv(false)} - onConfirm={handleConfirmMainTv} - /> - - <ReorganizeDialog - open={reorganize.open} - token={token} - change={reorganize.change} - operationId={reorganize.operationId} - onClose={handleReorganizeClosed} - onApplied={announceFolderChange} - /> - </Box> - ); -}; diff --git a/client/src/components/Configuration/sections/components/PlexLibraryLabel.tsx b/client/src/components/Configuration/sections/components/PlexLibraryLabel.tsx index b0bb80f06..f10522238 100644 --- a/client/src/components/Configuration/sections/components/PlexLibraryLabel.tsx +++ b/client/src/components/Configuration/sections/components/PlexLibraryLabel.tsx @@ -9,7 +9,7 @@ interface PlexLibraryLabelProps { display: PlexLibraryDisplay; /** * When true, the primary text (title or id) renders with fontWeight 600. - * Used by DefaultPlexLibraryDisplay; PlexSubfolderMappings uses the default. + * Used by DefaultPlexLibraryDisplay. */ boldPrimary?: boolean; primaryVariant?: TypographyVariant; @@ -19,10 +19,9 @@ interface PlexLibraryLabelProps { } /** - * Render a `PlexLibraryDisplay` discriminated union once. Both consumers - * (DefaultPlexLibraryDisplay in PlexIntegrationSection and the mapping table - * in PlexSubfolderMappings) use this. Adding a fourth display branch only - * requires editing this file, not every consumer. + * Render a `PlexLibraryDisplay` discriminated union once, for + * DefaultPlexLibraryDisplay. Adding a fourth display branch only requires + * editing this file, not every consumer. */ export const PlexLibraryLabel: React.FC<PlexLibraryLabelProps> = ({ display, diff --git a/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx b/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx index f4e8b89ad..3bb6c9d23 100644 --- a/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx +++ b/client/src/components/Configuration/sections/components/VideoFilenameTemplate.tsx @@ -1,14 +1,18 @@ import React, { ChangeEvent, useMemo } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { LIBRARY_FOLDERS_PATH } from '../../../../utils/libraryLayouts'; import { Box, TextField, Typography, Button, - Link, CircularProgress, Tooltip, } from '../../../ui'; -import { Info as InfoIcon } from '../../../../lib/icons'; +import { Info as InfoIcon, Tv } from '../../../../lib/icons'; +import { cn } from '../../../../lib/cn'; +import { useMediaQuery } from '../../../../hooks/useMediaQuery'; +import { settingDescriptionId } from '../../common/SettingRow'; import { FILENAME_PRESETS, PLEX_TV_SERIES_PRESET_PREFIX, @@ -28,10 +32,12 @@ interface VideoFilenameTemplateProps { token: string | null; saveRequirement?: string | null; onPreviewSuccess?: (prefix: string) => void; - /** Whether the embedded MP4 title is still prefixed with the channel name. */ - channelPrefixEnabled?: boolean; + /** Id of the template input; its SettingRow label and description point at it */ + inputId?: string; } +const PHONE_QUERY = '(max-width: 767px)'; + const SEVERITY_TEXT: Record<'warn' | 'danger', string> = { warn: "Long filename. With deep subfolders or non-ASCII channel names, the full path may approach Windows' 260-character limit, which would cause downloads to fail.", @@ -40,7 +46,7 @@ const SEVERITY_TEXT: Record<'warn' | 'danger', string> = { }; const TV_SHOW_HINT = - 'For TV-style channels, save the channel as a TV show instead: Channel Settings > TV Show. Episodes then get season folders and NFO files.'; + 'For TV-style channels, use a TV shows folder instead. Episodes then get Season folders, SxxEyy names and .nfo files.'; export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ value, @@ -48,10 +54,10 @@ export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ token, saveRequirement, onPreviewSuccess, - channelPrefixEnabled = false, + inputId = 'videoFilenamePrefix', }) => { + const phone = useMediaQuery(PHONE_QUERY); const validation = useMemo(() => validatePrefix(value), [value]); - const showTvSeriesPrefixTip = channelPrefixEnabled && value === PLEX_TV_SERIES_PRESET_PREFIX; const preview = useFilenamePreview(token); const isStale = preview.isStale(value); @@ -82,37 +88,16 @@ export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ return ( <Box className="flex flex-col gap-3"> - <Typography variant="subtitle2" className="font-bold"> - Video Filename Template - </Typography> - <Typography variant="caption" color="text.secondary"> - How yt-dlp names downloaded video files and per-video folders. Youtarr always appends{' '} - <span className="font-mono px-1 py-0.5 rounded text-xs bg-muted"> - [VIDEO_ID].EXT - </span>{' '} - to filenames and{' '} - <span className="font-mono px-1 py-0.5 rounded text-xs bg-muted"> - - VIDEO_ID - </span>{' '} - to folder names so it can re-find your videos on disk. Only applies to new downloads.{' '} - <Link - href="https://github.com/yt-dlp/yt-dlp#output-template" - target="_blank" - rel="noopener noreferrer" - > - See yt-dlp output template docs - </Link> - . - </Typography> - <TextField + id={inputId} fullWidth - label="Video Filename Template" + className="[&_input]:font-mono" value={value} onChange={handleChange as React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>} error={!validation.ok} helperText={validation.error} - inputProps={{ style: { fontFamily: 'monospace' } }} + aria-describedby={settingDescriptionId(inputId)} + inputProps={{ 'aria-label': 'Video filename template' }} /> <Box className="flex flex-wrap gap-2"> @@ -122,6 +107,8 @@ export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ key={preset.label} variant="outlined" size="small" + aria-pressed={value === preset.prefix} + className={cn(value === preset.prefix && 'border-primary bg-primary/10', phone ? 'min-h-[44px]' : 'h-[30px]')} onClick={() => onChange(preset.prefix)} title={preset.description} > @@ -136,7 +123,7 @@ export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ <button type="button" aria-label="About saving channels as TV shows" - className="inline-flex items-center justify-center rounded-full p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="inline-flex items-center justify-center rounded-full p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-md:min-h-[44px] max-md:min-w-[44px]" > <InfoIcon size={16} aria-hidden /> </button> @@ -146,24 +133,25 @@ export const VideoFilenameTemplate: React.FC<VideoFilenameTemplateProps> = ({ })} </Box> - {showTvSeriesPrefixTip && ( - <Box - data-testid="tv-series-channel-prefix-tip" - className="rounded p-2 bg-muted" - > - <Typography variant="caption" color="text.secondary"> - Tip: in a Plex TV Shows library the channel is already the show name, so turn off - "Prefix channel name in embedded video title" under Download Settings to keep - episode titles clean. - </Typography> - </Box> + {value === PLEX_TV_SERIES_PRESET_PREFIX && ( + <p data-testid="tv-series-tip" className="flex items-start gap-2 rounded-ui bg-muted p-2 text-[13px] text-muted-foreground"> + <Tv size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-info" /> + <span> + Want channels as real TV shows? Download them to a TV shows folder instead: episodes get Season folders, SxxEyy names and + .nfo files, and this template doesn't apply there. This preset doesn't convert anything: switching a folder to TV + shows is a separate step you review first.{' '} + <RouterLink to={LIBRARY_FOLDERS_PATH} className="text-primary underline max-md:inline-flex max-md:min-h-[44px] max-md:items-center">Library folders</RouterLink> + </span> + </p> )} - <Box className="flex items-center gap-3"> + <Box className={cn('flex gap-3', phone ? 'flex-col items-stretch' : 'items-center')}> <Button data-testid="filename-preview-button" - variant="contained" + variant="outlined" size="small" + fullWidth={phone} + className={cn(phone && 'min-h-[44px]')} onClick={handlePreviewClick} disabled={previewDisabled} startIcon={preview.loading ? <CircularProgress size={14} /> : undefined} diff --git a/client/src/components/Configuration/sections/components/__tests__/FlatStructureDialog.test.tsx b/client/src/components/Configuration/sections/components/__tests__/FlatStructureDialog.test.tsx new file mode 100644 index 000000000..612307ed3 --- /dev/null +++ b/client/src/components/Configuration/sections/components/__tests__/FlatStructureDialog.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FlatStructureDialog } from '../FlatStructureDialog'; + +jest.mock('axios', () => ({ get: jest.fn() })); +const axios = require('axios'); + +describe('FlatStructureDialog', () => { + test('shows the count and the channel list, then confirms', async () => { + axios.get.mockResolvedValue({ data: { count: 1, channelNames: ['Blippi'] } }); + const onConfirm = jest.fn(); + render(<FlatStructureDialog open turningOn token="t" onConfirm={onConfirm} onCancel={jest.fn()} />); + expect(await screen.findByText('1 tracked channel follows the global setting and will be affected.')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Show affected channels' })); + expect(screen.getByText('Blippi')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })); + expect(onConfirm).toHaveBeenCalled(); + }); + + test('lists every affected channel when two share a name', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + axios.get.mockResolvedValue({ data: { count: 2, channelNames: ['Same', 'Same'] } }); + render(<FlatStructureDialog open turningOn token="t" onConfirm={jest.fn()} onCancel={jest.fn()} />); + await userEvent.click(await screen.findByRole('button', { name: 'Show affected channels' })); + expect(screen.getAllByText('Same')).toHaveLength(2); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); + + test('still allows confirming when the count fails', async () => { + axios.get.mockRejectedValue(new Error('down')); + render(<FlatStructureDialog open turningOn={false} token="t" onConfirm={jest.fn()} onCancel={jest.fn()} />); + expect(await screen.findByText(/Could not determine how many channels are affected/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled(); + }); +}); diff --git a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersCard.test.tsx b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersCard.test.tsx new file mode 100644 index 000000000..b00b0b8dc --- /dev/null +++ b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersCard.test.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../../../../../test-utils'; +import { LibraryFoldersCard } from '../LibraryFoldersCard'; +import { DEFAULT_CONFIG } from '../../../../../config/configSchema'; +import { useLibraryFolders } from '../../../../../hooks/useLibraryFolders'; +import { useLibraryCheck } from '../../../../../hooks/useLibraryCheck'; +import { useMediaServerStatus } from '../../../../../hooks/useMediaServerStatus'; +import { useContainerWidth } from '../../../../../hooks/useContainerWidth'; +import type { LibraryFolder } from '../../../../../types/tvShows'; +import type { LibraryCheckFolder, LibraryCheckResponse } from '../../../../../types/libraryCheck'; +import type { MediaServerStatus } from '../../../../../types/playlist'; +import type { DeploymentEnvironment, PlatformManagedState } from '../../../types'; + +jest.mock('../../../../../hooks/useLibraryFolders', () => ({ useLibraryFolders: jest.fn() })); +jest.mock('../../../../../hooks/useLibraryCheck', () => ({ useLibraryCheck: jest.fn() })); +jest.mock('../../../../../hooks/useMediaServerStatus', () => ({ useMediaServerStatus: jest.fn() })); +jest.mock('../../../../../hooks/useContainerWidth', () => ({ useContainerWidth: jest.fn() })); + +const f = (name: string, extra: Partial<LibraryFolder> = {}): LibraryFolder => ({ name, layout: 'videos', isDefault: false, hasFiles: false, channels: 0, ...extra }); +const missing = (name: string): LibraryCheckFolder => ({ name, layout: 'videos', hasFiles: true, channels: 1, servers: [{ serverType: 'plex', status: 'missing', libraries: [], issues: [] }] }); +const config = { ...DEFAULT_CONFIG, youtubeOutputDirectory: '/data/yt' }; +const PLATFORM_MANAGED: PlatformManagedState = { plexUrl: false, authEnabled: false, useTmpForDownloads: false, ytdlpUpdates: false }; +const PLEX_ONLY: MediaServerStatus = { plex: true, jellyfin: false, emby: false }; +const NO_SERVERS: MediaServerStatus = { plex: false, jellyfin: false, emby: false }; +const PLEX_CHECK: LibraryCheckResponse = { servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null }], folders: [] }; + +interface SetupOptions { + folders?: LibraryFolder[]; + foldersLoading?: boolean; + foldersLoaded?: boolean; + check?: LibraryCheckResponse | null; + checkLoading?: boolean; + servers?: MediaServerStatus; + serversLoading?: boolean; + width?: number; + isPlatformManaged?: PlatformManagedState; + deploymentEnvironment?: DeploymentEnvironment; +} + +function setup({ + folders = [f(''), f('Kids', { isDefault: true, channels: 2 })], foldersLoading = false, foldersLoaded = true, + check = PLEX_CHECK, checkLoading = false, servers = PLEX_ONLY, serversLoading = false, width = 1000, + isPlatformManaged = PLATFORM_MANAGED, deploymentEnvironment = { isWsl: false }, +}: SetupOptions = {}) { + (useLibraryFolders as jest.Mock).mockReturnValue({ folders, loading: foldersLoading, loaded: foldersLoaded, error: null, refetch: jest.fn() }); + (useLibraryCheck as jest.Mock).mockReturnValue({ + data: check, loading: checkLoading, error: null, lastCheckedAt: check ? Date.now() : null, refetch: jest.fn(), + }); + (useMediaServerStatus as jest.Mock).mockReturnValue({ status: servers, loading: serversLoading }); + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), width]); + renderWithProviders( + <LibraryFoldersCard token="token" config={config} isPlatformManaged={isPlatformManaged} deploymentEnvironment={deploymentEnvironment} /> + ); +} + +const ONE_MISSING = { folders: [f(''), f('A', { channels: 1 })], check: { ...PLEX_CHECK, folders: [missing('A')] } }; + +describe('LibraryFoldersCard', () => { + test('facts: downloads folder, default folder with a Change link, counts', () => { + setup(); + expect(screen.getByRole('link', { name: 'Manage library folders' })).toHaveAttribute('href', '/settings/library'); + expect(screen.getByText('/data/yt')).toBeInTheDocument(); + expect(screen.getByText('__Kids')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Change' })).toHaveAttribute('href', '/settings/library/Kids'); + expect(screen.getByText('2 Videos folders')).toBeInTheDocument(); + expect(screen.getByText('0 TV folders')).toBeInTheDocument(); + }); + + test('main folder only', () => { + setup({ folders: [f('', { isDefault: true })] }); + expect(screen.getByText('Main folder only')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Change' })).not.toBeInTheDocument(); + }); + + test('a TV default folder says unsubscribed channels become shows', () => { + setup({ folders: [f(''), f('Shows', { isDefault: true, layout: 'tv', channels: 1 })] }); + expect(screen.getByText(/Each channel you don't subscribe to becomes its own show\./)).toBeInTheDocument(); + }); + + test('a downloads folder set by DATA_PATH says so', () => { + setup({ isPlatformManaged: { ...PLATFORM_MANAGED, youtubeOutputDirectory: true } }); + expect(screen.getByText('Set by the DATA_PATH environment variable.')).toBeInTheDocument(); + }); + + test('an Elfhosted downloads folder says the platform sets it', () => { + setup({ deploymentEnvironment: { isWsl: false, platform: 'Elfhosted' } }); + expect(screen.getByText('This path is configured by your platform deployment and cannot be changed here.')).toBeInTheDocument(); + }); + + test('an attention row is named by what it shows', () => { + setup(ONE_MISSING); + expect(screen.getByRole('link', { name: /__A.*Plex: No library.*Review/ })).toHaveAttribute('href', '/settings/library/A'); + }); + + test('a wide card shows the row message', () => { + setup(ONE_MISSING); + expect(screen.getByText('No Plex Other Videos library holds __A.')).toBeInTheDocument(); + }); + + test('a medium card drops the row message', () => { + setup({ ...ONE_MISSING, width: 700 }); + expect(screen.queryByText('No Plex Other Videos library holds __A.')).not.toBeInTheDocument(); + }); + + test('a partial check with issues counts them and says which server could not be reached', () => { + setup({ + folders: [f(''), f('A', { channels: 1 })], + servers: { plex: true, jellyfin: true, emby: false }, + check: { + servers: [ + { serverType: 'plex', name: 'Plex', reachable: true, error: null }, + { serverType: 'jellyfin', name: 'Jellyfin', reachable: false, error: 'timeout' }, + ], + folders: [{ + name: 'A', layout: 'videos', hasFiles: true, channels: 1, servers: [ + { serverType: 'plex', status: 'missing', libraries: [], issues: [] }, + { serverType: 'jellyfin', status: 'unreachable', libraries: [], issues: [] }, + ], + }], + }, + }); + expect(screen.getByText('1 needs attention')).toBeInTheDocument(); + expect(screen.getByText(/Jellyfin couldn't be reached\./)).toBeInTheDocument(); + expect(screen.queryByText('All folders OK')).not.toBeInTheDocument(); + }); + + test('lists at most three attention rows, then a link to the rest', () => { + const names = ['A', 'B', 'C', 'D']; + setup({ + folders: [f(''), ...names.map((name) => f(name, { channels: 1 }))], + check: { servers: [{ serverType: 'plex', name: 'Plex', reachable: true, error: null }], folders: names.map(missing) }, + }); + expect(screen.getByText('4 need attention')).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: /Review/ })).toHaveLength(3); + expect(screen.getByRole('link', { name: 'And 1 more on Library folders' })).toHaveAttribute('href', '/settings/library'); + }); + + test('a library-wide issue is one row linking to the first folder it affects', () => { + const nfoSaver = (name: string): LibraryCheckFolder => ({ + name, layout: 'videos', hasFiles: true, channels: 1, servers: [{ + serverType: 'jellyfin', status: 'warning', + libraries: [{ id: '7', name: 'YouTube', type: 'videos', location: '/media/yt', relation: 'covers' }], + issues: [{ code: 'nfoSaver', message: 'YouTube saves NFO files.', libraryId: '7' }], + }], + }); + setup({ + folders: [f(''), f('A', { channels: 1 }), f('B', { channels: 1 })], + check: { servers: [{ serverType: 'jellyfin', name: 'Jellyfin', reachable: true, error: null }], folders: [nfoSaver('A'), nfoSaver('B')] }, + servers: { plex: false, jellyfin: true, emby: false }, + }); + const rows = screen.getAllByRole('link', { name: /Review/ }); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveAttribute('href', '/settings/library/A'); + expect(rows[0]).toHaveTextContent('affects 2 folders'); + }); + + test('loading folders marks the card busy and shows no counts', () => { + setup({ folders: [], foldersLoaded: false }); + expect(screen.getByRole('region', { name: 'Library folders' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByText(/Videos folders?$/)).not.toBeInTheDocument(); + expect(screen.queryByText('Main folder')).not.toBeInTheDocument(); + }); + + test('a running first check shows the spinner text', () => { + setup({ check: null, checkLoading: true }); + expect(screen.getByText('Checking media servers...')).toBeInTheDocument(); + }); + + test('media server status still loading reads as checking, not as no media server', () => { + setup({ check: null, servers: NO_SERVERS, serversLoading: true }); + expect(screen.getByText('Checking media servers...')).toBeInTheDocument(); + expect(screen.queryByText('Not checked')).not.toBeInTheDocument(); + }); + + test('no media servers links each server settings page', () => { + setup({ check: { servers: [], folders: [] }, servers: NO_SERVERS }); + expect(screen.getByText('Not checked')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Plex' })).toHaveAttribute('href', '/settings/plex'); + expect(screen.getByRole('link', { name: 'Jellyfin' })).toHaveAttribute('href', '/settings/jellyfin'); + expect(screen.getByRole('link', { name: 'Emby' })).toHaveAttribute('href', '/settings/emby'); + }); + + test('a failed folder list shows Try again', () => { + (useLibraryFolders as jest.Mock).mockReturnValue({ folders: [], loading: false, loaded: true, error: 'boom', refetch: jest.fn() }); + (useLibraryCheck as jest.Mock).mockReturnValue({ data: null, loading: false, error: null, lastCheckedAt: null, refetch: jest.fn() }); + (useMediaServerStatus as jest.Mock).mockReturnValue({ status: { plex: false, jellyfin: false, emby: false } }); + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 1000]); + renderWithProviders( + <LibraryFoldersCard token="token" config={config} isPlatformManaged={PLATFORM_MANAGED} deploymentEnvironment={{ isWsl: false }} /> + ); + expect(screen.getByText("Couldn't load library folders: boom.")).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + + test('a narrow card keeps the partial-check qualification', () => { + setup({ width: 400, check: { servers: [ + { serverType: 'plex', name: 'Plex', reachable: true, error: null }, + { serverType: 'jellyfin', name: 'Jellyfin', reachable: false, error: 'x' }, + ], folders: [] } }); + expect(screen.getByText(/Jellyfin couldn't be reached\./)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx b/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx deleted file mode 100644 index 8e301bb3e..000000000 --- a/client/src/components/Configuration/sections/components/__tests__/LibraryFoldersList.test.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import React from 'react'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; -import { LibraryFoldersList } from '../LibraryFoldersList'; -import type { LibraryFolder } from '../../../../../types/tvShows'; -import type { UseLibraryFoldersResult } from '../../../../../hooks/useLibraryFolders'; - -jest.mock('../../../../shared/Reorganize', () => { - const actual = jest.requireActual('../../../../shared/Reorganize'); - return { - ...actual, - ReorganizeDialog: function MockReorganizeDialog(props: { open: boolean; change: unknown }) { - const React = require('react'); - return props.open - ? React.createElement('div', { 'data-testid': 'reorganize-dialog' }, JSON.stringify(props.change)) - : null; - }, - }; -}); - -const mockSetFolderLayout = jest.fn(); -const mockRefetch = jest.fn(); -const mockCheckRefetch = jest.fn(); -const mockApplyPlexMapping = jest.fn(); -let mockCheckData: unknown = null; - -jest.mock('../../../../../hooks/useLibraryCheck', () => ({ - useLibraryCheck: () => ({ - data: mockCheckData, - loading: false, - error: null, - refetch: mockCheckRefetch, - applyPlexMapping: mockApplyPlexMapping, - }), -})); - -const checkReport = (name: string, layout: 'videos' | 'tv', status: string, issues: { code: string; message: string; libraryId?: string }[] = []) => ({ - name, - layout, - hasFiles: true, - channels: 1, - servers: [{ serverType: 'plex', status, libraries: [{ id: '41', name: 'YouTube TV', type: layout, location: 'Q:\\Y', relation: 'exact' }], issues }], -}); - -const FOLDERS: LibraryFolder[] = [ - { name: '', layout: 'videos', isDefault: true, hasFiles: false, channels: 3 }, - { name: 'Shows', layout: 'videos', isDefault: false, hasFiles: false, channels: 1 }, - { name: 'Kids', layout: 'tv', isDefault: false, hasFiles: true, channels: 0 }, -]; - -type User = ReturnType<typeof userEvent.setup>; - -// The library folders state the section passes down (its useLibraryFolders result). -let library: UseLibraryFoldersResult; - -function mockHook(overrides: Partial<UseLibraryFoldersResult> = {}) { - library = { - folders: FOLDERS, - loading: false, - loaded: true, - error: null, - layoutOf: () => 'videos', - refetch: mockRefetch, - setFolderLayout: mockSetFolderLayout, - ...overrides, - }; -} - -function rowFor(label: string): HTMLElement { - const row = screen.getAllByRole('listitem').find((item) => within(item).queryByText(label)); - if (!row) throw new Error(`No row for ${label}`); - return row; -} - -async function chooseLayout(user: User, folderLabel: string, optionLabel: string) { - await user.click(screen.getByRole('button', { name: `Layout for ${folderLabel}` })); - await user.click(await screen.findByRole('option', { name: optionLabel })); -} - -describe('LibraryFoldersList', () => { - beforeEach(() => { - mockHook(); - mockCheckData = null; - }); - - test('renders a row for each folder with its label', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.getAllByRole('listitem')).toHaveLength(3); - expect(screen.getByText('Main folder')).toBeInTheDocument(); - expect(screen.getByText('__Shows')).toBeInTheDocument(); - expect(screen.getByText('__Kids')).toBeInTheDocument(); - }); - - test('marks the default folder', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(within(rowFor('Main folder')).getByText('Default')).toBeInTheDocument(); - }); - - test('does not mark folders that are not the default', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(within(rowFor('__Shows')).queryByText('Default')).not.toBeInTheDocument(); - }); - - test('shows the plural channel count', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(within(rowFor('Main folder')).getByText('3 channels')).toBeInTheDocument(); - }); - - test('shows the singular channel count', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(within(rowFor('__Shows')).getByText('1 channel')).toBeInTheDocument(); - }); - - test('shows each folder layout as the selected value', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.getByRole('button', { name: 'Layout for __Kids' })).toHaveTextContent('TV shows'); - }); - - test('changes a subfolder layout through the hook', async () => { - const user = userEvent.setup(); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, '__Shows', 'TV shows'); - - expect(mockSetFolderLayout).toHaveBeenCalledWith('Shows', 'tv'); - }); - - test('lets a folder that holds downloaded videos change layout', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.getByRole('button', { name: 'Layout for __Kids' })).toBeEnabled(); - }); - - test('says a folder\'s downloaded videos move with a layout change', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect( - within(rowFor('__Kids')).getByText('Holds downloaded videos: changing its layout moves them, and you review the move first.') - ).toBeInTheDocument(); - }); - - test('opens the move review when the folder\'s files must move', async () => { - const { ReorganizeRequiredError } = jest.requireActual('../../../../shared/Reorganize'); - mockSetFolderLayout.mockRejectedValueOnce( - new ReorganizeRequiredError('Review the move', { type: 'folderLayout', folder: 'Shows', layout: 'tv' }) - ); - const user = userEvent.setup(); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, '__Shows', 'TV shows'); - - expect(await screen.findByTestId('reorganize-dialog')).toHaveTextContent('"folder":"Shows"'); - expect(screen.queryByText('Review the move')).not.toBeInTheDocument(); - }); - - test('switching the main folder to TV asks for confirmation first', async () => { - const user = userEvent.setup(); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, 'Main folder', 'TV shows'); - - expect( - await screen.findByRole('dialog', { name: 'Use the main folder for TV shows?' }) - ).toBeInTheDocument(); - expect(mockSetFolderLayout).not.toHaveBeenCalled(); - }); - - test('confirming the main folder dialog changes the layout', async () => { - const user = userEvent.setup(); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, 'Main folder', 'TV shows'); - await user.click(await screen.findByRole('button', { name: 'Use for TV shows' })); - - expect(mockSetFolderLayout).toHaveBeenCalledWith('', 'tv'); - }); - - test('closes the main folder dialog once the change is done', async () => { - const user = userEvent.setup(); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, 'Main folder', 'TV shows'); - await user.click(await screen.findByRole('button', { name: 'Use for TV shows' })); - - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - - test('cancelling the main folder dialog leaves the layout alone', async () => { - const user = userEvent.setup(); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, 'Main folder', 'TV shows'); - await user.click(await screen.findByRole('button', { name: 'Cancel' })); - - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - expect(mockSetFolderLayout).not.toHaveBeenCalled(); - }); - - test('switching a TV main folder back to Videos needs no confirmation', async () => { - const user = userEvent.setup(); - mockHook({ - folders: [{ name: '', layout: 'tv', isDefault: true, hasFiles: false, channels: 0 }], - }); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, 'Main folder', 'Videos'); - - expect(mockSetFolderLayout).toHaveBeenCalledWith('', 'videos'); - }); - - test('shows the refusal message from a failed change', async () => { - const user = userEvent.setup(); - mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, '__Shows', 'TV shows'); - - expect(await screen.findByRole('alert')).toHaveTextContent('A download is running'); - }); - - test('dismisses the refusal message', async () => { - const user = userEvent.setup(); - mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, '__Shows', 'TV shows'); - const alert = await screen.findByRole('alert'); - await user.click(within(alert).getByRole('button', { name: 'Close' })); - - expect(screen.queryByText('A download is running')).not.toBeInTheDocument(); - }); - - test('clears the refusal message after the next successful change', async () => { - const user = userEvent.setup(); - mockSetFolderLayout.mockRejectedValueOnce(new Error('A download is running')); - render(<LibraryFoldersList library={library} token="token" />); - - await chooseLayout(user, '__Shows', 'TV shows'); - await screen.findByText('A download is running'); - await chooseLayout(user, '__Shows', 'TV shows'); - - await waitFor(() => { - expect(screen.queryByText('A download is running')).not.toBeInTheDocument(); - }); - }); - - test('notes when the default folder is a TV folder', () => { - mockHook({ - folders: [ - { name: '', layout: 'videos', isDefault: false, hasFiles: false, channels: 0 }, - { name: 'Shows', layout: 'tv', isDefault: true, hasFiles: false, channels: 2 }, - ], - }); - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.getByText(/The default subfolder is a TV folder/)).toBeInTheDocument(); - }); - - test('has no TV note when the default folder uses the Videos layout', () => { - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.queryByText(/The default subfolder is a TV folder/)).not.toBeInTheDocument(); - }); - - test('shows a loading state before the folders arrive', () => { - mockHook({ folders: [], loading: true }); - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.getByText('Loading library folders...')).toBeInTheDocument(); - }); - - test('keeps the rows visible while refetching', () => { - mockHook({ loading: true }); - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.getAllByRole('listitem')).toHaveLength(3); - }); - - test('shows the load error', () => { - mockHook({ folders: [], error: 'Failed to load library folders' }); - render(<LibraryFoldersList library={library} token="token" />); - expect(screen.getByRole('alert')).toHaveTextContent('Failed to load library folders'); - }); - - test('retries loading from the error alert', async () => { - const user = userEvent.setup(); - mockHook({ folders: [], error: 'Failed to load library folders' }); - render(<LibraryFoldersList library={library} token="token" />); - - await user.click(screen.getByRole('button', { name: 'Retry' })); - - expect(mockRefetch).toHaveBeenCalled(); - }); - - describe('media server check', () => { - const servers = [{ serverType: 'plex', name: 'Plex', reachable: true, error: null }]; - - test("shows each TV folder's libraries", () => { - mockCheckData = { servers, folders: [checkReport('Kids', 'tv', 'ok')] }; - render(<LibraryFoldersList library={library} token="token" />); - - expect(within(rowFor('__Kids')).getByText(/YouTube TV/)).toBeInTheDocument(); - }); - - test('shows only problems for a Videos folder', () => { - mockCheckData = { - servers, - folders: [checkReport('', 'videos', 'ok'), checkReport('Shows', 'videos', 'warning', [{ code: 'wrongType', message: 'YouTube TV is a TV Shows library.' }])], - }; - render(<LibraryFoldersList library={library} token="token" />); - - expect(within(rowFor('Main folder')).queryByText(/YouTube TV/)).not.toBeInTheDocument(); - expect(within(rowFor('__Shows')).getByText('YouTube TV is a TV Shows library.')).toBeInTheDocument(); - }); - - test('maps a TV folder for Plex refreshes from its issue', async () => { - const user = userEvent.setup(); - mockApplyPlexMapping.mockResolvedValue(undefined); - mockCheckData = { - servers, - folders: [checkReport('Kids', 'tv', 'warning', [{ code: 'plexMappingMissing', message: "New episodes don't refresh YouTube TV.", libraryId: '41' }])], - }; - render(<LibraryFoldersList library={library} token="token" />); - - await user.click(screen.getByRole('button', { name: 'Refresh this library' })); - - expect(mockApplyPlexMapping).toHaveBeenCalledWith('Kids', '41'); - }); - - test('checks again on request', async () => { - const user = userEvent.setup(); - mockCheckData = { servers, folders: [] }; - render(<LibraryFoldersList library={library} token="token" />); - - await user.click(screen.getByRole('button', { name: 'Check again' })); - - expect(mockCheckRefetch).toHaveBeenCalled(); - }); - - test('shows nothing without configured media servers', () => { - mockCheckData = { servers: [], folders: [checkReport('Kids', 'tv', 'ok')] }; - render(<LibraryFoldersList library={library} token="token" />); - - expect(screen.queryByRole('button', { name: 'Check again' })).not.toBeInTheDocument(); - }); - }); -}); diff --git a/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx b/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx index 1b5d4c58f..20457f8a3 100644 --- a/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx +++ b/client/src/components/Configuration/sections/components/__tests__/VideoFilenameTemplate.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; jest.mock('axios', () => ({ post: jest.fn(), @@ -10,10 +11,22 @@ jest.mock('axios', () => ({ const axios = require('axios'); import { VideoFilenameTemplate } from '../VideoFilenameTemplate'; -import { FILENAME_PRESETS } from '../../../../../utils/filenameTemplate/presets'; +import { FILENAME_PRESETS, PLEX_TV_SERIES_PRESET_PREFIX } from '../../../../../utils/filenameTemplate/presets'; const defaultPrefix = '%(uploader,channel,uploader_id).80B - %(title).76B'; +type TemplateProps = React.ComponentProps<typeof VideoFilenameTemplate>; + +function renderTemplate(props: Partial<TemplateProps> = {}) { + const element = (next: Partial<TemplateProps>) => ( + <MemoryRouter> + <VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" {...next} /> + </MemoryRouter> + ); + const view = render(element(props)); + return { ...view, rerenderTemplate: (next: Partial<TemplateProps>) => view.rerender(element({ ...props, ...next })) }; +} + const SAMPLE_RESPONSE = { fileLine: 'TEDx Talks - How to Get Your Brain... [Hu4Yvq-g7_Y].mp4', folderLine: 'TEDx Talks - How to Get Your Brain... - Hu4Yvq-g7_Y', @@ -25,14 +38,14 @@ describe('VideoFilenameTemplate', () => { beforeEach(() => jest.clearAllMocks()); it('renders the input with the current value', () => { - render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + renderTemplate(); const input = screen.getByLabelText(/video filename template/i) as HTMLInputElement; expect(input.value).toBe(defaultPrefix); }); it('calls onChange when the user types', () => { const handleChange = jest.fn(); - render(<VideoFilenameTemplate value={defaultPrefix} onChange={handleChange} token="tok" />); + renderTemplate({ onChange: handleChange }); const input = screen.getByLabelText(/video filename template/i); fireEvent.change(input, { target: { value: '%(title)s' } }); expect(handleChange).toHaveBeenCalledWith('%(title)s'); @@ -40,7 +53,7 @@ describe('VideoFilenameTemplate', () => { it('renders all five presets and applies one when clicked', () => { const handleChange = jest.fn(); - render(<VideoFilenameTemplate value="x" onChange={handleChange} token="tok" />); + renderTemplate({ value: 'x', onChange: handleChange }); expect(screen.getByRole('button', { name: /default/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /date prefix/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /plex youtube-agent/i })).toBeInTheDocument(); @@ -52,21 +65,21 @@ describe('VideoFilenameTemplate', () => { }); describe('TV show hint next to the Plex TV Series preset', () => { - it('points TV-style channels at the TV Show channel setting on hover', async () => { + it('points TV-style channels at TV shows folders on hover', async () => { const user = userEvent.setup(); - render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + renderTemplate(); await user.hover(screen.getByRole('button', { name: 'About saving channels as TV shows' })); expect(await screen.findByRole('tooltip')).toHaveTextContent( - 'For TV-style channels, save the channel as a TV show instead: Channel Settings > TV Show. Episodes then get season folders and NFO files.' + 'For TV-style channels, use a TV shows folder instead. Episodes then get Season folders, SxxEyy names and .nfo files.' ); }); it('still applies the Plex TV Series preset when clicked', () => { const handleChange = jest.fn(); const tvSeriesPrefix = FILENAME_PRESETS.find((preset) => preset.label === 'Plex TV Series')!.prefix; - render(<VideoFilenameTemplate value="x" onChange={handleChange} token="tok" />); + renderTemplate({ value: 'x', onChange: handleChange }); fireEvent.click(screen.getByRole('button', { name: /plex tv series/i })); @@ -74,40 +87,26 @@ describe('VideoFilenameTemplate', () => { }); }); - describe('Plex TV Series channel prefix tip', () => { - const tvSeriesPrefix = FILENAME_PRESETS.find((preset) => preset.label === 'Plex TV Series')!.prefix; - - it('shows the tip when the TV Series preset is active and the channel prefix is enabled', () => { - render( - <VideoFilenameTemplate value={tvSeriesPrefix} onChange={() => {}} token="tok" channelPrefixEnabled /> - ); - expect(screen.getByTestId('tv-series-channel-prefix-tip')).toBeInTheDocument(); - }); - - it('hides the tip when the channel prefix is already disabled', () => { - render( - <VideoFilenameTemplate value={tvSeriesPrefix} onChange={() => {}} token="tok" channelPrefixEnabled={false} /> - ); - expect(screen.queryByTestId('tv-series-channel-prefix-tip')).not.toBeInTheDocument(); - }); + test('presets are toggle buttons pressed for the current value', () => { + renderTemplate({ value: PLEX_TV_SERIES_PRESET_PREFIX }); + expect(screen.getByRole('button', { name: 'Plex TV Series' })).toHaveAttribute('aria-pressed', 'true'); + }); - it('hides the tip for templates other than the TV Series preset', () => { - render( - <VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" channelPrefixEnabled /> - ); - expect(screen.queryByTestId('tv-series-channel-prefix-tip')).not.toBeInTheDocument(); - }); + test('the Plex TV Series preset points at TV shows folders', () => { + renderTemplate({ value: PLEX_TV_SERIES_PRESET_PREFIX }); + expect(screen.getByText(/Want channels as real TV shows\?/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Library folders' })).toHaveAttribute('href', '/settings/library'); }); it('does not show preview lines until the user clicks Preview', () => { - render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + renderTemplate(); expect(screen.queryByTestId('filename-preview-file')).not.toBeInTheDocument(); expect(screen.queryByTestId('filename-preview-folder')).not.toBeInTheDocument(); }); it('renders both file and folder lines after Preview is clicked and resolves', async () => { axios.post.mockResolvedValueOnce({ data: SAMPLE_RESPONSE }); - render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + renderTemplate(); fireEvent.click(screen.getByTestId('filename-preview-button')); @@ -118,7 +117,7 @@ describe('VideoFilenameTemplate', () => { }); it('Preview button is disabled when prefix fails client-side validation', () => { - render(<VideoFilenameTemplate value="" onChange={() => {}} token="tok" />); + renderTemplate({ value: '' }); expect(screen.getByTestId('filename-preview-button')).toBeDisabled(); }); @@ -127,7 +126,7 @@ describe('VideoFilenameTemplate', () => { axios.post.mockImplementationOnce( () => new Promise((resolve) => { resolveAxios = resolve; }) ); - render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + renderTemplate(); fireEvent.click(screen.getByTestId('filename-preview-button')); expect(screen.getByTestId('filename-preview-button')).toBeDisabled(); @@ -148,7 +147,7 @@ describe('VideoFilenameTemplate', () => { }, }, }); - render(<VideoFilenameTemplate value="%(title)Z" onChange={() => {}} token="tok" />); + renderTemplate({ value: '%(title)Z' }); fireEvent.click(screen.getByTestId('filename-preview-button')); @@ -158,32 +157,32 @@ describe('VideoFilenameTemplate', () => { }); it('shows the structural warning when %(title)s lacks .NB truncation', () => { - render(<VideoFilenameTemplate value="%(title)s" onChange={() => {}} token="tok" />); + renderTemplate({ value: '%(title)s' }); expect(screen.getByText(/untruncated/i)).toBeInTheDocument(); }); it('shows the oversized-title warning when title byte truncation exceeds the recommended limit', () => { - render(<VideoFilenameTemplate value="%(title).150B" onChange={() => {}} token="tok" />); + renderTemplate({ value: '%(title).150B' }); expect(screen.getByTestId('oversized-title-warning')).toHaveTextContent(/64B/); }); it('does not show the oversized-title warning at the recommended .64B', () => { - render(<VideoFilenameTemplate value="%(title).64B" onChange={() => {}} token="tok" />); + renderTemplate({ value: '%(title).64B' }); expect(screen.queryByTestId('oversized-title-warning')).not.toBeInTheDocument(); }); it('shows a soft warning when the prefix includes locked suffix tokens', () => { - render(<VideoFilenameTemplate value="%(title).76B %(id)s" onChange={() => {}} token="tok" />); + renderTemplate({ value: '%(title).76B %(id)s' }); expect(screen.getByTestId('locked-suffix-warning')).toHaveTextContent(/added automatically/i); }); it('shows a validation error when prefix is empty', () => { - render(<VideoFilenameTemplate value="" onChange={() => {}} token="tok" />); + renderTemplate({ value: '' }); expect(screen.getByText(/may not be empty/i)).toBeInTheDocument(); }); it('shows a validation error when prefix contains a path separator', () => { - render(<VideoFilenameTemplate value="bad/value" onChange={() => {}} token="tok" />); + renderTemplate({ value: 'bad/value' }); expect(screen.getByText(/path separator/i)).toBeInTheDocument(); }); @@ -196,7 +195,7 @@ describe('VideoFilenameTemplate', () => { folderLineLength: 115, }, }); - render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + renderTemplate(); fireEvent.click(screen.getByTestId('filename-preview-button')); @@ -214,7 +213,7 @@ describe('VideoFilenameTemplate', () => { folderLineLength: 135, }, }); - render(<VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" />); + renderTemplate(); fireEvent.click(screen.getByTestId('filename-preview-button')); @@ -225,16 +224,14 @@ describe('VideoFilenameTemplate', () => { it('marks the preview block stale (opacity-60) after the prefix changes following a successful preview', async () => { axios.post.mockResolvedValueOnce({ data: SAMPLE_RESPONSE }); - const { rerender } = render( - <VideoFilenameTemplate value={defaultPrefix} onChange={() => {}} token="tok" /> - ); + const { rerenderTemplate } = renderTemplate(); fireEvent.click(screen.getByTestId('filename-preview-button')); await waitFor(() => { expect(screen.getByTestId('filename-preview')).toBeInTheDocument(); }); - rerender(<VideoFilenameTemplate value="%(title).50B" onChange={() => {}} token="tok" />); + rerenderTemplate({ value: '%(title).50B' }); expect(screen.getByTestId('filename-preview')).toHaveClass('opacity-60'); }); diff --git a/client/src/components/Configuration/sections/components/__tests__/libraryCardText.test.ts b/client/src/components/Configuration/sections/components/__tests__/libraryCardText.test.ts new file mode 100644 index 000000000..4fc7f8d8a --- /dev/null +++ b/client/src/components/Configuration/sections/components/__tests__/libraryCardText.test.ts @@ -0,0 +1,70 @@ +import { checkCell } from '../libraryCardText'; +import type { CheckStatus } from '../../../../../utils/libraryAttention'; + +type CheckCellArgs = Parameters<typeof checkCell>[0]; + +const base: CheckStatus = { kind: 'full', running: false, checked: ['Plex', 'Jellyfin'], unreachable: [], lastCheckedAt: 0, hasEarlierResults: true, error: null }; +const args = (status: Partial<CheckStatus>, extra: Partial<CheckCellArgs> = {}): CheckCellArgs => ({ + status: { ...base, ...status }, attentionCount: 0, configured: ['plex', 'jellyfin'], now: 60_000, timeZone: null, ...extra, +}); +const NOTHING_YET: Partial<CheckStatus> = { checked: [], lastCheckedAt: null, hasEarlierResults: false }; + +describe('checkCell', () => { + test('a full check with nothing to fix', () => { + expect(checkCell(args({}))).toMatchObject({ tone: 'success', value: 'All folders OK', sub: 'Checked with Plex and Jellyfin. Emby is not set up.' }); + }); + + test('a partial check never says All folders OK', () => { + expect(checkCell(args({ kind: 'partial', checked: ['Plex'], unreachable: [{ name: 'Jellyfin', error: 'x' }] }))).toMatchObject({ + value: 'No issues found', sub: "Checked with Plex. Jellyfin couldn't be reached. Emby is not set up.", + }); + }); + + test('attention counts and keeps the partial qualification', () => { + expect(checkCell(args({ kind: 'partial', checked: ['Plex'], unreachable: [{ name: 'Jellyfin', error: 'x' }] }, { attentionCount: 2 }))) + .toMatchObject({ tone: 'warning', value: '2 need attention', sub: "Checked with Plex. Jellyfin couldn't be reached. Emby is not set up." }); + }); + + test('a failed check with earlier results says when it last worked', () => { + expect(checkCell(args({ kind: 'failed', error: 'boom' }))).toMatchObject({ sub: 'Last checked 1 min ago. The latest check failed.', retry: true }); + }); + + test('a failed check without earlier results shows the error', () => { + expect(checkCell(args({ kind: 'failed', error: 'boom', hasEarlierResults: false, lastCheckedAt: null }))).toMatchObject({ + tone: 'destructive', value: "Couldn't check", sub: 'boom', retry: true, + }); + }); + + test('every server unreachable names them joined with or', () => { + expect(checkCell(args({ kind: 'unreachable', checked: [], unreachable: [{ name: 'Plex', error: 'x' }, { name: 'Jellyfin', error: 'y' }] }))) + .toMatchObject({ value: "Couldn't reach Plex or Jellyfin", retry: true }); + }); + + test('a failed re-check after every server was unreachable stays a failure, never a success', () => { + expect(checkCell(args({ kind: 'failed', error: 'boom', checked: [], unreachable: [{ name: 'Plex', error: 'x' }] }))).toMatchObject({ + tone: 'destructive', icon: 'x', value: "Couldn't reach Plex", sub: 'Last checked 1 min ago. The latest check failed.', retry: true, + }); + }); + + test('no media server configured links the server settings', () => { + expect(checkCell(args({ kind: 'none' }, { configured: [] }))).toMatchObject({ value: 'Not checked', linksServers: true }); + }); + + test('a running first check shows the spinner', () => { + expect(checkCell(args({ ...NOTHING_YET, kind: 'checking', running: true }))).toMatchObject({ + icon: 'spinner', value: 'Checking media servers...', sub: '', + }); + }); + + test('a check that has not started yet reads as checking, not as a result', () => { + expect(checkCell(args({ ...NOTHING_YET, kind: 'unchecked' }))).toMatchObject({ + icon: 'spinner', value: 'Checking media servers...', sub: '', retry: false, + }); + }); + + test('servers not known yet reads as checking, not as no media server', () => { + expect(checkCell(args({ ...NOTHING_YET, kind: 'none' }, { configured: [], serversKnown: false }))).toMatchObject({ + icon: 'spinner', value: 'Checking media servers...', linksServers: false, + }); + }); +}); diff --git a/client/src/components/Configuration/sections/components/libraryCardText.ts b/client/src/components/Configuration/sections/components/libraryCardText.ts new file mode 100644 index 000000000..d51cb4b53 --- /dev/null +++ b/client/src/components/Configuration/sections/components/libraryCardText.ts @@ -0,0 +1,69 @@ +import type { MediaServerType } from '../../../../types/libraryCheck'; +import { CheckStatus, SERVER_NAMES, SERVER_ORDER, joinNames, timeAgo } from '../../../../utils/libraryAttention'; + +/** The Core card's Media server check cell (Core 3.4). */ +export interface CheckCell { + tone: 'muted' | 'success' | 'warning' | 'destructive' | 'foreground'; + icon: 'spinner' | 'info' | 'check' | 'alert' | 'x'; + value: string; + sub: string; + /** Offer Try again */ + retry: boolean; + /** The sub line links Plex, Jellyfin and Emby to their settings pages */ + linksServers: boolean; +} + +/** The no-media-server sub line around its server names, so the card can link each name. */ +export const NO_SERVERS_LEAD = "No media server is connected, so Youtarr can't check your libraries. Libraries you set up by hand keep " + + 'working; connect '; +export const NO_SERVERS_TAIL = ' to check them.'; +const NO_SERVERS_SUB = `${NO_SERVERS_LEAD}${joinNames(SERVER_ORDER.map((type) => SERVER_NAMES[type]), 'or')}${NO_SERVERS_TAIL}`; + +const CHECKING: CheckCell = { + tone: 'muted', icon: 'spinner', value: 'Checking media servers...', sub: '', retry: false, linksServers: false, +}; + +function notSetUp(configured: readonly MediaServerType[]): string { + const missing = SERVER_ORDER.filter((type) => !configured.includes(type)).map((type) => SERVER_NAMES[type]); + if (missing.length === 0 || missing.length === SERVER_ORDER.length) return ''; + return ` ${joinNames(missing)} ${missing.length === 1 ? 'is' : 'are'} not set up.`; +} + +function checkedWith(status: CheckStatus, configured: readonly MediaServerType[]): string { + const unreachable = status.unreachable.map((server) => ` ${server.name} couldn't be reached.`).join(''); + return `Checked with ${joinNames(status.checked)}.${unreachable}${notSetUp(configured)}`; +} + +export function checkCell({ status, attentionCount, configured, now, timeZone, serversKnown = true }: { + status: CheckStatus; attentionCount: number; configured: readonly MediaServerType[]; now: number; timeZone: string | null; + /** False while the configured servers are still loading and the check has no answer yet */ + serversKnown?: boolean; +}): CheckCell { + const base = { retry: false, linksServers: false }; + // Until the servers are known and the first check has answered, show it running, never a result. + if (!serversKnown) return CHECKING; + if (status.kind === 'none') return { ...base, tone: 'foreground', icon: 'info', value: 'Not checked', sub: NO_SERVERS_SUB, linksServers: true }; + if (!status.hasEarlierResults && (status.kind === 'checking' || status.kind === 'unchecked' || status.running)) return CHECKING; + if (status.kind === 'failed' && !status.hasEarlierResults) { + return { tone: 'destructive', icon: 'x', value: "Couldn't check", sub: status.error || 'Could not check the media server libraries', retry: true, linksServers: false }; + } + const failedSub = status.kind === 'failed' && status.lastCheckedAt !== null + ? `Last checked ${timeAgo(status.lastCheckedAt, now, timeZone)}. The latest check failed.` : null; + // Also after a failed re-check: the earlier results checked no server, so they are no success. + if (status.checked.length === 0 && status.unreachable.length > 0) { + return { + tone: 'destructive', icon: 'x', value: `Couldn't reach ${joinNames(status.unreachable.map((server) => server.name), 'or')}`, + sub: failedSub ?? '', retry: true, linksServers: false, + }; + } + if (attentionCount > 0) { + return { + tone: 'warning', icon: 'alert', value: `${attentionCount} ${attentionCount === 1 ? 'needs' : 'need'} attention`, + sub: failedSub ?? checkedWith(status, configured), retry: Boolean(failedSub), linksServers: false, + }; + } + return { + tone: 'success', icon: 'check', value: status.unreachable.length > 0 ? 'No issues found' : 'All folders OK', + sub: failedSub ?? checkedWith(status, configured), retry: Boolean(failedSub), linksServers: false, + }; +} diff --git a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx index cf6043f80..d7b254fc0 100644 --- a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx +++ b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx @@ -392,7 +392,7 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({ )} <Typography variant="caption" color="text.secondary" className="block mt-2"> - Configured channels will use their subfolder settings. + Configured channels will use their library folder settings. Enable custom settings to download MP3 audio. </Typography> </Paper> @@ -504,8 +504,8 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({ createSubfolder={createSubfolder} defaultSubfolderDisplay={defaultLibraryFolder || null} layoutOf={layoutOf} - label="Override Destination" - helperText="Configured channels use their subfolder, unconfigured channels use global default." + label="Library folder" + helperText="Channels you subscribe to use their own library folder; others use the default folder." /> <Typography variant="subtitle2" color="text.secondary" className="mb-2 mt-4"> diff --git a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.story.tsx b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.story.tsx index 47de38234..4f8073080 100644 --- a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.story.tsx +++ b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.story.tsx @@ -1,3 +1,5 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from 'storybook/test'; import DownloadSettingsDialog from '../DownloadSettingsDialog'; @@ -5,6 +7,7 @@ import DownloadSettingsDialog from '../DownloadSettingsDialog'; const meta: Meta<typeof DownloadSettingsDialog> = { title: 'Components/DownloadManager/DownloadSettingsDialog', component: DownloadSettingsDialog, + decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>], args: { open: true, onClose: fn(), diff --git a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx index 34af2255e..4c95124ce 100644 --- a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx +++ b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx @@ -1,9 +1,13 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { render as rtlRender, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import DownloadSettingsDialog from '../DownloadSettingsDialog'; +const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter }); + jest.mock('../../../../hooks/useLibraryFolders', () => ({ useLibraryFolders: jest.fn(), })); @@ -1184,7 +1188,7 @@ describe('DownloadSettingsDialog', () => { }; const chooseDestination = async (user: ReturnType<typeof userEvent.setup>, name: RegExp) => { - await user.click(screen.getByLabelText('Override Destination')); + await user.click(screen.getByLabelText('Library folder')); await user.click(screen.getByRole('option', { name })); }; @@ -1197,7 +1201,7 @@ describe('DownloadSettingsDialog', () => { render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); openCustomSettings(); - await chooseDestination(user, /Root directory \(no subfolder\) \(TV\)/); + await chooseDestination(user, /Main folder \(TV\)/); await user.click(screen.getByLabelText('Download Type')); expect(screen.getByRole('option', { name: 'Video Only' })).toBeInTheDocument(); @@ -1210,7 +1214,7 @@ describe('DownloadSettingsDialog', () => { render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); openCustomSettings(); - await chooseDestination(user, /Root directory \(no subfolder\) \(TV\)/); + await chooseDestination(user, /Main folder \(TV\)/); expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); }); @@ -1232,7 +1236,7 @@ describe('DownloadSettingsDialog', () => { await user.click(screen.getByLabelText('Download Type')); await user.click(screen.getByRole('option', { name: 'MP3 Only' })); - await chooseDestination(user, /Root directory \(no subfolder\) \(TV\)/); + await chooseDestination(user, /Main folder \(TV\)/); fireEvent.click(screen.getByRole('button', { name: /Start Download/i })); expect(mockOnConfirm).toHaveBeenCalledWith( @@ -1249,7 +1253,7 @@ describe('DownloadSettingsDialog', () => { render(<DownloadSettingsDialog {...defaultProps} mode="manual" />); openCustomSettings(); - await chooseDestination(user, /Use Global Default Subfolder \(TV\)/); + await chooseDestination(user, /Use the default folder \(TV\)/); expect(screen.getByText('TV folders are video-only.')).toBeInTheDocument(); }); diff --git a/client/src/components/LibraryFolders/LibraryFoldersContext.tsx b/client/src/components/LibraryFolders/LibraryFoldersContext.tsx index 2127a41ac..a4b90c454 100644 --- a/client/src/components/LibraryFolders/LibraryFoldersContext.tsx +++ b/client/src/components/LibraryFolders/LibraryFoldersContext.tsx @@ -20,6 +20,8 @@ export interface LibraryPageValue { folders: LibraryFolder[]; /** The folder list's first answer (or failure) has arrived */ foldersLoaded: boolean; + /** Whether the guide starts open, decided once when the folders first load (null until then) */ + guideDefaultOpen: boolean | null; /** The main folder's detail, loaded once for every section that lists its channels */ mainDetail: LibraryFolderDetail | null; check: LibraryCheckState & { diff --git a/client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx b/client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx index 3e1219def..706ea9230 100644 --- a/client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx +++ b/client/src/components/LibraryFolders/__tests__/LibraryFolders.test.tsx @@ -141,6 +141,19 @@ describe('LibraryFolders page', () => { await waitFor(() => expect(screen.getByRole('button', { name: /__Kids/ })).toHaveFocus()); }); + test('narrow: the guide stays expanded after Back when a TV folder was created meanwhile', async () => { + const { rerender } = renderAt('/settings/library', 800); + expect(screen.getByRole('button', { name: 'Hide the guide' })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /__Kids/ })); + await screen.findByText('inspector Kids'); + (useLibraryFolders as jest.Mock).mockReturnValue(libraryResult({ folders: [...folders, folder('Shows', { layout: 'tv' })] })); + rerender(pageAt('/settings/library/Kids')); + + await userEvent.click(await screen.findByRole('button', { name: 'Library folders' })); + + expect(await screen.findByRole('button', { name: 'Hide the guide' })).toHaveAttribute('aria-expanded', 'true'); + }); + test('narrow: deleting from a detail opened from the list goes back instead of adding a list entry', async () => { (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 800]); render( diff --git a/client/src/components/LibraryFolders/__tests__/renderPage.tsx b/client/src/components/LibraryFolders/__tests__/renderPage.tsx index 67dd2059f..773a2439a 100644 --- a/client/src/components/LibraryFolders/__tests__/renderPage.tsx +++ b/client/src/components/LibraryFolders/__tests__/renderPage.tsx @@ -26,6 +26,7 @@ export function makePageValue(overrides: Partial<LibraryPageValue> = {}): Librar twoColumn: true, folders: [folder('', { isDefault: true })], foldersLoaded: true, + guideDefaultOpen: true, mainDetail: null, check: { data: emptyCheck, loading: false, error: null, lastCheckedAt: Date.now(), refetch: jest.fn().mockResolvedValue(undefined), applyPlexMapping: jest.fn().mockResolvedValue(undefined) }, configuredServers: [], diff --git a/client/src/components/LibraryFolders/components/LibraryGuide.tsx b/client/src/components/LibraryFolders/components/LibraryGuide.tsx index b500f99eb..7e912089e 100644 --- a/client/src/components/LibraryFolders/components/LibraryGuide.tsx +++ b/client/src/components/LibraryFolders/components/LibraryGuide.tsx @@ -30,8 +30,7 @@ const ALL_SERVERS: SetupServer[] = ['plex', 'jellyfin', 'emby', 'kodi']; /** How library folders work (UI 5.4, 6.2). */ export function LibraryGuide() { const page = useLibraryPage(); - const hasTv = page.folders.some((folder) => folder.layout === 'tv'); - const [open, toggle] = useGuideOpen(page.foldersLoaded ? !page.phone && !hasTv : null); + const [open, toggle] = useGuideOpen(page.guideDefaultOpen); const servers: SetupServer[] = page.servers.length > 0 ? page.servers.map((server) => server.serverType) : ALL_SERVERS; return ( diff --git a/client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx b/client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx index b5dd1e5b9..cc87be9bc 100644 --- a/client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx +++ b/client/src/components/LibraryFolders/components/__tests__/LibraryGuide.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { LibraryGuide } from '../LibraryGuide'; -import { folder, inPage, makePageValue, renderInPage } from '../../__tests__/renderPage'; +import { inPage, makePageValue, renderInPage } from '../../__tests__/renderPage'; describe('LibraryGuide', () => { beforeEach(() => window.localStorage.clear()); @@ -17,21 +17,21 @@ describe('LibraryGuide', () => { }); test('is collapsed once a TV folder exists, with the one-line summary', () => { - renderInPage(<LibraryGuide />, { value: makePageValue({ folders: [folder(''), folder('TV', { layout: 'tv' })] }) }); + renderInPage(<LibraryGuide />, { value: makePageValue({ guideDefaultOpen: false }) }); expect(screen.getByText('Channels pick a folder, its layout files them, a matching library shows it.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Show the guide' })).toBeInTheDocument(); }); test('stays folded while the folders load, then opens when there are no TV folders', () => { - const { rerender } = renderInPage(<LibraryGuide />, { value: makePageValue({ folders: [], foldersLoaded: false }) }); + const { rerender } = renderInPage(<LibraryGuide />, { value: makePageValue({ guideDefaultOpen: null }) }); expect(screen.getByRole('button', { name: 'Show the guide' })).toBeInTheDocument(); - rerender(inPage(<LibraryGuide />, makePageValue({ folders: [folder('')], foldersLoaded: true }))); + rerender(inPage(<LibraryGuide />, makePageValue({ guideDefaultOpen: true }))); expect(screen.getByRole('button', { name: 'Hide the guide' })).toBeInTheDocument(); }); - test('stays open when the first TV folder is created', () => { + test('keeps its first default when the page value changes later', () => { const { rerender } = renderInPage(<LibraryGuide />); - rerender(inPage(<LibraryGuide />, makePageValue({ folders: [folder(''), folder('TV', { layout: 'tv' })] }))); + rerender(inPage(<LibraryGuide />, makePageValue({ guideDefaultOpen: false }))); expect(screen.getByRole('button', { name: 'Hide the guide' })).toBeInTheDocument(); }); diff --git a/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx b/client/src/components/LibraryFolders/components/__tests__/MainFolderTvDialog.test.tsx similarity index 97% rename from client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx rename to client/src/components/LibraryFolders/components/__tests__/MainFolderTvDialog.test.tsx index c07c4e080..bce6e1a46 100644 --- a/client/src/components/Configuration/sections/components/__tests__/MainFolderTvDialog.test.tsx +++ b/client/src/components/LibraryFolders/components/__tests__/MainFolderTvDialog.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; -import { MainFolderTvDialog } from '../MainFolderTvDialog'; +import { MainFolderTvDialog } from '../dialogs/MainFolderTvDialog'; function showDialog(overrides: Partial<React.ComponentProps<typeof MainFolderTvDialog>> = {}) { const props = { diff --git a/client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx b/client/src/components/LibraryFolders/components/dialogs/MainFolderTvDialog.tsx similarity index 100% rename from client/src/components/Configuration/sections/components/MainFolderTvDialog.tsx rename to client/src/components/LibraryFolders/components/dialogs/MainFolderTvDialog.tsx diff --git a/client/src/components/LibraryFolders/index.tsx b/client/src/components/LibraryFolders/index.tsx index 417db7dab..f43aa5843 100644 --- a/client/src/components/LibraryFolders/index.tsx +++ b/client/src/components/LibraryFolders/index.tsx @@ -12,7 +12,7 @@ import { useMediaServerStatus } from '../../hooks/useMediaServerStatus'; import { useContainerWidth } from '../../hooks/useContainerWidth'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import { ReorganizeDialog, useActiveReorganize } from '../shared/Reorganize'; -import { MainFolderTvDialog } from '../Configuration/sections/components/MainFolderTvDialog'; +import { MainFolderTvDialog } from './components/dialogs/MainFolderTvDialog'; import { SERVER_ORDER, buildAttention, serversOf } from '../../utils/libraryAttention'; import { folderKey } from '../../utils/libraryLayouts'; import { HEADER_HEIGHT_DESKTOP } from '../layout/navLayoutConstants'; @@ -68,6 +68,12 @@ export default function LibraryFolders({ folders: library.folders, loaded: library.loaded, loading: library.loading, error: library.error, twoColumn, }); const { readBackDefault } = useDefaultFolder(token); + // The guide's default is decided once, here: the narrow detail screen unmounts the guide, and a + // remount after the first TV folder was created would otherwise decide "folded". + const [guideDefaultOpen, setGuideDefaultOpen] = useState<boolean | null>(null); + if (guideDefaultOpen === null && library.loaded) { + setGuideDefaultOpen(!phone && !library.folders.some((folder) => folder.layout === 'tv')); + } const [addLayout, setAddLayout] = useState<LibraryLayout | null>(null); const [startTvOpen, setStartTvOpen] = useState(false); const [mainTvOpen, setMainTvOpen] = useState(false); @@ -139,7 +145,7 @@ export default function LibraryFolders({ const value: LibraryPageValue = { token, config, isPlatformManaged, timeZone: deploymentEnvironment.timezone ?? null, phone, twoColumn: twoColumn === true, - folders: library.folders, foldersLoaded: library.loaded, mainDetail, check, configuredServers, + folders: library.folders, foldersLoaded: library.loaded, guideDefaultOpen, mainDetail, check, configuredServers, serversKnown: !serverStatusLoading || checkData !== null, servers, plexLibraries, plexConnectionStatus, reorganizing: Boolean(operation), movingFolders, layoutResult: layoutChange.result, busyLayoutFolder: layoutChange.busyFolder, changeLayout: layoutChange.changeLayout, diff --git a/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx b/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx index 6c08821c8..814f33847 100644 --- a/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistSettingsDialog.tsx @@ -145,7 +145,7 @@ const PlaylistSettingsDialog: React.FC<PlaylistSettingsDialogProps> = ({ <div> <Typography variant="subtitle2" gutterBottom style={{ fontWeight: 600 }}> - Subfolder + Library folder </Typography> <SubfolderAutocomplete mode="channel" @@ -156,8 +156,8 @@ const PlaylistSettingsDialog: React.FC<PlaylistSettingsDialogProps> = ({ createSubfolder={createSubfolder} defaultSubfolderDisplay={config.defaultSubfolder || null} layoutOf={layoutOf} - label="Default Subfolder" - helperText="Where this playlist's videos are saved when the channel has no subfolder of its own." + label="Library folder" + helperText="Where this playlist's videos are saved when the channel has no library folder of its own." /> </div> diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 395631b94..423d7c95f 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import isEqual from 'lodash/isEqual'; import { Alert, @@ -40,7 +40,6 @@ import { useYouTubeApiKey } from '../Configuration/hooks/useYouTubeApiKey'; import { useStorageStatus } from '../../hooks/useStorageStatus'; import { useConfig } from '../../hooks/useConfig'; import { TRACKABLE_CONFIG_KEYS } from '../../config/configSchema'; -import { defaultSubfolderMoveNotice } from './defaultSubfolderMove'; import { ConfigState, SnackbarState } from '../Configuration/types'; import { validateConfig } from '../Configuration/utils/configValidation'; import { FILENAME_PRESETS } from '../../utils/filenameTemplate/presets'; @@ -51,8 +50,6 @@ import LibraryFolders from '../LibraryFolders'; import { MaintenanceSection } from './MaintenanceSection'; import { SchedulingSection } from '../Configuration/sections/SchedulingSection'; import { LoggingSection } from '../Configuration/sections/LoggingSection'; -import { ReorganizeDialog, useReorganizeOutcome } from '../shared/Reorganize'; -import { ReorganizeStartResult } from '../../types/reorganize'; interface SettingsProps { token: string | null; @@ -138,9 +135,7 @@ export function Settings({ token }: SettingsProps) { hasPlexServerConfigured, }); - const { - saveConfig, isSaving, fieldErrors, clearFieldErrors, reorganizeChange, finishReorganize, readBackDefaultSubfolder, - } = useConfigSave({ + const { saveConfig, isSaving, fieldErrors, clearFieldErrors } = useConfigSave({ token, config, setInitialConfig, @@ -187,60 +182,6 @@ export function Settings({ token }: SettingsProps) { } }, [validationError, saveConfig, confirmNav]); - // The move started or retried from here, followed to each end (the review may be closed by then). - const [trackedMove, setTrackedMove] = useState<{ operationId: number; requested: string; attempt: number } | null>(null); - // The default subfolder value this flow last put in the form: the requested - // one at the start, the read-back one after an end. The form is moved on - // only while it still holds that value, so an edit made meanwhile is kept. - const formDefaultSetByMove = useRef<string | null>(null); - - // A default subfolder change that moved downloads is applied by the - // reorganize; the rest of the form is saved by saving again. - const handleDefaultSubfolderMoved = useCallback((result: ReorganizeStartResult) => { - if (!reorganizeChange || reorganizeChange.type !== 'defaultSubfolder') return; - const value = reorganizeChange.value; - const notice = initialConfig ? defaultSubfolderMoveNotice(config, initialConfig, value) : null; - setInitialConfig((current) => (current ? { ...current, defaultSubfolder: value } : current)); - formDefaultSetByMove.current = value; - if (result.operationId) setTrackedMove({ operationId: result.operationId, requested: value, attempt: 0 }); - if (notice) setSnackbar({ open: true, message: notice, severity: 'info' }); - }, [reorganizeChange, setInitialConfig, config, initialConfig]); - - // The server undoes the change when no video could be moved, and applies - // it again when a retry moves some: the form follows the value read back, - // unless the user has edited the field again meanwhile. - const followReadBack = useCallback((requested: string, saved: string | null) => { - const shown = formDefaultSetByMove.current; - if (saved === null || shown === null || saved === shown) return; - setConfig((current) => (current.defaultSubfolder === shown ? { ...current, defaultSubfolder: saved } : current)); - formDefaultSetByMove.current = saved; - setSnackbar({ - open: true, - message: saved === requested - ? 'The retry moved the videos, so the default subfolder change is applied after all.' - : 'The default subfolder change was undone because none of the videos could be moved.', - severity: saved === requested ? 'info' : 'warning', - }); - }, [setConfig]); - - const handleReorganizeClosed = useCallback(async () => { - const requested = reorganizeChange?.type === 'defaultSubfolder' ? reorganizeChange.value : null; - const saved = await finishReorganize(); - if (requested !== null) followReadBack(requested, saved); - }, [reorganizeChange, finishReorganize, followReadBack]); - - const handleReorganizeRetried = useCallback((operationId: number) => { - setTrackedMove((current) => (current && current.operationId === operationId - ? { ...current, attempt: current.attempt + 1 } - : current)); - }, []); - - useReorganizeOutcome(token, trackedMove?.operationId ?? null, () => { - const requested = trackedMove?.requested; - if (requested === undefined) return; - void readBackDefaultSubfolder().then((saved) => followReadBack(requested, saved)); - }, { attempt: trackedMove?.attempt ?? 0 }); - const { versionInfo: ytDlpVersionInfo, updateStatus: ytDlpUpdateStatus, @@ -356,7 +297,7 @@ export function Settings({ token }: SettingsProps) { <ConfigurationSkeleton compact /> ) : ( <Routes> - <Route index element={<SettingsIndex />} /> + <Route index element={<SettingsIndex token={token} />} /> <Route path="core" element={ @@ -414,7 +355,6 @@ export function Settings({ token }: SettingsProps) { onOpenLibrarySelector={openLibrarySelector} onOpenPlexAuthDialog={() => setOpenPlexAuthDialog(true)} onMobileTooltipClick={setMobileTooltip} - token={token} /> } /> @@ -605,15 +545,6 @@ export function Settings({ token }: SettingsProps) { onSuccess={handlePlexAuthSuccess} /> - <ReorganizeDialog - open={reorganizeChange !== null} - token={token} - change={reorganizeChange} - onClose={() => { void handleReorganizeClosed(); }} - onApplied={handleDefaultSubfolderMoved} - onRetried={handleReorganizeRetried} - /> - <Snackbar open={snackbar.open} autoHideDuration={6000} diff --git a/client/src/components/Settings/SettingsIndex.tsx b/client/src/components/Settings/SettingsIndex.tsx index e721be685..bdbf0fcaa 100644 --- a/client/src/components/Settings/SettingsIndex.tsx +++ b/client/src/components/Settings/SettingsIndex.tsx @@ -1,97 +1,145 @@ import React from 'react'; -import { Card, CardActionArea, CardContent, Grid, Typography } from '../ui'; import { Link as RouterLink } from 'react-router-dom'; +import { LIBRARY_FOLDERS_PATH } from '../../utils/libraryLayouts'; +import type { LucideIcon } from 'lucide-react'; +import { ArrowRight, ChevronRight, Download, Film, Folder, Library, RefreshCw, Server, Settings as SettingsIcon, Tv } from '../../lib/icons'; +import { useContainerWidth } from '../../hooks/useContainerWidth'; +import { useLibraryFolders } from '../../hooks/useLibraryFolders'; +import { LayoutChip } from '../shared/LayoutChip'; -const SETTINGS_CARD_CONTENT_HEIGHT = 72; +export type SettingsGroupKey = 'downloads' | 'servers' | 'automation' | 'system'; export interface SettingsPage { + /** Route segment under /settings */ key: string; + /** Index row title and page title */ title: string; description: string; - /** Shorter label for the nav sub-item and phone tab strip */ + group: SettingsGroupKey; + /** Shorter label for the tab strip and sidebar sub-items */ navLabel?: string; } +export const SETTINGS_GROUPS: Array<{ key: SettingsGroupKey; title: string; description: string; Icon: LucideIcon }> = [ + { key: 'downloads', title: 'Downloads & library', description: 'What Youtarr downloads and where the files go.', Icon: Download }, + { key: 'servers', title: 'Media servers', description: 'Connections for library checks, refreshes, playlists and watch status.', Icon: Server }, + { key: 'automation', title: 'Automation & storage', description: 'Schedules, cleanup and disk space.', Icon: RefreshCw }, + { key: 'system', title: 'System', description: 'Notifications, access and troubleshooting.', Icon: SettingsIcon }, +]; + +/** Nav order (tab strip, sidebar): today's order with library after core. The index groups by `group`. */ export const SETTINGS_PAGES: SettingsPage[] = [ - { key: 'scheduling', title: 'Scheduling', description: 'Choose when automatic downloads and maintenance tasks run.' }, - { key: 'core', title: 'Core', description: 'Downloads folder, quality, defaults, and core behavior.' }, - { key: 'library', title: 'Library folders', navLabel: 'Library', description: 'Folders, their Videos or TV shows layout, and the media server library for each.' }, - { key: 'downloading', title: 'YT-DLP', description: 'yt-dlp backend settings for downloads and reliability.' }, - { key: 'api-keys', title: 'API Keys', description: 'API key settings and rate limits.' }, - { key: 'appearance', title: 'Appearance', description: 'Theme, animations, and visual preferences.' }, - { key: 'autoremove', title: 'Auto Removal', description: 'Automated cleanup and retention policies.' }, - { key: 'storage-limits', title: 'Storage Limits', description: 'Pause downloads when storage is full or over a size limit.' }, - { key: 'cookies', title: 'Cookies', description: 'Cookie configuration and login helpers.' }, - { key: 'maintenance', title: 'Maintenance & Rescan', description: 'Rescan files on disk and other maintenance actions.' }, - { key: 'logging', title: 'Logging', description: 'Log level and log files for troubleshooting.' }, - { key: 'notifications', title: 'Notifications', description: 'Toast notifications and alert behavior.' }, - { key: 'plex', title: 'Plex', description: 'Plex integration and library configuration.' }, - { key: 'jellyfin', title: 'Jellyfin', description: 'Jellyfin connection for native playlist sync.' }, - { key: 'emby', title: 'Emby', description: 'Emby connection for native playlist sync.' }, - { key: 'watch-status', title: 'Watch Status', description: 'Sync watched state from your media servers into Youtarr.' }, - { key: 'security', title: 'Account Security', description: 'Authentication and password management.' }, - { key: 'sponsorblock', title: 'SponsorBlock', description: 'Skip segments and SponsorBlock settings.' }, - { key: 'youtube-api', title: 'YouTube API', description: 'Optional YouTube Data API v3 key for faster metadata fetches.' }, + { key: 'scheduling', title: 'Scheduling', group: 'automation', description: 'Choose when automatic downloads and maintenance tasks run.' }, + { key: 'core', title: 'Core', group: 'downloads', description: 'Automatic downloads, quality, subtitles, metadata and naming.' }, + { key: 'library', title: 'Library folders', navLabel: 'Library', group: 'downloads', description: 'Folders, their Videos or TV shows layout, and the media server library for each.' }, + { key: 'downloading', title: 'YT-DLP', group: 'downloads', description: 'yt-dlp backend settings for downloads and reliability.' }, + { key: 'api-keys', title: 'API Keys', group: 'system', description: 'API key settings and rate limits.' }, + { key: 'appearance', title: 'Appearance', group: 'system', description: 'Theme, animations, and visual preferences.' }, + { key: 'autoremove', title: 'Auto Removal', group: 'automation', description: 'Automated cleanup and retention policies.' }, + { key: 'storage-limits', title: 'Storage Limits', group: 'automation', description: 'Pause downloads when storage is full or over a size limit.' }, + { key: 'cookies', title: 'Cookies', group: 'downloads', description: 'Cookie configuration and login helpers.' }, + { key: 'maintenance', title: 'Maintenance & Rescan', group: 'automation', description: 'Rescan files on disk and other maintenance actions.' }, + { key: 'logging', title: 'Logging', group: 'system', description: 'Log level and log files for troubleshooting.' }, + { key: 'notifications', title: 'Notifications', group: 'system', description: 'Toast notifications and alert behavior.' }, + { key: 'plex', title: 'Plex', group: 'servers', description: 'Plex connection, default library and playlist visibility.' }, + { key: 'jellyfin', title: 'Jellyfin', group: 'servers', description: 'Jellyfin connection for native playlist sync.' }, + { key: 'emby', title: 'Emby', group: 'servers', description: 'Emby connection for native playlist sync.' }, + { key: 'watch-status', title: 'Watch Status', group: 'servers', description: 'Sync watched state from your media servers into Youtarr.' }, + { key: 'security', title: 'Account Security', group: 'system', description: 'Authentication and password management.' }, + { key: 'sponsorblock', title: 'SponsorBlock', group: 'downloads', description: 'Skip segments and SponsorBlock settings.' }, + { key: 'youtube-api', title: 'YouTube API', group: 'downloads', description: 'Optional YouTube Data API v3 key for faster metadata fetches.' }, +]; + +const TWO_COLUMN_MIN_WIDTH = 720; +const LEFT: SettingsGroupKey[] = ['downloads', 'automation']; +const RIGHT: SettingsGroupKey[] = ['servers', 'system']; +const STACKED: SettingsGroupKey[] = ['downloads', 'servers', 'automation', 'system']; +const HELP_STEPS = [ + 'Keep different kinds of content in their own library folders, like __Kids or __TV Shows.', + 'Set each folder to Videos or TV shows, and show it in a library of the matching type. One library on the whole downloads folder works while every folder uses Videos.', + 'Choose a folder for each channel, or let it use the default folder.', ]; -export function SettingsIndex() { +function LibraryRowStatus({ token }: { token: string | null }) { + const { folders, loading, loaded, error } = useLibraryFolders(token); + if (error) return null; + if (!loaded || (loading && folders.length === 0)) return <span className="mt-2 block h-5 w-40 animate-pulse rounded bg-muted" />; + if (folders.length === 1) return <span className="mt-2 flex items-center gap-1.5 text-xs">Main folder only <LayoutChip layout={folders[0].layout} /></span>; + const videos = folders.filter((folder) => folder.layout === 'videos').length; + const tv = folders.length - videos; + return ( + <span className="mt-2 flex flex-wrap gap-x-3 text-xs leading-5"> + <span className="inline-flex items-center gap-1"><Folder size={12} aria-hidden="true" />{folders.length} {folders.length === 1 ? 'folder' : 'folders'}</span> + <span className="inline-flex items-center gap-1"><Film size={12} aria-hidden="true" />{videos} Videos {videos === 1 ? 'folder' : 'folders'}</span> + <span className="inline-flex items-center gap-1 text-info"><Tv size={12} aria-hidden="true" />{tv} TV {tv === 1 ? 'folder' : 'folders'}</span> + </span> + ); +} + +function Group({ groupKey, token }: { groupKey: SettingsGroupKey; token: string | null }) { + const group = SETTINGS_GROUPS.find((entry) => entry.key === groupKey)!; + const titleId = `settings-group-${groupKey}`; return ( - <div> - {/* Page title is rendered by the parent Settings page; keep this index compact */} + <section aria-labelledby={titleId}> + <h2 id={titleId} className="flex items-center gap-2 font-display text-base font-semibold leading-[22px]"> + <group.Icon size={16} aria-hidden="true" className="text-muted-foreground" />{group.title} + </h2> + <p className="mt-1 text-[13px] text-muted-foreground">{group.description}</p> + <ul className="mt-2.5 divide-y divide-border/60 rounded-ui border border-border bg-card"> + {SETTINGS_PAGES.filter((page) => page.group === groupKey).map((page) => ( + <li key={page.key}> + <RouterLink to={`/settings/${page.key}`} className="group flex min-h-[56px] items-center gap-3 px-4 py-3.5 hover:bg-muted/40"> + <span className="min-w-0 flex-1"> + <span className="block text-sm font-semibold leading-5">{page.title}</span> + <span className="mt-0.5 block text-[13px] leading-[19.5px] text-muted-foreground">{page.description}</span> + {page.key === 'library' && <LibraryRowStatus token={token} />} + </span> + <ChevronRight size={16} aria-hidden="true" className="shrink-0 text-muted-foreground group-hover:text-foreground" /> + </RouterLink> + </li> + ))} + </ul> + </section> + ); +} - <Grid container spacing={2}> - {SETTINGS_PAGES.map((page) => ( - <Grid item xs={12} md={6} lg={4} key={page.key} style={{ display: 'flex' }}> - <Card - className="settings-splash-card" - variant="outlined" - style={{ - borderRadius: 'var(--radius-ui)', - width: '100%', - height: '100%', - border: 'var(--border-weight) solid var(--border)', - }} - > - <CardActionArea - component={RouterLink} - to={`/settings/${page.key}`} - style={{ height: '100%', display: 'flex' }} - > - <CardContent - style={{ - flex: 1, - height: SETTINGS_CARD_CONTENT_HEIGHT, - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-start', - gap: 4, - padding: '12px 12px', - }} - > - <div style={{ minWidth: 0 }}> - <Typography variant="h6" style={{ fontWeight: 700, marginBottom: 2, lineHeight: 1 }}> - {page.title} - </Typography> - <Typography - variant="body2" - color="text.secondary" - style={{ - display: '-webkit-box', - WebkitLineClamp: 2, - overflow: 'hidden', - textOverflow: 'ellipsis', - lineHeight: 1.15, - } as React.CSSProperties} - > - {page.description} - </Typography> - </div> - </CardContent> - </CardActionArea> - </Card> - </Grid> +function HelpCard() { + return ( + <section aria-labelledby="settings-help-title" className="rounded-ui border border-border bg-card p-4"> + <h2 id="settings-help-title" className="flex items-center gap-2 font-display text-[15px] font-semibold"> + <Library size={16} aria-hidden="true" className="text-primary" />Setting up your library + </h2> + <ol className="mt-2 flex flex-col gap-2"> + {HELP_STEPS.map((step, index) => ( + <li key={step} className="flex gap-2 text-[13px] text-muted-foreground"> + <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-ui border border-border text-xs">{index + 1}</span>{step} + </li> ))} - </Grid> + </ol> + <RouterLink to={LIBRARY_FOLDERS_PATH} className="mt-3 inline-flex items-center gap-1 text-[13px] text-primary underline max-md:min-h-[44px]"> + Open Library folders<ArrowRight size={14} aria-hidden="true" /> + </RouterLink> + </section> + ); +} + +/** /settings: pages grouped as list cards (Core spec 7). */ +export function SettingsIndex({ token }: { token: string | null }) { + const [measureRef, width] = useContainerWidth<HTMLDivElement>(); + const columns = width !== null && width >= TWO_COLUMN_MIN_WIDTH; + return ( + <div ref={measureRef}> + {columns ? ( + <div className="grid grid-cols-2 items-start gap-6"> + <div className="flex flex-col gap-8">{LEFT.map((key) => <Group key={key} groupKey={key} token={token} />)}</div> + <div className="flex flex-col gap-8">{RIGHT.map((key) => <Group key={key} groupKey={key} token={token} />)}<HelpCard /></div> + </div> + ) : ( + <div className="flex flex-col gap-8"> + {STACKED.map((key) => <Group key={key} groupKey={key} token={token} />)} + <HelpCard /> + </div> + )} </div> ); } diff --git a/client/src/components/Settings/__tests__/SettingsIndex.test.tsx b/client/src/components/Settings/__tests__/SettingsIndex.test.tsx new file mode 100644 index 000000000..7aeabdb22 --- /dev/null +++ b/client/src/components/Settings/__tests__/SettingsIndex.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { screen, within } from '@testing-library/react'; +import { renderWithProviders } from '../../../test-utils'; +import { SETTINGS_PAGES, SettingsIndex } from '../SettingsIndex'; +import { useLibraryFolders } from '../../../hooks/useLibraryFolders'; +import { useContainerWidth } from '../../../hooks/useContainerWidth'; + +jest.mock('../../../hooks/useLibraryFolders', () => ({ useLibraryFolders: jest.fn() })); +jest.mock('../../../hooks/useContainerWidth', () => ({ useContainerWidth: jest.fn() })); + +const f = (name: string, layout = 'videos') => ({ name, layout, isDefault: false, hasFiles: false, channels: 0 }); +const loadedState = (folders: ReturnType<typeof f>[]) => ({ folders, loading: false, loaded: true, error: null }); + +const HEADINGS = ['Downloads & library', 'Media servers', 'Automation & storage', 'System', 'Setting up your library']; + +function headingOrder(): string[] { + return screen + .getAllByRole('heading', { level: 2 }) + .map((heading) => heading.textContent ?? '') + .filter((text) => HEADINGS.includes(text)); +} + +describe('SettingsIndex', () => { + beforeEach(() => { + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 900]); + (useLibraryFolders as jest.Mock).mockReturnValue(loadedState([])); + }); + + test('groups pages, keeping their order inside each group', () => { + renderWithProviders(<SettingsIndex token="t" />); + const downloads = screen.getByRole('region', { name: 'Downloads & library' }); + expect(within(downloads).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([ + '/settings/core', '/settings/library', '/settings/downloading', '/settings/cookies', '/settings/sponsorblock', '/settings/youtube-api', + ]); + }); + + test('every page belongs to a group', () => { + expect(SETTINGS_PAGES.every((page) => ['downloads', 'servers', 'automation', 'system'].includes(page.group))).toBe(true); + }); + + test('the Library folders row counts folders by layout', () => { + (useLibraryFolders as jest.Mock).mockReturnValue(loadedState([f(''), f('Kids'), f('TV', 'tv')])); + renderWithProviders(<SettingsIndex token="t" />); + expect(screen.getByText('3 folders')).toBeInTheDocument(); + expect(screen.getByText('2 Videos folders')).toBeInTheDocument(); + expect(screen.getByText('1 TV folder')).toBeInTheDocument(); + }); + + test('main folder only', () => { + (useLibraryFolders as jest.Mock).mockReturnValue(loadedState([f('')])); + renderWithProviders(<SettingsIndex token="t" />); + expect(screen.getByText('Main folder only')).toBeInTheDocument(); + }); + + test('the Library folders row shows no counts before the folders have loaded', () => { + (useLibraryFolders as jest.Mock).mockReturnValue({ folders: [], loading: false, loaded: false, error: null }); + renderWithProviders(<SettingsIndex token="t" />); + expect(screen.queryByText(/^\d+ (Videos |TV )?folders?$/)).not.toBeInTheDocument(); + expect(screen.queryByText('Main folder only')).not.toBeInTheDocument(); + }); + + test('puts the groups in two columns at 720px and wider', () => { + renderWithProviders(<SettingsIndex token="t" />); + expect(headingOrder()).toEqual(['Downloads & library', 'Automation & storage', 'Media servers', 'System', 'Setting up your library']); + }); + + test('stacks the groups in one column below 720px', () => { + (useContainerWidth as jest.Mock).mockReturnValue([jest.fn(), 600]); + renderWithProviders(<SettingsIndex token="t" />); + expect(headingOrder()).toEqual(['Downloads & library', 'Media servers', 'Automation & storage', 'System', 'Setting up your library']); + }); + + test('the help card links to the Library folders page with a phone-sized target', () => { + renderWithProviders(<SettingsIndex token="t" />); + const link = screen.getByRole('link', { name: /Open Library folders/ }); + expect(link).toHaveAttribute('href', '/settings/library'); + expect(link).toHaveClass('max-md:min-h-[44px]'); + }); +}); diff --git a/client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts b/client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts deleted file mode 100644 index 68b06e9ba..000000000 --- a/client/src/components/Settings/__tests__/defaultSubfolderMove.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { DEFAULT_CONFIG } from '../../../config/configSchema'; -import { defaultSubfolderMoveNotice } from '../defaultSubfolderMove'; - -describe('defaultSubfolderMoveNotice', () => { - const saved = { ...DEFAULT_CONFIG, defaultSubfolder: 'Videos' }; - - test('asks to save again when another setting is unsaved', () => { - const form = { ...saved, defaultSubfolder: 'TV', defaultSkipVideoFolder: !saved.defaultSkipVideoFolder }; - expect(defaultSubfolderMoveNotice(form, saved, 'TV')).toBe( - 'The default subfolder changed. Save again to apply your other changes.' - ); - }); - - test('says nothing when the default subfolder was the only change', () => { - const form = { ...saved, defaultSubfolder: 'TV' }; - expect(defaultSubfolderMoveNotice(form, saved, 'TV')).toBeNull(); - }); -}); diff --git a/client/src/components/Settings/defaultSubfolderMove.ts b/client/src/components/Settings/defaultSubfolderMove.ts deleted file mode 100644 index d0d83f433..000000000 --- a/client/src/components/Settings/defaultSubfolderMove.ts +++ /dev/null @@ -1,15 +0,0 @@ -import isEqual from 'lodash/isEqual'; -import { ConfigState, TRACKABLE_CONFIG_KEYS } from '../../config/configSchema'; - -const SAVE_AGAIN_NOTICE = 'The default subfolder changed. Save again to apply your other changes.'; - -/** - * What to tell the user once a move applied the default subfolder change: - * the rest of the form is saved by saving again, so ask only when it holds - * other unsaved changes. - */ -export function defaultSubfolderMoveNotice(form: ConfigState, saved: ConfigState, movedTo: string): string | null { - const savedAfterMove = { ...saved, defaultSubfolder: movedTo }; - const otherChanges = TRACKABLE_CONFIG_KEYS.some((key) => !isEqual(form[key], savedAfterMove[key])); - return otherChanges ? SAVE_AGAIN_NOTICE : null; -} diff --git a/client/src/components/SubscriptionImport/__tests__/RowSettingsPopover.test.tsx b/client/src/components/SubscriptionImport/__tests__/RowSettingsPopover.test.tsx index 3d36eeb2c..b7f75726f 100644 --- a/client/src/components/SubscriptionImport/__tests__/RowSettingsPopover.test.tsx +++ b/client/src/components/SubscriptionImport/__tests__/RowSettingsPopover.test.tsx @@ -1,9 +1,13 @@ import React, { useEffect, useRef, useState } from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import RowSettingsPopover from '../components/RowSettingsPopover'; import { RowState, DEFAULT_ROW_SETTINGS } from '../../../types/subscriptionImport'; import { ImportFlowAction } from '../hooks/useImportFlow'; +const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter }); + function makeRowState(overrides: Partial<RowState> = {}): RowState { return { selected: true, @@ -72,7 +76,7 @@ describe('RowSettingsPopover', () => { }); expect(screen.getByLabelText('Auto-download enabled')).toBeInTheDocument(); expect(screen.getByLabelText('Video Quality')).toBeInTheDocument(); - expect(screen.getByLabelText('Subfolder')).toBeInTheDocument(); + expect(screen.getByLabelText('Library folder')).toBeInTheDocument(); expect(screen.getByLabelText('Content Rating')).toBeInTheDocument(); }); diff --git a/client/src/components/SubscriptionImport/components/RowSettingsPopover.tsx b/client/src/components/SubscriptionImport/components/RowSettingsPopover.tsx index d07e7e975..6379cd630 100644 --- a/client/src/components/SubscriptionImport/components/RowSettingsPopover.tsx +++ b/client/src/components/SubscriptionImport/components/RowSettingsPopover.tsx @@ -93,7 +93,7 @@ const RowSettingsPopover: React.FC<RowSettingsPopoverProps> = ({ subfolders={subfolders} createSubfolder={createSubfolder} defaultSubfolderDisplay={defaultSubfolderDisplay} - label="Subfolder" + label="Library folder" /> <FormControl fullWidth> diff --git a/client/src/components/SubscriptionImport/components/RowSettingsSheet.tsx b/client/src/components/SubscriptionImport/components/RowSettingsSheet.tsx index e4c5f7e91..59663ba73 100644 --- a/client/src/components/SubscriptionImport/components/RowSettingsSheet.tsx +++ b/client/src/components/SubscriptionImport/components/RowSettingsSheet.tsx @@ -89,7 +89,7 @@ const RowSettingsSheet: React.FC<RowSettingsSheetProps> = ({ subfolders={subfolders} createSubfolder={createSubfolder} defaultSubfolderDisplay={defaultSubfolderDisplay} - label="Subfolder" + label="Library folder" /> <FormControl fullWidth> diff --git a/client/src/components/Subscriptions/PlaylistHelpDialog.tsx b/client/src/components/Subscriptions/PlaylistHelpDialog.tsx index 1f468eaed..d630495e3 100644 --- a/client/src/components/Subscriptions/PlaylistHelpDialog.tsx +++ b/client/src/components/Subscriptions/PlaylistHelpDialog.tsx @@ -85,10 +85,10 @@ function PlaylistHelpDialog({ open, onClose, isMobile }: PlaylistHelpDialogProps <ul className="list-disc pl-5 flex flex-col gap-1"> <li> If you're already subscribed to that channel, the video uses that channel's - subfolder and quality settings. + library folder and quality settings. </li> <li> - If you're not, the video is saved in the playlist's default subfolder (your + If you're not, the video is saved in the playlist's default folder (your global default, unless you've changed it). </li> </ul> diff --git a/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx b/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx index 50d740fe6..711c7bab0 100644 --- a/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx +++ b/client/src/components/Subscriptions/components/AddChannelSettingsDialog.tsx @@ -132,7 +132,7 @@ const AddChannelSettingsDialog: React.FC<AddChannelSettingsDialogProps> = ({ onChange={(patch) => setSettings({ ...settings, ...patch })} globalQuality={config.preferredResolution || '1080'} defaultSubfolder={config.defaultSubfolder || null} - subfolderLabel="Subfolder" + subfolderLabel="Library folder" subfolderHelperText="Choose where this channel's videos are saved" showTvShowCaption /> diff --git a/client/src/components/Subscriptions/components/NewPlaylistSettings.tsx b/client/src/components/Subscriptions/components/NewPlaylistSettings.tsx index e57675024..197d9cb02 100644 --- a/client/src/components/Subscriptions/components/NewPlaylistSettings.tsx +++ b/client/src/components/Subscriptions/components/NewPlaylistSettings.tsx @@ -56,8 +56,8 @@ const NewPlaylistSettings: React.FC<NewPlaylistSettingsProps> = ({ token, values onChange={onChange} globalQuality={config.preferredResolution || '1080'} defaultSubfolder={config.defaultSubfolder || null} - subfolderLabel="Default Subfolder" - subfolderHelperText="Where this playlist's videos are saved when the channel has no subfolder of its own." + subfolderLabel="Library folder" + subfolderHelperText="Where this playlist's videos are saved when the channel has no library folder of its own." mp3OnlyHint={MP3_ONLY_SYNC_HINT} disabled={readOnly} /> diff --git a/client/src/components/shared/AddSubfolderDialog.tsx b/client/src/components/shared/AddSubfolderDialog.tsx index 563005ffd..bd55e3eed 100644 --- a/client/src/components/shared/AddSubfolderDialog.tsx +++ b/client/src/components/shared/AddSubfolderDialog.tsx @@ -1,4 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { LIBRARY_FOLDERS_PATH } from '../../utils/libraryLayouts'; import { Dialog, DialogTitle, @@ -19,8 +21,8 @@ interface AddSubfolderDialogProps { } /** - * Dialog for adding a new subfolder name. - * Validates input and returns the cleaned subfolder name. + * Dialog for adding a new library folder name. + * Validates input and returns the cleaned library folder name. */ export function AddSubfolderDialog({ open, @@ -46,7 +48,7 @@ export function AddSubfolderDialog({ // Check empty if (!cleaned) { - return { isValid: false, cleanedValue: '', error: 'Subfolder name cannot be empty' }; + return { isValid: false, cleanedValue: '', error: 'Library folder name cannot be empty' }; } // Reserved prefix @@ -54,7 +56,7 @@ export function AddSubfolderDialog({ return { isValid: false, cleanedValue: cleaned, - error: 'Subfolder names cannot start with __ (reserved prefix)', + error: 'Library folder names cannot start with __ (reserved prefix)', }; } @@ -72,7 +74,7 @@ export function AddSubfolderDialog({ return { isValid: false, cleanedValue: cleaned, - error: 'Subfolder name can only contain letters, numbers, spaces, hyphens, and underscores', + error: 'Library folder name can only contain letters, numbers, spaces, hyphens, and underscores', }; } @@ -81,7 +83,7 @@ export function AddSubfolderDialog({ return { isValid: false, cleanedValue: cleaned, - error: 'Invalid subfolder name', + error: 'Invalid library folder name', }; } @@ -96,7 +98,7 @@ export function AddSubfolderDialog({ return { isValid: false, cleanedValue: cleaned, - error: 'A subfolder with this name already exists', + error: 'A library folder with this name already exists', }; } @@ -134,18 +136,18 @@ export function AddSubfolderDialog({ return ( <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> - <DialogTitle>Add New Subfolder</DialogTitle> + <DialogTitle>Add library folder</DialogTitle> <DialogContent> <TextField autoFocus fullWidth - label="Subfolder Name" + label="Library folder name" value={inputValue} onChange={handleInputChange} onKeyDown={handleKeyDown} error={!!validationError} helperText={ - validationError || 'Enter a name for the new subfolder (e.g., Sports, Music)' + validationError || 'Enter a name for the new library folder (e.g., Sports, Music)' } InputLabelProps={{ shrink: true }} inputProps={{ @@ -153,11 +155,24 @@ export function AddSubfolderDialog({ }} style={{ marginTop: 8 }} /> + <p className="mt-2 text-xs text-muted-foreground"> + Creates a Videos folder. To make a TV show folder, use{' '} + <RouterLink + to={LIBRARY_FOLDERS_PATH} + target="_blank" + rel="noopener noreferrer" + className="text-primary underline max-md:inline-flex max-md:min-h-[44px] max-md:items-center" + > + Settings > Library folders + <span className="sr-only"> (opens in a new tab)</span> + </RouterLink> + . + </p> </DialogContent> <DialogActions> <Button onClick={onClose}>Cancel</Button> <Button onClick={handleAdd} variant="contained" disabled={!isValid}> - Add Subfolder + Add library folder </Button> </DialogActions> </Dialog> diff --git a/client/src/components/shared/ManageSubfoldersDialog.tsx b/client/src/components/shared/ManageSubfoldersDialog.tsx deleted file mode 100644 index 2912b208a..000000000 --- a/client/src/components/shared/ManageSubfoldersDialog.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useState } from 'react'; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Chip, - Typography, -} from '../ui'; -import { Trash2 as DeleteIcon } from '../../lib/icons'; -import { useSubfolders } from '../../hooks/useSubfolders'; -import { useSubfolderUsage, SubfolderUsage } from '../../hooks/useSubfolderUsage'; - -interface ManageSubfoldersDialogProps { - open: boolean; - onClose: () => void; - token: string | null; -} - -type ChipColor = 'default' | 'primary' | 'info' | 'warning'; - -function usageChips(usage: SubfolderUsage): Array<{ label: string; color: ChipColor }> { - const chips: Array<{ label: string; color: ChipColor }> = []; - if (usage.isDefault) chips.push({ label: 'Default', color: 'primary' }); - if (usage.plexMapped) chips.push({ label: 'Plex library', color: 'info' }); - if (usage.channels > 0) { - chips.push({ label: `${usage.channels} channel${usage.channels === 1 ? '' : 's'}`, color: 'default' }); - } - if (usage.playlists > 0) { - chips.push({ label: `${usage.playlists} playlist${usage.playlists === 1 ? '' : 's'}`, color: 'default' }); - } - if (usage.hasFiles) chips.push({ label: 'Has videos', color: 'warning' }); - return chips; -} - -export function ManageSubfoldersDialog({ open, onClose, token }: ManageSubfoldersDialogProps) { - // Only fetch while the dialog is open (the usage list does on-disk checks). - const activeToken = open ? token : null; - const { items, loading } = useSubfolderUsage(activeToken); - const { deleteSubfolder } = useSubfolders(activeToken); - const [errorByName, setErrorByName] = useState<Record<string, string>>({}); - const [busy, setBusy] = useState<string | null>(null); - - const handleDelete = async (name: string) => { - setBusy(name); - setErrorByName((prev) => ({ ...prev, [name]: '' })); - try { - await deleteSubfolder(name); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to delete subfolder'; - setErrorByName((prev) => ({ ...prev, [name]: message })); - } finally { - setBusy(null); - } - }; - - return ( - <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> - <DialogTitle>Manage Subfolders</DialogTitle> - <DialogContent> - {loading && items.length === 0 && <Typography variant="body2">Loading...</Typography>} - {!loading && items.length === 0 && ( - <Typography variant="body2" className="text-muted-foreground"> - No subfolders yet. - </Typography> - )} - <ul className="m-0 list-none p-0"> - {items.map((item) => { - const chips = usageChips(item.usage); - return ( - <li key={item.displayName} className="flex flex-col gap-1 border-b border-border py-2"> - <div className="flex items-center justify-between gap-2"> - <div className="flex min-w-0 flex-col gap-1"> - <Typography component="span" variant="body2"> - {item.displayName} - </Typography> - <div className="flex flex-wrap items-center gap-1"> - {chips.length === 0 ? ( - <Typography variant="caption" className="text-muted-foreground"> - Unused - </Typography> - ) : ( - chips.map((chip) => ( - <Chip key={chip.label} label={chip.label} size="small" variant="outlined" color={chip.color} /> - )) - )} - </div> - </div> - <Button - variant="text" - aria-label={item.deletable ? `Delete ${item.displayName}` : `${item.displayName} is in use`} - disabled={!item.deletable || busy === item.name} - onClick={() => handleDelete(item.name)} - > - <DeleteIcon size={16} /> - </Button> - </div> - {errorByName[item.name] && ( - <Typography variant="caption" className="text-destructive"> - {errorByName[item.name]} - </Typography> - )} - </li> - ); - })} - </ul> - </DialogContent> - <DialogActions> - <Button onClick={onClose}>Close</Button> - </DialogActions> - </Dialog> - ); -} - -export default ManageSubfoldersDialog; diff --git a/client/src/components/shared/SubfolderAutocomplete.tsx b/client/src/components/shared/SubfolderAutocomplete.tsx index 45801cc28..9f708411c 100644 --- a/client/src/components/shared/SubfolderAutocomplete.tsx +++ b/client/src/components/shared/SubfolderAutocomplete.tsx @@ -13,9 +13,10 @@ import { isExplicitlyNoSubfolder, isExplicitlyRoot, } from '../../utils/channelHelpers'; +import { Link as RouterLink } from 'react-router-dom'; import { AddSubfolderDialog } from './AddSubfolderDialog'; import { addSubfolderPrefix, stripSubfolderPrefix } from '../../utils/subfolderDisplay'; -import type { LayoutResolver } from '../../utils/libraryLayouts'; +import { LIBRARY_FOLDERS_PATH, type LayoutResolver } from '../../utils/libraryLayouts'; /** * Represents an option in the subfolder autocomplete @@ -28,13 +29,13 @@ interface SubfolderOption { group: 'special' | 'subfolders' | 'actions'; } -type SubfolderMode = 'global' | 'channel' | 'download'; +type SubfolderMode = 'channel' | 'download'; interface SubfolderAutocompleteProps { /** Current value (clean, without __ prefix). null = root (backwards compat), ##USE_GLOBAL_DEFAULT## = use default */ value: string | null | undefined; - /** Callback when value changes. meta.isNewlyCreated is true when the value came from the Add Subfolder dialog */ - onChange: (value: string | null, meta?: { isNewlyCreated?: boolean }) => void; + /** Callback when value changes */ + onChange: (value: string | null) => void; /** List of existing subfolders (with __ prefix from API) */ subfolders: string[]; /** Global default subfolder for display purposes (without __ prefix) */ @@ -51,8 +52,6 @@ interface SubfolderAutocompleteProps { label?: string; /** Optional callback to persist a newly added subfolder (e.g. via API) */ createSubfolder?: (name: string) => Promise<void>; - /** Whether to render the inline "Add Subfolder" action (default true). Set false when the parent provides its own add flow */ - showAddAction?: boolean; /** Folder layouts ('' = main folder): TV folders are labelled "(TV)" */ layoutOf?: LayoutResolver; } @@ -60,10 +59,9 @@ interface SubfolderAutocompleteProps { const ADD_NEW_SENTINEL = '__ADD_NEW__'; /** - * Reusable subfolder autocomplete component - * Supports three modes: - * - 'global': For CoreSettingsSection - shows "No Subfolder (root)" + existing subfolders + Add - * - 'channel': For channel settings - adds "Default Subfolder" and "No Subfolder" special options + Add + * Reusable library folder picker + * Supports two modes: + * - 'channel': For channel settings - adds "Default folder" and "Main folder" special options + Add * - 'download': For manual downloads - adds "No override" option plus channel options + Add */ export function SubfolderAutocomplete({ @@ -75,12 +73,11 @@ export function SubfolderAutocomplete({ disabled = false, loading = false, helperText, - label = 'Subfolder', + label = 'Library folder', createSubfolder, - showAddAction = true, layoutOf, }: SubfolderAutocompleteProps) { - // State for the Add Subfolder dialog + // State for the Add library folder dialog const [addDialogOpen, setAddDialogOpen] = useState(false); // Pending flag: true → open the dialog on the next effect flush (avoids Radix layer conflicts) const [pendingAddDialog, setPendingAddDialog] = useState(false); @@ -111,29 +108,20 @@ export function SubfolderAutocomplete({ const tvTag = (libraryFolder: string) => (layoutOf && layoutOf(libraryFolder) === 'tv' ? ' (TV)' : ''); // Add special options based on mode - if (mode === 'global') { - // "No Subfolder" option - maps to null (root directory) - opts.push({ - label: `No Subfolder (root)${tvTag('')}`, - value: null, - isSpecial: true, - isAddNew: false, - group: 'special', - }); - } else if (mode === 'channel') { - // "No Subfolder" option - maps to null (backwards compatible root) + if (mode === 'channel') { + // "Main folder" option - maps to null (backwards compatible root) opts.push({ - label: `No Subfolder (root)${tvTag('')}`, + label: `Main folder${tvTag('')}`, value: null, isSpecial: true, isAddNew: false, group: 'special', }); - // "Default Subfolder" option - maps to ##USE_GLOBAL_DEFAULT## + // "Default folder" option - maps to ##USE_GLOBAL_DEFAULT## const defaultLabel = (defaultSubfolderDisplay - ? `Default Subfolder (__${defaultSubfolderDisplay})` - : 'Default Subfolder (root)') + tvTag(defaultSubfolderDisplay || ''); + ? `Default folder (__${defaultSubfolderDisplay})` + : 'Default folder (main folder)') + tvTag(defaultSubfolderDisplay || ''); opts.push({ label: defaultLabel, value: GLOBAL_DEFAULT_SENTINEL, @@ -151,18 +139,18 @@ export function SubfolderAutocomplete({ group: 'special', }); - // "Root directory" option - explicitly download to root (no subfolder) + // "Main folder" option - explicitly download to the main folder opts.push({ - label: `Root directory (no subfolder)${tvTag('')}`, + label: `Main folder${tvTag('')}`, value: ROOT_SENTINEL, isSpecial: true, isAddNew: false, group: 'special', }); - // "Use Global Default" option - uses global default subfolder + // "Use the default folder" option - uses the default library folder opts.push({ - label: `Use Global Default Subfolder${tvTag(defaultSubfolderDisplay || '')}`, + label: `Use the default folder${tvTag(defaultSubfolderDisplay || '')}`, value: GLOBAL_DEFAULT_SENTINEL, isSpecial: true, isAddNew: false, @@ -183,7 +171,7 @@ export function SubfolderAutocomplete({ }); }); - // Note: "Add Subfolder" is rendered as a button BELOW the Select, not as a dropdown option. + // Note: "Add library folder" is rendered as a button BELOW the Select, not as a dropdown option. // This makes it reliably clickable in tests without depending on Radix Select portal events. return opts; @@ -191,24 +179,6 @@ export function SubfolderAutocomplete({ // Find the current option based on value const currentOption = useMemo((): SubfolderOption | null => { - if (mode === 'global') { - // For global mode, null/empty means root - if (!value) { - return options.find((o) => o.value === null && o.isSpecial) || null; - } - // Find existing subfolder option - const existingOption = options.find((o) => o.value === value && !o.isAddNew); - if (existingOption) return existingOption; - // Custom value (shouldn't happen without freeSolo, but handle gracefully) - return { - label: `__${value}`, - value: value as string, - isSpecial: false, - isAddNew: false, - group: 'subfolders', - }; - } - if (mode === 'channel') { if (isExplicitlyNoSubfolder(value)) { // null/empty = root (backwards compatible) @@ -271,11 +241,11 @@ export function SubfolderAutocomplete({ onChange(val); }; - // Handle new subfolder addition from dialog + // Handle new library folder addition from dialog const handleAddSubfolder = (newName: string) => { // Optimistically show it immediately. setLocalSubfolders((prev) => [...prev, addSubfolderPrefix(newName)]); - onChange(newName, { isNewlyCreated: true }); + onChange(newName); setAddDialogOpen(false); // Persist so it survives navigation and is reusable everywhere. if (createSubfolder) { @@ -395,13 +365,12 @@ export function SubfolderAutocomplete({ {helperText} </Typography> )} - {/* "Add Subfolder" lives outside the Radix portal so it sits inside the + {/* "Add library folder" lives outside the Radix portal so it sits inside the Dialog's DOM subtree and keeps pointer-events: auto even when a parent Radix Dialog has set body pointer-events to none. */} - {showAddAction && ( - <button + <button type="button" - aria-label="Open add subfolder dialog" + aria-label="Open add library folder dialog" onClick={() => { setIsOpen(false); setPendingAddDialog(true); }} style={{ marginTop: 4, @@ -419,18 +388,24 @@ export function SubfolderAutocomplete({ }} > <AddIcon size={14} style={{ color: 'var(--primary)' }} /> - Add Subfolder + Add library folder </button> - )} + <RouterLink + to={LIBRARY_FOLDERS_PATH} + target="_blank" + rel="noopener noreferrer" + className="mt-1 block text-[0.8rem] text-primary underline max-md:inline-flex max-md:min-h-[44px] max-md:items-center" + > + Manage library folders + <span className="sr-only"> (opens in a new tab)</span> + </RouterLink> </div> - {showAddAction && ( - <AddSubfolderDialog - open={addDialogOpen} - onClose={() => setAddDialogOpen(false)} - onAdd={handleAddSubfolder} - existingSubfolders={allSubfolders} - /> - )} + <AddSubfolderDialog + open={addDialogOpen} + onClose={() => setAddDialogOpen(false)} + onAdd={handleAddSubfolder} + existingSubfolders={allSubfolders} + /> </> ); } diff --git a/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx b/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx index 1dab7799e..51cd67020 100644 --- a/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx +++ b/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx @@ -1,3 +1,5 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from 'storybook/test'; import AddSubfolderDialog from '../AddSubfolderDialog'; @@ -5,6 +7,7 @@ import AddSubfolderDialog from '../AddSubfolderDialog'; const meta: Meta<typeof AddSubfolderDialog> = { title: 'Atomic/Shared/AddSubfolderDialog', component: AddSubfolderDialog, + decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>], args: { open: true, onClose: fn(), @@ -28,7 +31,7 @@ export const ValidatesAndSubmits: Story = { play: async ({ canvasElement, args }) => { const body = within(canvasElement.ownerDocument.body); - const input = body.getByLabelText(/subfolder name/i); + const input = body.getByLabelText(/library folder name/i); // Invalid: reserved prefix await userEvent.clear(input); diff --git a/client/src/components/shared/__tests__/AddSubfolderDialog.test.tsx b/client/src/components/shared/__tests__/AddSubfolderDialog.test.tsx index 6638dd80a..6d73ab578 100644 --- a/client/src/components/shared/__tests__/AddSubfolderDialog.test.tsx +++ b/client/src/components/shared/__tests__/AddSubfolderDialog.test.tsx @@ -1,8 +1,12 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import '@testing-library/jest-dom'; import AddSubfolderDialog from '../AddSubfolderDialog'; +const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter }); + describe('AddSubfolderDialog', () => { const mockOnClose = jest.fn(); const mockOnAdd = jest.fn(); @@ -22,30 +26,35 @@ describe('AddSubfolderDialog', () => { test('renders dialog when open is true', () => { render(<AddSubfolderDialog {...defaultProps} />); - expect(screen.getByText('Add New Subfolder')).toBeInTheDocument(); - expect(screen.getByLabelText('Subfolder Name')).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Add library folder' })).toBeInTheDocument(); + expect(screen.getByLabelText('Library folder name')).toBeInTheDocument(); + expect(screen.getByText(/Creates a Videos folder\. To make a TV show folder, use/)).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'Settings > Library folders (opens in a new tab)' }); + expect(link).toHaveAttribute('href', '/settings/library'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeInTheDocument(); }); test('does not render dialog when open is false', () => { render(<AddSubfolderDialog {...defaultProps} open={false} />); - expect(screen.queryByText('Add New Subfolder')).not.toBeInTheDocument(); + expect(screen.queryByText('Add library folder')).not.toBeInTheDocument(); }); test('renders helper text when input is empty', () => { render(<AddSubfolderDialog {...defaultProps} />); expect( - screen.getByText('Enter a name for the new subfolder (e.g., Sports, Music)') + screen.getByText('Enter a name for the new library folder (e.g., Sports, Music)') ).toBeInTheDocument(); }); test('Add button is disabled when input is empty', () => { render(<AddSubfolderDialog {...defaultProps} />); - const addButton = screen.getByRole('button', { name: 'Add Subfolder' }); + const addButton = screen.getByRole('button', { name: 'Add library folder' }); expect(addButton).toBeDisabled(); }); }); @@ -54,71 +63,71 @@ describe('AddSubfolderDialog', () => { test('shows error for whitespace-only input', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: ' ' } }); - expect(screen.getByText('Subfolder name cannot be empty')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeDisabled(); + expect(screen.getByText('Library folder name cannot be empty')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeDisabled(); }); - test('shows error for duplicate subfolder name (case-insensitive)', () => { + test('shows error for duplicate library folder name (case-insensitive)', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'sports' } }); - expect(screen.getByText('A subfolder with this name already exists')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeDisabled(); + expect(screen.getByText('A library folder with this name already exists')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeDisabled(); }); test('shows error for reserved __ prefix', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: '__Sports' } }); - expect(screen.getByText('Subfolder names cannot start with __ (reserved prefix)')).toBeInTheDocument(); + expect(screen.getByText('Library folder names cannot start with __ (reserved prefix)')).toBeInTheDocument(); }); test('shows error for invalid characters', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'Sports/2024' } }); expect( - screen.getByText('Subfolder name can only contain letters, numbers, spaces, hyphens, and underscores') + screen.getByText('Library folder name can only contain letters, numbers, spaces, hyphens, and underscores') ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeDisabled(); }); test('shows error for name exceeding 100 characters', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'a'.repeat(101) } }); expect(screen.getByText('Name cannot exceed 100 characters')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeDisabled(); }); test('allows valid input up to 100 characters', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'a'.repeat(100) } }); expect(screen.queryByText(/cannot exceed/)).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeEnabled(); }); test('enables Add button when valid name entered', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'Gaming' } }); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeEnabled(); }); }); @@ -135,9 +144,9 @@ describe('AddSubfolderDialog', () => { test('calls onAdd with trimmed value when Add clicked', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: ' Gaming ' } }); - fireEvent.click(screen.getByRole('button', { name: 'Add Subfolder' })); + fireEvent.click(screen.getByRole('button', { name: 'Add library folder' })); expect(mockOnAdd).toHaveBeenCalledWith('Gaming'); expect(mockOnClose).not.toHaveBeenCalled(); @@ -146,7 +155,7 @@ describe('AddSubfolderDialog', () => { test('calls onAdd when Enter key pressed with valid input', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'Gaming' } }); fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); @@ -156,7 +165,7 @@ describe('AddSubfolderDialog', () => { test('does not call onAdd when Enter pressed with invalid input', () => { render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: '' } }); fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); @@ -166,14 +175,14 @@ describe('AddSubfolderDialog', () => { test('clears input when dialog reopens', () => { const { rerender } = render(<AddSubfolderDialog {...defaultProps} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'Gaming' } }); // Close and reopen rerender(<AddSubfolderDialog {...defaultProps} open={false} />); rerender(<AddSubfolderDialog {...defaultProps} open={true} />); - const newInput = screen.getByLabelText('Subfolder Name'); + const newInput = screen.getByLabelText('Library folder name'); expect(newInput).toHaveValue(''); }); @@ -191,20 +200,20 @@ describe('AddSubfolderDialog', () => { test('handles empty existingSubfolders array', () => { render(<AddSubfolderDialog {...defaultProps} existingSubfolders={[]} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'Sports' } }); // Should be valid since there are no existing subfolders - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeEnabled(); }); test('handles subfolders without __ prefix in existingSubfolders', () => { render(<AddSubfolderDialog {...defaultProps} existingSubfolders={['Sports', 'Music']} />); - const input = screen.getByLabelText('Subfolder Name'); + const input = screen.getByLabelText('Library folder name'); fireEvent.change(input, { target: { value: 'Sports' } }); - expect(screen.getByText('A subfolder with this name already exists')).toBeInTheDocument(); + expect(screen.getByText('A library folder with this name already exists')).toBeInTheDocument(); }); }); @@ -218,14 +227,14 @@ describe('AddSubfolderDialog', () => { test('input has proper label', () => { render(<AddSubfolderDialog {...defaultProps} />); - expect(screen.getByLabelText('Subfolder Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Library folder name')).toBeInTheDocument(); }); test('buttons have proper accessible names', () => { render(<AddSubfolderDialog {...defaultProps} />); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add Subfolder' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add library folder' })).toBeInTheDocument(); }); }); }); diff --git a/client/src/components/shared/__tests__/ManageSubfoldersDialog.test.tsx b/client/src/components/shared/__tests__/ManageSubfoldersDialog.test.tsx deleted file mode 100644 index f6235f0fa..000000000 --- a/client/src/components/shared/__tests__/ManageSubfoldersDialog.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ManageSubfoldersDialog } from '../ManageSubfoldersDialog'; - -const mockDelete = jest.fn(); -jest.mock('../../../hooks/useSubfolders', () => ({ - useSubfolders: () => ({ - subfolders: [], - loading: false, - error: null, - refetch: jest.fn(), - createSubfolder: jest.fn(), - deleteSubfolder: mockDelete, - }), -})); - -let mockItems: Array<{ - name: string; - displayName: string; - usage: { channels: number; playlists: number; isDefault: boolean; plexMapped: boolean; hasFiles: boolean }; - deletable: boolean; -}>; -jest.mock('../../../hooks/useSubfolderUsage', () => ({ - useSubfolderUsage: () => ({ items: mockItems, loading: false, error: null, refetch: jest.fn() }), -})); - -const usage = (over = {}) => ({ - channels: 0, playlists: 0, isDefault: false, plexMapped: false, hasFiles: false, ...over, -}); - -describe('ManageSubfoldersDialog', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockItems = [ - { name: 'Spare', displayName: '__Spare', usage: usage(), deletable: true }, - { name: 'Music', displayName: '__Music', usage: usage({ channels: 2, hasFiles: true }), deletable: false }, - ]; - }); - - test('lists known subfolders', () => { - render(<ManageSubfoldersDialog open onClose={jest.fn()} token="t" />); - expect(screen.getByText('__Spare')).toBeInTheDocument(); - expect(screen.getByText('__Music')).toBeInTheDocument(); - }); - - test('shows usage chips for an in-use subfolder', () => { - render(<ManageSubfoldersDialog open onClose={jest.fn()} token="t" />); - expect(screen.getByText('2 channels')).toBeInTheDocument(); - expect(screen.getByText('Has videos')).toBeInTheDocument(); - }); - - test('shows Unused for a deletable subfolder', () => { - render(<ManageSubfoldersDialog open onClose={jest.fn()} token="t" />); - expect(screen.getByText('Unused')).toBeInTheDocument(); - }); - - test('deletes a deletable subfolder by its clean name', async () => { - mockDelete.mockResolvedValueOnce(undefined); - render(<ManageSubfoldersDialog open onClose={jest.fn()} token="t" />); - fireEvent.click(screen.getByRole('button', { name: 'Delete __Spare' })); - await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('Spare')); - }); - - test('disables delete for an in-use subfolder', () => { - render(<ManageSubfoldersDialog open onClose={jest.fn()} token="t" />); - expect(screen.getByRole('button', { name: '__Music is in use' })).toBeDisabled(); - }); - - test('shows the server reason when a delete is blocked by a race', async () => { - mockDelete.mockRejectedValueOnce(new Error('Subfolder is in use by 1 channel(s)')); - render(<ManageSubfoldersDialog open onClose={jest.fn()} token="t" />); - fireEvent.click(screen.getByRole('button', { name: 'Delete __Spare' })); - expect(await screen.findByText('Subfolder is in use by 1 channel(s)')).toBeInTheDocument(); - }); -}); diff --git a/client/src/components/shared/__tests__/SubfolderAutocomplete.story.tsx b/client/src/components/shared/__tests__/SubfolderAutocomplete.story.tsx index 47fa1c810..6e6782753 100644 --- a/client/src/components/shared/__tests__/SubfolderAutocomplete.story.tsx +++ b/client/src/components/shared/__tests__/SubfolderAutocomplete.story.tsx @@ -1,3 +1,5 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from 'storybook/test'; import { SubfolderAutocomplete } from '../SubfolderAutocomplete'; @@ -5,6 +7,7 @@ import { SubfolderAutocomplete } from '../SubfolderAutocomplete'; const meta: Meta<typeof SubfolderAutocomplete> = { title: 'Atomic/Shared/SubfolderAutocomplete', component: SubfolderAutocomplete, + decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>], args: { value: null, onChange: fn(), diff --git a/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx b/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx index 9d13ca668..164eb47e5 100644 --- a/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx +++ b/client/src/components/shared/__tests__/SubfolderAutocomplete.test.tsx @@ -1,10 +1,14 @@ import React from 'react'; -import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'; +import { render as rtlRender, screen, within, fireEvent, waitFor } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { SubfolderAutocomplete } from '../SubfolderAutocomplete'; import { GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL } from '../../../utils/channelHelpers'; +const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter }); + // Mock AddSubfolderDialog to simplify testing jest.mock('../AddSubfolderDialog', () => ({ AddSubfolderDialog: function MockAddSubfolderDialog({ @@ -20,7 +24,7 @@ jest.mock('../AddSubfolderDialog', () => ({ const [inputValue, setInputValue] = React.useState(''); if (!open) return null; return React.createElement('div', { 'data-testid': 'add-subfolder-dialog' }, - React.createElement('label', { htmlFor: 'subfolder-name-input' }, 'Subfolder Name'), + React.createElement('label', { htmlFor: 'subfolder-name-input' }, 'Library folder name'), React.createElement('input', { id: 'subfolder-name-input', value: inputValue, @@ -33,7 +37,7 @@ jest.mock('../AddSubfolderDialog', () => ({ React.createElement('button', { 'data-testid': 'dialog-add', onClick: () => onAdd(inputValue || 'NewFolder'), - }, 'Add Subfolder'), + }, 'Add library folder'), ); } })); @@ -46,91 +50,12 @@ describe('SubfolderAutocomplete', () => { jest.clearAllMocks(); }); - describe('Global Mode', () => { - const globalModeProps = { - mode: 'global' as const, - value: null, - onChange: mockOnChange, - subfolders: defaultSubfolders, - }; - - test('renders with correct label', () => { - render(<SubfolderAutocomplete {...globalModeProps} />); - expect(screen.getByLabelText('Subfolder')).toBeInTheDocument(); - }); - - test('renders with custom label', () => { - render(<SubfolderAutocomplete {...globalModeProps} label="Custom Label" />); - expect(screen.getByLabelText('Custom Label')).toBeInTheDocument(); - }); - - test('shows "No Subfolder (root)" special option when dropdown opened', async () => { - const user = userEvent.setup(); - render(<SubfolderAutocomplete {...globalModeProps} />); - - const autocomplete = screen.getByRole('combobox'); - await user.click(autocomplete); - - expect(within(screen.getByRole('listbox')).getByText('No Subfolder (root)')).toBeInTheDocument(); - }); - - test('shows existing subfolders with __ prefix', async () => { - const user = userEvent.setup(); - render(<SubfolderAutocomplete {...globalModeProps} />); - - const autocomplete = screen.getByRole('combobox'); - await user.click(autocomplete); - - expect(screen.getByText('__Sports')).toBeInTheDocument(); - expect(screen.getByText('__Music')).toBeInTheDocument(); - expect(screen.getByText('__Tech')).toBeInTheDocument(); - }); - - test('shows "Add Subfolder" action option', async () => { - const user = userEvent.setup(); - render(<SubfolderAutocomplete {...globalModeProps} />); - - const autocomplete = screen.getByRole('combobox'); - await user.click(autocomplete); - - expect(screen.getByText('Add Subfolder')).toBeInTheDocument(); - }); - - test('displays null value as "No Subfolder (root)"', () => { - render(<SubfolderAutocomplete {...globalModeProps} value={null} />); - - const autocomplete = screen.getByRole('combobox'); - expect(autocomplete).toHaveValue('No Subfolder (root)'); - }); - - test('displays subfolder value with __ prefix', () => { - render(<SubfolderAutocomplete {...globalModeProps} value="Sports" />); - - const autocomplete = screen.getByRole('combobox'); - expect(autocomplete).toHaveValue('__Sports'); - }); - - test('calls onChange with null when "No Subfolder (root)" selected', async () => { - const user = userEvent.setup(); - render(<SubfolderAutocomplete {...globalModeProps} value="Sports" />); - - const autocomplete = screen.getByRole('combobox'); - await user.click(autocomplete); - await user.click(within(screen.getByRole('listbox')).getByText('No Subfolder (root)')); - - expect(mockOnChange).toHaveBeenCalledWith(null); - }); - - test('calls onChange with clean value when subfolder selected', async () => { - const user = userEvent.setup(); - render(<SubfolderAutocomplete {...globalModeProps} />); - - const autocomplete = screen.getByRole('combobox'); - await user.click(autocomplete); - await user.click(screen.getByText('__Sports')); - - expect(mockOnChange).toHaveBeenCalledWith('Sports'); - }); + test('links to Library folders', () => { + render(<SubfolderAutocomplete mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} />); + const link = screen.getByRole('link', { name: 'Manage library folders (opens in a new tab)' }); + expect(link).toHaveAttribute('href', '/settings/library'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); describe('Channel Mode', () => { @@ -141,34 +66,34 @@ describe('SubfolderAutocomplete', () => { subfolders: defaultSubfolders, }; - test('shows "No Subfolder (root)" special option', async () => { + test('shows "Main folder" special option', async () => { const user = userEvent.setup(); render(<SubfolderAutocomplete {...channelModeProps} />); const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - expect(within(screen.getByRole('listbox')).getByText('No Subfolder (root)')).toBeInTheDocument(); + expect(within(screen.getByRole('listbox')).getByText('Main folder')).toBeInTheDocument(); }); - test('shows "Default Subfolder (root)" when no defaultSubfolderDisplay', async () => { + test('shows "Default folder (main folder)" when no defaultSubfolderDisplay', async () => { const user = userEvent.setup(); render(<SubfolderAutocomplete {...channelModeProps} />); const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - expect(screen.getByText('Default Subfolder (root)')).toBeInTheDocument(); + expect(screen.getByText('Default folder (main folder)')).toBeInTheDocument(); }); - test('shows "Default Subfolder (__name)" when defaultSubfolderDisplay provided', async () => { + test('shows "Default folder (__name)" when defaultSubfolderDisplay provided', async () => { const user = userEvent.setup(); render(<SubfolderAutocomplete {...channelModeProps} defaultSubfolderDisplay="Videos" />); const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - expect(screen.getByText('Default Subfolder (__Videos)')).toBeInTheDocument(); + expect(screen.getByText('Default folder (__Videos)')).toBeInTheDocument(); }); test('calls onChange with GLOBAL_DEFAULT_SENTINEL when default option selected', async () => { @@ -177,7 +102,7 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - await user.click(screen.getByText('Default Subfolder (root)')); + await user.click(screen.getByText('Default folder (main folder)')); expect(mockOnChange).toHaveBeenCalledWith(GLOBAL_DEFAULT_SENTINEL); }); @@ -186,7 +111,7 @@ describe('SubfolderAutocomplete', () => { render(<SubfolderAutocomplete {...channelModeProps} value={GLOBAL_DEFAULT_SENTINEL} />); const autocomplete = screen.getByRole('combobox'); - expect(autocomplete).toHaveValue('Default Subfolder (root)'); + expect(autocomplete).toHaveValue('Default folder (main folder)'); }); }); @@ -208,24 +133,24 @@ describe('SubfolderAutocomplete', () => { expect(within(screen.getByRole('listbox')).getByText('No override (use channel settings)')).toBeInTheDocument(); }); - test('shows "Root directory (no subfolder)" option', async () => { + test('shows "Main folder" option', async () => { const user = userEvent.setup(); render(<SubfolderAutocomplete {...downloadModeProps} />); const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - expect(screen.getByText('Root directory (no subfolder)')).toBeInTheDocument(); + expect(screen.getByText('Main folder')).toBeInTheDocument(); }); - test('shows "Use Global Default Subfolder" option', async () => { + test('shows "Use the default folder" option', async () => { const user = userEvent.setup(); render(<SubfolderAutocomplete {...downloadModeProps} />); const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - expect(screen.getByText('Use Global Default Subfolder')).toBeInTheDocument(); + expect(screen.getByText('Use the default folder')).toBeInTheDocument(); }); test('calls onChange with ROOT_SENTINEL when root directory selected', async () => { @@ -234,16 +159,16 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - await user.click(screen.getByText('Root directory (no subfolder)')); + await user.click(screen.getByText('Main folder')); expect(mockOnChange).toHaveBeenCalledWith(ROOT_SENTINEL); }); - test('displays ROOT_SENTINEL as "Root directory (no subfolder)"', () => { + test('displays ROOT_SENTINEL as "Main folder"', () => { render(<SubfolderAutocomplete {...downloadModeProps} value={ROOT_SENTINEL} />); const autocomplete = screen.getByRole('combobox'); - expect(autocomplete).toHaveValue('Root directory (no subfolder)'); + expect(autocomplete).toHaveValue('Main folder'); }); test('displays null as "No override (use channel settings)"', () => { @@ -254,12 +179,12 @@ describe('SubfolderAutocomplete', () => { }); }); - describe('Add Subfolder Dialog', () => { - test('opens dialog when "Add Subfolder" clicked', async () => { + describe('Add library folder dialog', () => { + test('opens dialog when "Add library folder" clicked', async () => { const user = userEvent.setup(); render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -268,16 +193,16 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - await user.click(screen.getByText('Add Subfolder')); + await user.click(screen.getByText('Add library folder')); expect(screen.getByTestId('add-subfolder-dialog')).toBeInTheDocument(); }); - test('does not change value when "Add Subfolder" clicked', async () => { + test('does not change value when "Add library folder" clicked', async () => { const user = userEvent.setup(); render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -286,7 +211,7 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - await user.click(screen.getByText('Add Subfolder')); + await user.click(screen.getByText('Add library folder')); expect(mockOnChange).not.toHaveBeenCalled(); }); @@ -295,7 +220,7 @@ describe('SubfolderAutocomplete', () => { const user = userEvent.setup(); render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -304,7 +229,7 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - await user.click(screen.getByText('Add Subfolder')); + await user.click(screen.getByText('Add library folder')); expect(screen.getByTestId('add-subfolder-dialog')).toBeInTheDocument(); @@ -313,11 +238,11 @@ describe('SubfolderAutocomplete', () => { expect(screen.queryByTestId('add-subfolder-dialog')).not.toBeInTheDocument(); }); - test('calls onChange with isNewlyCreated meta and closes dialog when subfolder added', async () => { + test('calls onChange with the new name and closes dialog when library folder added', async () => { const user = userEvent.setup(); render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -326,10 +251,10 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - await user.click(screen.getByText('Add Subfolder')); + await user.click(screen.getByText('Add library folder')); await user.click(screen.getByTestId('dialog-add')); - expect(mockOnChange).toHaveBeenCalledWith('NewFolder', { isNewlyCreated: true }); + expect(mockOnChange).toHaveBeenCalledWith('NewFolder'); expect(screen.queryByTestId('add-subfolder-dialog')).not.toBeInTheDocument(); }); @@ -337,39 +262,25 @@ describe('SubfolderAutocomplete', () => { const user = userEvent.setup(); render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} /> ); - // Add a new subfolder via dialog + // Add a new library folder via dialog const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - await user.click(screen.getByText('Add Subfolder')); + await user.click(screen.getByText('Add library folder')); await user.click(screen.getByTestId('dialog-add')); - // Reopen dropdown and check for the new subfolder + // Reopen dropdown and check for the new library folder await user.click(autocomplete); expect(screen.getByText('__NewFolder')).toBeInTheDocument(); }); - test('hides the Add Subfolder action when showAddAction is false', () => { - render( - <SubfolderAutocomplete - mode="global" - value={null} - onChange={mockOnChange} - subfolders={defaultSubfolders} - showAddAction={false} - /> - ); - - expect(screen.queryByText('Add Subfolder')).not.toBeInTheDocument(); - }); - test('persists a newly added subfolder via createSubfolder when provided', async () => { const onChange = jest.fn(); const createSubfolder = jest.fn().mockResolvedValue(undefined); @@ -383,12 +294,12 @@ describe('SubfolderAutocomplete', () => { /> ); - fireEvent.click(screen.getByText('Add Subfolder')); - fireEvent.change(screen.getByLabelText('Subfolder Name'), { target: { value: 'Sports' } }); - fireEvent.click(screen.getByRole('button', { name: 'Add Subfolder' })); + fireEvent.click(screen.getByText('Add library folder')); + fireEvent.change(screen.getByLabelText('Library folder name'), { target: { value: 'Sports' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add library folder' })); await waitFor(() => expect(createSubfolder).toHaveBeenCalledWith('Sports')); - expect(onChange).toHaveBeenCalledWith('Sports', { isNewlyCreated: true }); + expect(onChange).toHaveBeenCalledWith('Sports'); }); }); @@ -396,7 +307,7 @@ describe('SubfolderAutocomplete', () => { test('disables autocomplete when disabled prop is true', () => { render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -412,7 +323,7 @@ describe('SubfolderAutocomplete', () => { // Verify the component accepts and handles the loading prop correctly render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -428,7 +339,7 @@ describe('SubfolderAutocomplete', () => { test('displays helper text when provided', () => { render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -443,7 +354,7 @@ describe('SubfolderAutocomplete', () => { const user = userEvent.setup(); render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={[]} @@ -453,9 +364,9 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); await user.click(autocomplete); - // Should still show special options and Add Subfolder - expect(within(screen.getByRole('listbox')).getByText('No Subfolder (root)')).toBeInTheDocument(); - expect(screen.getByText('Add Subfolder')).toBeInTheDocument(); + // Should still show special options and Add library folder + expect(within(screen.getByRole('listbox')).getByText('Main folder')).toBeInTheDocument(); + expect(screen.getByText('Add library folder')).toBeInTheDocument(); }); test('handles undefined value', () => { @@ -470,7 +381,7 @@ describe('SubfolderAutocomplete', () => { const autocomplete = screen.getByRole('combobox'); // undefined should be treated as null (no subfolder) - expect(autocomplete).toHaveValue('No Subfolder (root)'); + expect(autocomplete).toHaveValue('Main folder'); }); test('handles custom subfolder value not in list', () => { @@ -478,7 +389,7 @@ describe('SubfolderAutocomplete', () => { // This test just verifies the component doesn't crash render( <SubfolderAutocomplete - mode="global" + mode="channel" value="CustomFolder" onChange={mockOnChange} subfolders={defaultSubfolders} @@ -496,7 +407,7 @@ describe('SubfolderAutocomplete', () => { const user = userEvent.setup(); render( <SubfolderAutocomplete - mode="global" + mode="channel" value={null} onChange={mockOnChange} subfolders={defaultSubfolders} @@ -542,12 +453,12 @@ describe('SubfolderAutocomplete', () => { await user.click(screen.getByRole('combobox')); - expect(within(screen.getByRole('listbox')).getByText('Default Subfolder (__Sports) (TV)')).toBeInTheDocument(); + expect(within(screen.getByRole('listbox')).getByText('Default folder (__Sports) (TV)')).toBeInTheDocument(); }); test('labels the root option when the main folder is TV', () => { render(<SubfolderAutocomplete mode="channel" value={null} onChange={mockOnChange} subfolders={[]} layoutOf={layoutOf} />); - expect(screen.getByRole('combobox')).toHaveValue('No Subfolder (root) (TV)'); + expect(screen.getByRole('combobox')).toHaveValue('Main folder (TV)'); }); test('shows no layout labels without a resolver', async () => { diff --git a/client/src/config/configSchema.ts b/client/src/config/configSchema.ts index 4ed266b2d..3d682068f 100644 --- a/client/src/config/configSchema.ts +++ b/client/src/config/configSchema.ts @@ -36,7 +36,8 @@ export const CONFIG_FIELDS = { // Video settings preferredResolution: { default: '1080', trackChanges: true }, videoCodec: { default: 'default', trackChanges: true }, - defaultSubfolder: { default: '', trackChanges: true }, + // Changed through the Library folders API, never through a Settings save. + defaultSubfolder: { default: '', trackChanges: false }, defaultSkipVideoFolder: { default: false, trackChanges: true }, // Changed through the library folders API, never through a Settings save. mainFolderLayout: { default: 'videos', trackChanges: false }, @@ -48,9 +49,10 @@ export const CONFIG_FIELDS = { // Plex integration plexApiKey: { default: '', trackChanges: true }, plexYoutubeLibraryId: { default: '', trackChanges: true }, + // Changed through the Library folders API, never through a Settings save. plexSubfolderLibraryMappings: { default: [] as Array<{ subfolder: string | null; libraryId: string | null }>, - trackChanges: true, + trackChanges: false, }, plexIP: { default: '', trackChanges: true }, plexPort: { default: '32400', trackChanges: true }, diff --git a/client/src/hooks/__tests__/useConfig.test.ts b/client/src/hooks/__tests__/useConfig.test.ts index 5bece1aed..3bce8f753 100644 --- a/client/src/hooks/__tests__/useConfig.test.ts +++ b/client/src/hooks/__tests__/useConfig.test.ts @@ -69,9 +69,21 @@ describe('useConfig patched from elsewhere', () => { expect(result.current.initialConfig?.preferredResolution).toBe('720'); expect(global.fetch).toHaveBeenCalledTimes(1); }); + + test('takes a default folder change into both copies', async () => { + const { result } = renderHook(() => useConfig('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: { defaultSubfolder: 'TV' } })); + }); + + expect(result.current.config.defaultSubfolder).toBe('TV'); + expect(result.current.initialConfig?.defaultSubfolder).toBe('TV'); + }); }); -describe('useConfig patched while the mappings themselves have unsaved edits', () => { +describe('useConfig patched while the same field has unsaved edits', () => { const originalFetch = global.fetch; const KIDS = { subfolder: 'Kids', libraryId: '12' }; const TV = { subfolder: 'TV', libraryId: '41' }; @@ -87,7 +99,7 @@ describe('useConfig patched while the mappings themselves have unsaved edits', ( global.fetch = originalFetch; }); - test('keeps a pending removal of another folder\'s mapping and still shows the draft as changed', async () => { + test('replaces the draft with the saved value', async () => { const { result } = renderHook(() => useConfig('tok')); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { result.current.setConfig((prev) => ({ ...prev, plexSubfolderLibraryMappings: [] })); }); @@ -96,20 +108,6 @@ describe('useConfig patched while the mappings themselves have unsaved edits', ( window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: { plexSubfolderLibraryMappings: [KIDS, TV] } })); }); - expect(result.current.config.plexSubfolderLibraryMappings).toEqual([TV]); - expect(result.current.initialConfig?.plexSubfolderLibraryMappings).toEqual([KIDS, TV]); - }); - - test('keeps a pending change of another folder\'s mapping', async () => { - const edited = { subfolder: 'Kids', libraryId: '99' }; - const { result } = renderHook(() => useConfig('tok')); - await waitFor(() => expect(result.current.loading).toBe(false)); - act(() => { result.current.setConfig((prev) => ({ ...prev, plexSubfolderLibraryMappings: [edited] })); }); - - act(() => { - window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: { plexSubfolderLibraryMappings: [KIDS, TV] } })); - }); - - expect(result.current.config.plexSubfolderLibraryMappings).toEqual([edited, TV]); + expect(result.current.config.plexSubfolderLibraryMappings).toEqual([KIDS, TV]); }); }); diff --git a/client/src/hooks/__tests__/useSubfolderUsage.test.ts b/client/src/hooks/__tests__/useSubfolderUsage.test.ts deleted file mode 100644 index 1901e02f7..000000000 --- a/client/src/hooks/__tests__/useSubfolderUsage.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { renderHook, waitFor, act } from '@testing-library/react'; - -jest.mock('axios', () => ({ - get: jest.fn(), - isAxiosError: (e: unknown) => Boolean(e && (e as { isAxiosError?: boolean }).isAxiosError), -})); - -const axios = require('axios'); - -import { useSubfolderUsage } from '../useSubfolderUsage'; -import { SUBFOLDERS_UPDATED_EVENT } from '../useSubfolders'; - -const item = { - name: 'Music', - displayName: '__Music', - usage: { channels: 2, playlists: 0, isDefault: false, plexMapped: false, hasFiles: true }, - deletable: false, -}; - -describe('useSubfolderUsage', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('does not fetch when token is null', () => { - renderHook(() => useSubfolderUsage(null)); - expect(axios.get).not.toHaveBeenCalled(); - }); - - test('fetches usage items with the auth header', async () => { - axios.get.mockResolvedValueOnce({ data: [item] }); - - const { result } = renderHook(() => useSubfolderUsage('t')); - - await waitFor(() => expect(result.current.loading).toBe(false)); - - expect(axios.get).toHaveBeenCalledWith('/api/subfolders', { headers: { 'x-access-token': 't' } }); - expect(result.current.items).toEqual([item]); - expect(result.current.error).toBeNull(); - }); - - test('refetches when a subfolder update event fires', async () => { - axios.get.mockResolvedValue({ data: [item] }); - - const { result } = renderHook(() => useSubfolderUsage('t')); - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(axios.get).toHaveBeenCalledTimes(1); - - await act(async () => { - window.dispatchEvent(new Event(SUBFOLDERS_UPDATED_EVENT)); - }); - - await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); - }); - - test('surfaces a fetch error', async () => { - axios.get.mockRejectedValueOnce(new Error('boom')); - - const { result } = renderHook(() => useSubfolderUsage('t')); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.error).toBeInstanceOf(Error); - expect(result.current.items).toEqual([]); - }); -}); diff --git a/client/src/hooks/useConfig.ts b/client/src/hooks/useConfig.ts index a6294589a..5a89a5700 100644 --- a/client/src/hooks/useConfig.ts +++ b/client/src/hooks/useConfig.ts @@ -1,37 +1,15 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { ConfigState, PlatformManagedState, DeploymentEnvironment, LoggingStatus } from '../components/Configuration/types'; import { DEFAULT_CONFIG } from '../config/configSchema'; -import { mergeServerChange } from '../utils/configPatch'; -import { folderKey } from '../utils/libraryLayouts'; export const CONFIG_UPDATED_EVENT = 'config-updated'; /** * A field the server saved outside the Settings form (detail: the saved - * fields). The saved copy takes the field as is; the draft takes only what - * the server changed since that copy, so an open Settings page keeps its - * unsaved edits, including edits to the same list, and its next save carries - * the server's change. + * fields), taken into both the draft and the saved copy. Only untracked + * fields are patched, so the form never turns dirty. */ export const CONFIG_PATCHED_EVENT = 'config-patched'; -type PlexMapping = ConfigState['plexSubfolderLibraryMappings'][number]; -const mappingKey = (mapping: PlexMapping) => folderKey(mapping.subfolder); - -// The draft after a server-side change: keyed lists are merged entry by entry -// against the saved copy they were edited from; other fields are replaced. -function patchDraft(draft: ConfigState, baseline: ConfigState | null, patch: Partial<ConfigState>): ConfigState { - const next = { ...draft, ...patch }; - if (patch.plexSubfolderLibraryMappings) { - next.plexSubfolderLibraryMappings = mergeServerChange( - draft.plexSubfolderLibraryMappings, - baseline?.plexSubfolderLibraryMappings, - patch.plexSubfolderLibraryMappings, - mappingKey - ); - } - return next; -} - interface UseConfigResult { config: ConfigState; initialConfig: ConfigState | null; @@ -48,9 +26,6 @@ interface UseConfigResult { export function useConfig(token: string | null): UseConfigResult { const [config, setConfig] = useState<ConfigState>(DEFAULT_CONFIG); const [initialConfig, setInitialConfig] = useState<ConfigState | null>(null); - // The saved copy as the patch handler sees it (state updaters can't read each other). - const initialConfigRef = useRef<ConfigState | null>(null); - initialConfigRef.current = initialConfig; const [isPlatformManaged, setIsPlatformManaged] = useState<PlatformManagedState>({ plexUrl: false, authEnabled: true, @@ -160,8 +135,7 @@ export function useConfig(token: string | null): UseConfigResult { const handleConfigPatched = (event: Event) => { const patch = (event as CustomEvent<Partial<ConfigState> | undefined>).detail; if (!patch) return; - const baseline = initialConfigRef.current; - setConfig((prev) => patchDraft(prev, baseline, patch)); + setConfig((prev) => ({ ...prev, ...patch })); setInitialConfig((prev) => (prev ? { ...prev, ...patch } : prev)); }; diff --git a/client/src/hooks/useLibraryCheck.ts b/client/src/hooks/useLibraryCheck.ts index 1d632a672..9df6f8102 100644 --- a/client/src/hooks/useLibraryCheck.ts +++ b/client/src/hooks/useLibraryCheck.ts @@ -104,9 +104,8 @@ export function useLibraryCheck( } catch (err: unknown) { throw new Error(serverMessage(err, MAPPING_FAILED_MESSAGE)); } - // The mappings live in the config, which an open Settings page keeps a - // copy of (and saves whole): hand it the saved list rather than reloading - // it over the page's unsaved edits. + // The mappings live in the config, which each open useConfig holds a copy + // of: hand them the saved list (only the Library folders API writes it). const patch: Partial<ConfigState> = { plexSubfolderLibraryMappings: saved.plexSubfolderLibraryMappings }; window.dispatchEvent(new CustomEvent(CONFIG_PATCHED_EVENT, { detail: patch })); // The folder list reads each folder's mapping. This instance checks once itself, awaited diff --git a/client/src/hooks/useSubfolderUsage.ts b/client/src/hooks/useSubfolderUsage.ts deleted file mode 100644 index 6ed94ac0d..000000000 --- a/client/src/hooks/useSubfolderUsage.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import axios from 'axios'; -import { SUBFOLDERS_UPDATED_EVENT } from './useSubfolders'; - -/** Where a subfolder is referenced, mirroring the server-side delete guard. */ -export interface SubfolderUsage { - channels: number; - playlists: number; - isDefault: boolean; - plexMapped: boolean; - hasFiles: boolean; -} - -export interface SubfolderUsageItem { - /** Clean name (no __ prefix) used for delete calls. */ - name: string; - /** __-prefixed name for display. */ - displayName: string; - usage: SubfolderUsage; - deletable: boolean; -} - -interface UseSubfolderUsageResult { - items: SubfolderUsageItem[]; - loading: boolean; - error: Error | null; - refetch: () => Promise<void>; -} - -/** - * Fetch subfolders with their usage breakdown. Re-fetches on the - * subfolder-updated event. - */ -export function useSubfolderUsage(token: string | null): UseSubfolderUsageResult { - const [items, setItems] = useState<SubfolderUsageItem[]>([]); - const [loading, setLoading] = useState<boolean>(false); - const [error, setError] = useState<Error | null>(null); - - const fetchUsage = useCallback(async () => { - if (!token) return; - setLoading(true); - setError(null); - try { - const response = await axios.get<SubfolderUsageItem[]>('/api/subfolders', { - headers: { 'x-access-token': token }, - }); - setItems(Array.isArray(response.data) ? response.data : []); - } catch (err) { - console.error('Failed to fetch subfolder usage:', err); - setError(err instanceof Error ? err : new Error('Unknown error')); - } finally { - setLoading(false); - } - }, [token]); - - useEffect(() => { - fetchUsage(); - }, [fetchUsage]); - - useEffect(() => { - const handler = () => { fetchUsage(); }; - window.addEventListener(SUBFOLDERS_UPDATED_EVENT, handler); - return () => window.removeEventListener(SUBFOLDERS_UPDATED_EVENT, handler); - }, [fetchUsage]); - - return { items, loading, error, refetch: fetchUsage }; -} diff --git a/client/src/index.css b/client/src/index.css index 684e08c76..48b0d54f5 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -349,8 +349,6 @@ body[data-motion='off'] [data-chip], body[data-motion='off'] .pop-toggle, body[data-motion='off'] [data-card], body[data-motion='off'] [data-btn], -body[data-motion='off'] .settings-splash-card, -body[data-motion='off'] .settings-splash-card [data-card-action], body[data-motion='off'] .linear-top-rail::after { animation-duration: 0ms !important; animation-iteration-count: 1 !important; @@ -369,19 +367,6 @@ body[data-motion='on'][data-theme='playful'] .animate-on-mount { animation: pop-in 400ms cubic-bezier(0.34, 1.56, 0.64, 1); } -/* Settings splash card: ensure Playful shows bold gold border and shadow on hover */ -body[data-theme='playful'] .settings-splash-card:hover { - border-color: var(--settings-splash-border) !important; - box-shadow: var(--settings-splash-shadow-hover) !important; -} - -/* Ensure CardActionArea inside the splash card also gets the gold hover and background */ -body[data-theme='playful'] .settings-splash-card [data-card-action]:hover, -body[data-theme='playful'] .settings-splash-card:hover [data-card-action] { - box-shadow: var(--settings-splash-shadow-hover) !important; - background-color: var(--nav-item-bg-hover) !important; -} - body[data-motion='on'][data-theme='playful'] .animate-bounce { animation: bounce 600ms cubic-bezier(0.34, 1.56, 0.64, 1) infinite; } diff --git a/client/src/themeTokens.css b/client/src/themeTokens.css index 9ed1122ae..76e2080a4 100644 --- a/client/src/themeTokens.css +++ b/client/src/themeTokens.css @@ -21,10 +21,6 @@ --rating-chip-max-width: 120px; --ui-chip-radius: var(--video-chip-radius, var(--radius-ui)); - /* Settings splash-specific tokens (override per-theme) */ - --settings-splash-shadow-hover: var(--nav-item-shadow-hover); - --settings-splash-border: var(--nav-item-bg-selected); - /* Audio format indicator tokens */ --audio-chip-radius: var(--radius-ui); --audio-chip-bg: transparent; diff --git a/client/src/themes/playful.ts b/client/src/themes/playful.ts index f92117ee1..94f985447 100644 --- a/client/src/themes/playful.ts +++ b/client/src/themes/playful.ts @@ -371,8 +371,6 @@ export const playfulTheme: ThemeDefinition = { 'channel-meta-chip-icon': 'var(--foreground)', 'rating-chip-border': '2px solid var(--border-strong)', 'rating-chip-shadow': 'var(--shadow-hard)', - 'settings-splash-shadow-hover': '6px 6px 0px 0px #fbbf24', - 'settings-splash-border': '#fbbf24', 'chip-shadow-hover': 'none', 'nav-item-transform': 'translate(0, 0)', 'nav-item-transform-hover': 'translate(-2px, -2px)', @@ -598,8 +596,6 @@ export const playfulTheme: ThemeDefinition = { 'channel-meta-chip-border': '2px solid var(--border-strong)', 'channel-meta-chip-shadow': 'var(--shadow-hard)', 'channel-meta-chip-icon': 'var(--foreground)', - 'settings-splash-shadow-hover': '6px 6px 0px 0px #fbbf24', - 'settings-splash-border': '#fbbf24', 'nav-item-transform': 'translate(0, 0)', 'nav-item-transform-hover': 'translate(-2px, -2px)', 'appbar-border': '2px solid var(--foreground)', diff --git a/client/src/utils/__tests__/configPatch.test.ts b/client/src/utils/__tests__/configPatch.test.ts deleted file mode 100644 index c55d2fcd3..000000000 --- a/client/src/utils/__tests__/configPatch.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mergeServerChange } from '../configPatch'; - -interface Mapping { subfolder: string | null; libraryId: string } - -const keyOf = (mapping: Mapping) => (mapping.subfolder || '').toLowerCase(); -const kids: Mapping = { subfolder: 'Kids', libraryId: '12' }; -const tv: Mapping = { subfolder: 'TV', libraryId: '41' }; -const music: Mapping = { subfolder: 'Music', libraryId: '40' }; - -describe('mergeServerChange', () => { - test('adds what the server added, after the draft\'s own entries', () => { - expect(mergeServerChange([kids], [kids], [kids, tv], keyOf)).toEqual([kids, tv]); - }); - - test('keeps a pending removal while taking the server\'s addition', () => { - // The user removed Kids but has not saved; the server added TV meanwhile. - expect(mergeServerChange([], [kids], [kids, tv], keyOf)).toEqual([tv]); - }); - - test('keeps a pending edit of an entry the server did not touch', () => { - const edited: Mapping = { subfolder: 'Kids', libraryId: '99' }; - expect(mergeServerChange([edited], [kids], [kids, tv], keyOf)).toEqual([edited, tv]); - }); - - test('keeps a pending addition the server does not know yet', () => { - expect(mergeServerChange([kids, music], [kids], [kids, tv], keyOf)).toEqual([kids, music, tv]); - }); - - test('applies a change the server made to an entry', () => { - const moved: Mapping = { subfolder: 'Kids', libraryId: '13' }; - expect(mergeServerChange([kids], [kids], [moved], keyOf)).toEqual([moved]); - }); - - test('applies a removal the server made', () => { - expect(mergeServerChange([kids, tv], [kids, tv], [tv], keyOf)).toEqual([tv]); - }); - - test('matches entries by key, so a re-cased name replaces rather than duplicates', () => { - const lower: Mapping = { subfolder: 'kids', libraryId: '12' }; - expect(mergeServerChange([kids], [kids], [lower, tv], keyOf)).toEqual([lower, tv]); - }); - - test('takes the saved list as is when there is no baseline yet', () => { - expect(mergeServerChange([music], null, [kids, tv], keyOf)).toEqual([kids, tv]); - }); -}); diff --git a/client/src/utils/configPatch.ts b/client/src/utils/configPatch.ts deleted file mode 100644 index fd41c247a..000000000 --- a/client/src/utils/configPatch.ts +++ /dev/null @@ -1,36 +0,0 @@ -import isEqual from 'lodash/isEqual'; - -/** - * Brings a server-side change to a list of keyed entries into an unsaved - * draft of that list without losing the draft's own edits. - * - * `baseline` is the last saved list the draft was edited from and `saved` the - * list the server holds now. Entries the server added or changed since the - * baseline are put into the draft (replacing the draft's entry of that key), - * entries the server removed are taken out, and everything else in the draft, - * including pending additions, removals and edits of other keys, is kept. - * Without a baseline the saved list is taken as is. - */ -export function mergeServerChange<T>( - draft: T[], - baseline: T[] | null | undefined, - saved: T[], - keyOf: (entry: T) => string -): T[] { - if (!baseline) return saved; - const baselineByKey = new Map(baseline.map((entry) => [keyOf(entry), entry])); - const savedKeys = new Set(saved.map(keyOf)); - const changed = new Map(saved - .filter((entry) => !isEqual(baselineByKey.get(keyOf(entry)), entry)) - .map((entry) => [keyOf(entry), entry])); - const removed = new Set([...baselineByKey.keys()].filter((key) => !savedKeys.has(key))); - - const merged = draft - .filter((entry) => !removed.has(keyOf(entry))) - .map((entry) => changed.get(keyOf(entry)) ?? entry); - const present = new Set(merged.map(keyOf)); - for (const [key, entry] of changed) { - if (!present.has(key)) merged.push(entry); - } - return merged; -} diff --git a/server/routes/__tests__/config.test.js b/server/routes/__tests__/config.test.js index e8ab5e10e..a476ba06d 100644 --- a/server/routes/__tests__/config.test.js +++ b/server/routes/__tests__/config.test.js @@ -234,34 +234,30 @@ describe('POST /updateconfig', () => { expect(configModule.updateConfig.mock.calls[0][0].mainFolderLayout).toBe('tv'); }); - test('checks a default subfolder change against folder layouts', async () => { - const libraryFolders = { checkDefaultSubfolderChange: jest.fn().mockResolvedValue(undefined) }; - const jobModule = { getInProgressJobId: jest.fn(() => 'job-1') }; - const { app, configModule } = makeApp({ libraryFolders, jobModule }); + test('keeps the stored default folder and Plex mappings over the posted ones', async () => { + const { app, configModule } = makeApp(); configModule._config.defaultSubfolder = 'Kids'; + configModule._config.plexSubfolderLibraryMappings = [{ subfolder: 'TV', libraryId: '41' }]; - const res = await supertest(app).post('/updateconfig').send({ defaultSubfolder: 'TV' }); + const res = await supertest(app).post('/updateconfig').send({ defaultSubfolder: 'Other', plexSubfolderLibraryMappings: [] }); expect(res.status).toBe(200); - const args = libraryFolders.checkDefaultSubfolderChange.mock.calls[0][0]; - expect(args).toMatchObject({ oldDefault: 'Kids', newDefault: 'TV' }); - expect(args.isDownloadRunning()).toBe(true); + const saved = configModule.updateConfig.mock.calls[0][0]; + expect(saved.defaultSubfolder).toBe('Kids'); + expect(saved.plexSubfolderLibraryMappings).toEqual([{ subfolder: 'TV', libraryId: '41' }]); }); - test('refuses a default subfolder change the layout check rejects', async () => { - const libraryFolders = { - checkDefaultSubfolderChange: jest.fn().mockRejectedValue(refusal('channels have downloads', 409)), - }; - const { app, configModule } = makeApp({ libraryFolders }); + test('does not bring back a deleted Plex mapping from a stale posted list', async () => { + const { app, configModule } = makeApp(); + configModule._config.plexSubfolderLibraryMappings = []; - const res = await supertest(app).post('/updateconfig').send({ defaultSubfolder: 'TV' }); + const res = await supertest(app).post('/updateconfig').send({ plexSubfolderLibraryMappings: [{ subfolder: 'Gone', libraryId: '9' }] }); - expect(res.status).toBe(409); - expect(res.body).toEqual({ error: 'channels have downloads' }); - expect(configModule.updateConfig).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(configModule.updateConfig.mock.calls[0][0].plexSubfolderLibraryMappings).toEqual([]); }); - test('names the reorganize to preview when the default change moves downloads', async () => { + test('never answers with a reorganize for a posted default folder', async () => { const libraryFolders = { checkDefaultSubfolderChange: jest.fn().mockRejectedValue(Object.assign(refusal('Review the move', 409), { reorganizeRequired: true, change: { type: 'defaultSubfolder', value: 'TV' }, @@ -271,9 +267,34 @@ describe('POST /updateconfig', () => { const res = await supertest(app).post('/updateconfig').send({ defaultSubfolder: 'TV' }); - expect(res.status).toBe(409); - expect(res.body).toEqual({ - error: 'Review the move', reorganizeRequired: true, change: { type: 'defaultSubfolder', value: 'TV' }, + expect(res.status).toBe(200); + expect(res.body.reorganizeRequired).toBeUndefined(); + expect(libraryFolders.checkDefaultSubfolderChange).not.toHaveBeenCalled(); + }); + + test('keeps folder settings saved elsewhere while the template check runs', async () => { + const { app, configModule } = makeApp(); + configModule._config.defaultSubfolder = 'Kids'; + configModule._config.plexSubfolderLibraryMappings = []; + filenamePreview.validateTemplate.mockImplementationOnce(async () => { + // Another writer replaces the stored config while yt-dlp checks the template. + configModule._config = { + ...configModule._config, + defaultSubfolder: 'TV', + plexSubfolderLibraryMappings: [{ subfolder: 'TV', libraryId: '41' }], + mainFolderLayout: 'tv', + }; + return { ok: true }; + }); + + const res = await supertest(app).post('/updateconfig').send({ videoFilenamePrefix: '%(title).76B' }); + + expect(res.status).toBe(200); + const saved = configModule.updateConfig.mock.calls[0][0]; + expect(saved).toMatchObject({ + defaultSubfolder: 'TV', + plexSubfolderLibraryMappings: [{ subfolder: 'TV', libraryId: '41' }], + mainFolderLayout: 'tv', }); }); }); diff --git a/server/routes/config.js b/server/routes/config.js index de00a7750..1d97e35af 100644 --- a/server/routes/config.js +++ b/server/routes/config.js @@ -88,16 +88,13 @@ const cookieUpload = multer({ * @param {Function} deps.validateEnvAuthCredentials - Function to validate ENV auth credentials * @param {boolean} deps.isWslEnvironment - Whether running in WSL * @param {Function} deps.getLoggingStatus - Returns LOG_LEVEL and log file status - * @param {Object} [deps.libraryFolders] - Refuses default subfolder changes across library folder layouts - * @param {Object} [deps.jobModule] - Its running job blocks those changes * @returns {express.Router} */ module.exports = function createConfigRoutes({ verifyToken, configModule, validateEnvAuthCredentials, isWslEnvironment, filenamePreviewRateLimiter, - cookieDetails, cookieTest, cookieTestRateLimiter, getLoggingStatus, libraryFolders, jobModule, + cookieDetails, cookieTest, cookieTestRateLimiter, getLoggingStatus, }) { const router = express.Router(); - const isDownloadRunning = () => Boolean(jobModule && jobModule.getInProgressJobId()); /** * @swagger @@ -182,7 +179,9 @@ module.exports = function createConfigRoutes({ * /updateconfig: * post: * summary: Update application configuration - * description: Update the application configuration. Sensitive fields (passwordHash, username) are protected. + * description: | + * Update the application configuration. Sensitive fields (passwordHash, username) are protected. + * defaultSubfolder, plexSubfolderLibraryMappings and mainFolderLayout are kept as stored; change them through PUT /api/library-folders/default, PUT/DELETE /api/library-folders/plex-mapping and PUT /api/library-folders. * tags: [Configuration] * requestBody: * required: true @@ -225,8 +224,6 @@ module.exports = function createConfigRoutes({ * responses: * 400: * description: Invalid configuration; schedule errors include a fieldErrors object keyed by config field - * 409: - * description: A new defaultSubfolder has a different layout (videos or TV) and the channels using the default have downloaded videos (reorganizeRequired, with the change to preview through /api/tv/reorganize/preview), a download or a reorganize is running, or they download MP3. mainFolderLayout is never changed here (see PUT /api/library-folders). * 200: * description: Configuration updated successfully * content: @@ -351,23 +348,6 @@ module.exports = function createConfigRoutes({ updateData.videoFilenamePrefix = basic.trimmed; } - // Moving the default subfolder to a folder with another layout switches - // every channel on the default between Videos and TV. - if (libraryFolders && Object.prototype.hasOwnProperty.call(updateData, 'defaultSubfolder')) { - try { - await libraryFolders.checkDefaultSubfolderChange({ - oldDefault: configModule.getDefaultSubfolder(), - newDefault: typeof updateData.defaultSubfolder === 'string' ? updateData.defaultSubfolder : null, - isDownloadRunning, - }); - } catch (error) { - if (!error.status) throw error; - const body = { error: error.message }; - if (error.reorganizeRequired) Object.assign(body, { reorganizeRequired: true, change: error.change }); - return res.status(error.status).json(body); - } - } - delete updateData.passwordHash; delete updateData.username; @@ -377,8 +357,13 @@ module.exports = function createConfigRoutes({ updateData.ytdlpLastUpdated = currentConfig.ytdlpLastUpdated; updateData.ytdlpLastResult = currentConfig.ytdlpLastResult; updateData.rescanLastRun = currentConfig.rescanLastRun ?? null; - // Owned by the library folders API; a Settings save sends a stale copy. - updateData.mainFolderLayout = currentConfig.mainFolderLayout || 'videos'; + // Owned by the Library folders API; a Settings save sends stale copies. + // Read again here: the template check above can wait on yt-dlp while + // that API saves. + const storedConfig = configModule.getConfig(); + updateData.mainFolderLayout = storedConfig.mainFolderLayout || 'videos'; + updateData.defaultSubfolder = storedConfig.defaultSubfolder; + updateData.plexSubfolderLibraryMappings = storedConfig.plexSubfolderLibraryMappings; configModule.updateConfig(updateData); diff --git a/server/routes/index.js b/server/routes/index.js index 66261a410..12d505f4d 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -104,7 +104,6 @@ function registerRoutes(app, deps) { app.use(createConfigRoutes({ verifyToken, configModule, validateEnvAuthCredentials, isWslEnvironment, filenamePreviewRateLimiter, cookieDetails, cookieTest, cookieTestRateLimiter, getLoggingStatus: logger.getLoggingStatus, - libraryFolders, jobModule, })); // Channel routes From a1fd30a5a9702b00c56f89aaf8b30f38f6951f25 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Thu, 8 Oct 2026 14:14:46 -0700 Subject: [PATCH 12/12] docs: document library folders and TV migration Add "Move an existing setup to TV shows" to the usage guide: which library setups work on Plex, Jellyfin and Emby, the three ways to start using TV show folders, the library changes each one needs, and what happens to watch state, including which moves get it restored and when to change libraries so the restore isn't lost. Point every reference to the old Core folder list, the Plex mapping table and the library check at Settings > Library folders, rename the Subfolder field to Library folder, and use Core's new section names. Document the explicit default Plex library choice and the folder settings /updateconfig now keeps as stored. Update CLAUDE.md for the Library folders page, its endpoints and the removed Core folder UI. The Start using TV shows panel offered the whole downloads folder as TV on Jellyfin and Emby while a TV show folder was in use, a setup the library check reports as a conflict. It now counts every folder in use, not just Video folders. Also update the AddSubfolderDialog story's queries for the renamed submit button. Refs: #175 --- CLAUDE.md | 21 +-- .../__tests__/tvSetupPaths.test.ts | 21 ++- .../__tests__/StartTvShowsDialog.test.tsx | 2 +- .../components/LibraryFolders/tvSetupPaths.ts | 9 +- .../__tests__/AddSubfolderDialog.story.tsx | 8 +- docs/CONFIG.md | 43 +++--- docs/DATABASE.md | 2 +- docs/DOCKER.md | 2 +- docs/MEDIA_SERVERS.md | 28 ++-- docs/TROUBLESHOOTING.md | 14 +- docs/USAGE_GUIDE.md | 137 +++++++++++++++--- docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md | 30 ++-- docs/development/ELFHOSTED.md | 100 ++++++------- docs/media-servers/emby.md | 26 ++-- docs/media-servers/jellyfin.md | 22 +-- docs/media-servers/kodi.md | 2 +- docs/media-servers/plex.md | 14 +- docs/platforms/synology.md | 4 +- 18 files changed, 310 insertions(+), 175 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e8d3183e1..3cbdce80e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `logger.js` + `logging/`: two transport targets pinned to `trace`, so the root level is the only filter and `logger.applyLevelSetting` can change it live. Only the server process (`process.argv[1]` is `server.js`; the yt-dlp post-processor, scripts, and tests never write files, since post-processor output already reaches the server log through yt-dlp's stdout) also writes the console format, without colors or embedded ANSI codes, to `config/logs/youtarr.<n>.log` through `logging/logFileTransport.js` (pino-roll with `removeOtherLogFiles`, so files from earlier runs are pruned too; `LOG_FILE_MAX_SIZE` default 10MB, `LOG_FILE_MAX_COUNT` older files kept, default 5). A file error prints one stderr notice and stops file output only; an unwritable folder is detected at startup and reported through `logger.getLoggingStatus()` (served as `logging` on `/getconfig`). Never log a field named `level`: it overwrites pino's level and the record is dropped. `modules/logLevelSync.js` applies the `logLevel` setting ('' = `LOG_LEVEL`) at startup, on config change, and in the post-processor. `modules/logFilesModule.js` + `routes/logs.js` serve `GET /api/logs/download` (all files oldest first, numeric order), read line by line through `logging/logScrubber.js`, which replaces the configured keys/tokens and Apprise notification URLs by value plus `X-Plex-Token`/`X-Emby-Token`/`api_key` values and URL userinfo (proxy credentials) with `[REDACTED]`; pino's redaction only covers top-level keys, and tokens reach the log inside logged axios errors and the logged yt-dlp args. The files on disk are not scrubbed. - `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus, tvshow, videoclassification, tvshowpattern, tvshowseason, episodeconflict, tvreorganizeoperation, tvreorganizeitem, watchstatushold). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates, TvShow hasMany VideoClassifications, TvShowPatterns (`patterns`) and TvShowSeasons (`seasons`), TvReorganizeOperation hasMany TvReorganizeItems. - `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs, tvShows, tvTitleShows, tvReorganize). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. -- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to <channel>@latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `<channel folder>.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings; MP3 is downgraded to video for videos whose predicted folder has the TV layout), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths, active references, and TV shows with numbered episodes in the folder, not counting a channel show whose tracked channel has since moved back to a videos folder (a title show always counts); reads layouts through `tvShows/libraryLayouts`, which therefore never imports it), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: <title>`; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation, downgrading MP3 to video for URLs whose predicted folder has the TV layout; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). +- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to <channel>@latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `<channel folder>.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings; MP3 is downgraded to video for videos whose predicted folder has the TV layout), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register(name, { layout, throwOnError })` idempotently upserts a name (a new row takes the layout; download-time callers rely on it never throwing); `delete` guards removal against non-empty disk paths, channels (enabled or not), playlists, the default folder, and TV shows with numbered episodes in the folder, not counting a channel show whose tracked channel has since moved back to a videos folder (a title show always counts), through `subfolderDeletion` (pure `deletionBlockers`/`deletionBlockReason`, shared with the Library folders page's delete blockers so the two can't drift); a Plex refresh mapping never blocks a delete: the delete removes the folder's entry, and a folder known only from a mapping is deleted the same way; reads layouts through `tvShows/libraryLayouts`, which therefore never imports it), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: <title>`; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation, downgrading MP3 to video for URLs whose predicted folder has the TV layout; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). - `modules/titleFilterRegex.js`: evaluates channel title filter regexes with Python's `re` (via `utils/title-filter-regex.py`, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's `--match-filter` exactly. Channel downloads apply the pattern through `ytdlpCommandBuilder.buildMatchFilters`, which escapes only `'` and `&` because yt-dlp's `match_str` unescapes nothing else. The same script has a `check` mode (compile-checks patterns; title show saves) and a `classify` mode (many patterns, first match and its named groups per title; `classifyTitles`), and `buildMatchFilterList` adds one `--match-filter` per title show for the show-only switch (`ChannelFilterConfig.showFilters`), never OR'd across shows into one regex. Playlist title filters are separate: `playlistModule.buildTitleFilterRegExp` evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile. - `modules/storageUsage.js`: total bytes of downloaded videos (`SUM(file_size + audio_file_size)` over non-removed rows, never a disk walk) and the shared `STORED_BYTES_SQL` fragment the oldest-first auto-removal candidate queries use, so audio-only (MP3) videos count their real size. - `modules/storageGuard.js`: the storage download pause (issues #196/#217). Optional `downloadPauseUsageLimit` (storageUsage total) and `downloadPauseMinFreeSpace` (df) triggers, both off by default; a failed measurement never pauses (fail open). `assertDownloadsAllowed()` throws `DownloadsPausedError` (code `DOWNLOADS_PAUSED`) from `downloadModule`'s fresh-request entry points (`doChannelAndPlaylistDownloads`, `doChannelDownloads`, `doSpecificDownloads`, `doPlaylistDownloads`; queued jobs started with isNextJob skip it), which routes map to 409 via the injected `storageGuard.isPausedError` (Download New reaches `doChannelAndPlaylistDownloads` through the scheduler's `channelDownloadFrequency` task rather than calling it directly, so its own pause check runs first, inside `autoDownloadScheduler.getRunBlocker`, and a paused Download New request gets the `downloads-paused` reason instead of a bare 409); the scheduled sweep records a skipped run first, and a pause hit mid-sweep stops the playlist loop and is reported as `pausedReason` (not a failure); `jobModule.startNextJob` refreshes the guard (so every finished job re-checks) and holds Pending jobs while paused, restarting the queue on the guard's `resumed` event. `startNextJob` coalesces overlapping calls into one in-flight scan (the `resumed` event fires inside the refresh a scan awaits, and a channel job stays Pending while it builds groups, so two scans would start it twice) and catches the unawaited job action's rejection; a job whose action rejects while it is still Pending is marked `Error` by `failUnstartedJob`, which waits for the launching scan to settle and then scans again, so the queue never stalls behind it (the single-group channel job's pre-spawn failure likewise reports a `jobIssue` to its run and calls `startNextJob`). `subscriptionImport` calls `startNextJob` when an import finishes (the import occupies the queue but completes outside the download path, so jobs queued or held meanwhile would otherwise stay Pending). The downloaded-size SUM runs only when a usage limit is set; `GET /api/jobs/download-pause` opts in with `refresh({ includeUsage: true })` for the settings page. `/updateconfig` rejects size values other than blank or `<positive int>MB|GB|TB`; `configModule.normalizeStorageSizeFields` corrects or clears hand-edited ones on load (startup and file-watch reload), because the client saves the full config and one bad value would otherwise block every Settings save. On a paused-state flip it broadcasts `downloadPauseChanged`, sends `notificationModule.sendDownloadPauseNotification` (paused and resumed), and re-checks every 5 min while paused; deletions re-check when paused. `GET /api/jobs/download-pause` serves the status; the client shows it through `hooks/useDownloadPauseStatus`, `layout/DownloadPauseBanner` (every page, dismissible per pause except on `/downloads`), and `shared/DownloadPauseAlert`, with settings at `/settings/storage-limits` (`Configuration/sections/StorageLimitsSection.tsx`). @@ -47,27 +47,28 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification; `getUnfinishedJobs(runId)` lists that run's still-Pending/In-Progress jobs, which `autoDownloadScheduler` -- given the tracker by `server.js` via `setRunTracker` -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: `channelAutoDownload` returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a `finalRecord` from `channel/sweepCompletion.waitForSweepEnd` (ends on the tracker's `onRunFinished`/`getFinishedRun`, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by `channel/sweepRunSummary.toRunRecord` (counts; failed videos, job issues, stopped groups, or playlist check failures make it `error`/`partial`; a pause ends it as `success`/`paused`; it carries the sweep's real `finishedAt`, which `scheduledTaskManager.describeResult` passes to `scheduledTaskRuns.finish` in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. `maybeFinalize` finishes a sealed run only once every job has reported: `updateJob` sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for `REPORT_GRACE_MS` (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. `jobModule.onJobEnded` (every final status, wired to `tracker.handleJobEnded` in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as `jobIssue` (`{ status, reason, byUser }`): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and `jobModule.failUnstartedJob` through `jobModule.onJobAbandoned` (wired to `tracker.handleAbandonedJob` in server.js, so jobModule never requires the tracker). `jobIssues` make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (`notifications/utils.getStoppedLines`), and notify unless the user terminated the job), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), `tempSpaceProbe` (decides which failed videos ran out of temp space, for the `temp-out-of-space` diagnosis: explicit "No space left on device" text, or a `Conversion failed!` whose leftover temp files are larger than the temp folder's free space (`fs.statfs`); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (out of temp space, 403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)). - `modules/channel/tabCountSources.js` + `tabCountThrottle.js` + `tabVideoCounts.js` + `tabDownloadStats.js`: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (`UULF`/`UUSH`/`UULV` + channel id minus `UC`; members-only videos live in separate `UUM*` playlists and are not counted), via YouTube API `playlists.list` (50 ids per unit) when a key is set, else yt-dlp `--flat-playlist --playlist-items 0` ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in `channels.tab_video_counts` (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (`channelVideoCountsFrequency` daily, and `refreshAtStartup` 2 minutes after boot, recorded as a `startup` run only when it counted something; server.js wires in the run history via `setRunHistory` and the download check via `setDownloadActivityCheck`) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, `lookupChannelsPaced` counts one tab at a time with a 3 s gap (yt-dlp's `--sleep-requests` only spaces requests inside one process) over channels whose counts and last attempt (`channels.tab_counts_attempted_at`, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (`stopBeforeChannel`) and before any lookup while bulk refreshes are paused (`stopBeforeLookup`, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome `throttled`) and starts a pause (`tabCountThrottle`: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is `bulk` for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and `all` otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (`bulkCookiesSince`) and the consecutive bot-check count (`cookielessBotChecks`), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes `currentDetails()` into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through `clearIfUnchanged`, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an `all` pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, `youtube_removed` and members-only excluded) through the channel's listing row or, without one, `videos.channel_id` + `media_type`; ignored counted separately; percent floors and caps at 100. Served as `tab_download_stats` on `/getchannels` and by `GET /api/channels/:channelId/tab-stats` (adds `loaded`). - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`, `showFolderCleanup`). Good example of the sub-module aggregator pattern. `sanitizer.sanitizeFilenameLikeYtDlp` ports yt-dlp's title-field sanitization, then cuts to a byte limit (fixtures from the image's yt-dlp via `scripts/generate-sanitizer-fixtures.js`). `showFolderCleanup` removes emptied season and show folders in TV-layout library folders (`<library folder>/<show>/Season NN/`); video deletion and the orphan sweep pick it or the channel-folder cleanup by the library folder's layout. -- `modules/tvShows/` (TV show support: channel shows, title shows, and the reorganize): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (Settings' folder list, layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, and `checkDefaultSubfolderChange`), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. **Title shows** (a series inside one channel, `tv_shows.kind = 'title'`, numbered from its titles; classification decides, the match filter only screens downloads): `showFolderNames` (dependency-free folder-name rules, shared with `showStore`), `patternCompiler` (simple syntax (`{season}`/`{episode}` as `[0-9]+`, `{title}`, `*`, `^`; `{episode_end}`/`{part}` recognized as unsupported) or regex mode -> a Python regex with named groups plus its filter form (groups unnamed, leading flags scoped; backreferences and conditional groups are refused, since unnaming and joining a show's patterns renumber groups); seasons 0..199), `titleMatcher` (one Python batch per channel through `titleFilterRegex.classifyTitles`: first show by position, then pattern, without an exclude term; a number the pattern takes from the title but the title skipped (an optional regex group) is `unsupported`, reason `missing-number`; a pending match keeps its title episode for the reorganize as `titleEpisode`), `titleNumbering` (pure: stored rows + matches -> rows with statuses `assigned`/`pending_number`/`duplicate`/`unsupported`/`opted_out`/`error`; the stored holder of a title number keeps it while it still claims it and is available or downloaded, else the earliest available upload (undated videos last); a manual override takes its number from any holder, which is then classified as if it held nothing; a TV channel's episode that loses a title claim keeps its channel-show row; `order` numbers past `tv_show_seasons.order_high_water`, never reissued; year seasons and date episodes wait for the download (`pending_number`), except that a downloaded video's upload year is known (`Videos.originalDate`), so a year season with a title episode is numbered at plan time and a downloaded duplicate the post-processor recorded keeps that verdict, since the planner never has the upload time to redo it; manual and opted-out rows are never touched; gaps, none for date or upload-year seasons), `titleShowDrafts` (validates and compiles a channel's whole set of shows; keys `title:<id>`/`new:<n>`), `titleShowStore` (rows, patterns, seasons, retire/restore, folder availability, show filters for downloads), `titlePlanner` (a channel's plan after a change, for every listed and downloaded video and every row classified for the channel; the preview, direct saves and the reorganize all plan through `planChannel`; a download outside the downloads folder never moves (`staysOutside`, counted in the preview); a show folder renamed only in case or accents moves its files, since the database compares folder names ignoring both and most filesystems don't), `titlePreview` (the editor's tabs), `titleRowWriter` (writes a plan in one transaction, numbers released before they are retaken, each row only while it is as planned: `ROW_CHANGED` otherwise, and the saver plans again once; conflicts are released by the channel's recorded duplicates), `titleShowSaver` (saves directly when no downloaded video's show, number or stem changes, else answers `reorganizeRequired` with a `titleShows` change; listing refreshes classify only new rows through `classifyNew`), `titleShowService` (the API's operations as drafts plus overrides: create, edit, retire, restore, reorder, manual episode, "Not an episode" (title show episodes only), back to automatic, "Use this copy instead", recheck; after each save it rewrites the channel's title show NFO files, which name and season-name edits change without moving a file; the reorganize follow-up does the same for a `titleShows` change), `titleShowQueries` (counts, `/getchannels` `titleShows`, missing episodes, the channel page's show filter, the planned episode chip of not-downloaded videos), `episodeConflicts` (duplicates and classification errors; a not-downloaded duplicate is ignored and suppressed in `complete.list`, and only what Youtarr did is undone on release: the ignore it set (`youtarr_ignored`) and the archive line it wrote (`archive_suppressed`, false when the line was already there, e.g. a deleted download's); a downloaded duplicate is never ignored or deleted; duplicates a listing refresh finds are ignored too), `archiveSuppressor` (the deferred `complete.list` writer: `archive_pending` on the conflict row, applied when no download job is In Progress, through `jobModule.onBeforeNextJob` (awaited before any job starts, queued or new) and at startup, because channel jobs count archive lines), `titleEpisodeAssigner` (the post-processor's step: a stored episode is reused without Python; otherwise the full title is classified at finalize; duplicates, unsupported, opted-out and errors fall back to the channel layout and are recorded), and `tvNfo`'s title show output (`<uniqueid type="youtarr">` + custom UUID, `<namedseason>`, `season.nfo` per named season). The show-only switch (`channels.tv_show_only_downloads`) adds one match filter per show to channel downloads and limits Download All to classified videos (`assigned`/`pending_number`); it does nothing while the channel has no active show. Routes: `routes/tvShows.js` (`GET`/`PUT /api/library-folders`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`), `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`) and `routes/tvTitleShows.js` (`/api/channels/:channelId/tv/shows` list/create/edit/retire/restore/order and `/:showId/missing`, `POST .../tv/preview`, `PUT .../tv/show-only`, `POST .../tv/conflicts/:youtubeId/use-copy`, `POST .../tv/recheck`, `GET`/`PUT /api/videos/:youtubeId/episode`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json` (`titleCorpusClassification.test.js` runs them through the real Python matcher). -- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts (or a TV channel moves to another TV folder), or when a change to a channel's title shows moves downloaded videos (`titleShows` change: the show definitions and every classification row of the channel are written at the start, with a `titleSnapshot` of the previous ones in `settings_change`, restored when no video moved (deleting the shows the change created); title shows are planned under show keys `title:<id>`/`new:<n>`, channel shows under their owner's channel id; `titleTargets` keeps episodes of active title shows in place for every other change type and a folder holding title shows can't switch to Videos (400); `titleAssignments` numbers videos moving into a title show, upload-time numbers from the stored info.json as the post-processor would). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned, but never for two paths that differ only in case or accents, which may be one file on a filesystem whose inode numbers vary by spelling), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings (a title show change marks the operation applied inside its own write transaction, so a restart never replays shows it created); items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. +- `modules/tvShows/` (TV show support: channel shows, title shows, and the reorganize): `constants` (layouts and show kinds, dependency-free so pure modules can import them), `libraryLayouts` (layout of each library folder: `mainFolderLayout` config for the main folder `''`, `subfolders.layout` for each subfolder; one query per resolver; `setLayout`, `listTvFolders`), `libraryFolders` (the Library folders page's folder list (`listLibraryFolders({ include })`), layout changes (registering a subfolder known only from config before `setLayout`) and the main folder's `.plexignore` with `__*/*`, `createLibraryFolder` (the `__` directory, then a registry row with the layout: a new, empty folder nothing uses takes it in its insert, anything else is registered as Videos and switched through `setFolderLayout`, whose guards send downloaded videos to the reorganize), `setDefaultFolder` (the only direct writer of `defaultSubfolder`: a same-layout switch saves at once, a layout-changing one goes through `checkDefaultSubfolderChange`, and one that needs review is applied, or rolled back, by the reorganize (`reorganize/settingsApplier`)), and `checkDefaultSubfolderChange`), `folderUsage` (`describeUsage` for `?include=usage,files`: the enabled channels that chose each folder or follow the default into it, its playlists and title shows, whether a layout change or a default switch needs the reorganize (disabled channels included), its Plex refresh mapping, every delete blocker, and how many downloaded videos sit in it), `folderDetail` (`getFolderDetail` for `GET /api/library-folders/folder/:key`, `~main` for the main folder: the enabled channels that chose it, the enabled playlists and active title shows that use it, each with its downloaded videos there, the enabled channels following the default into it (default folder only, as a count and the first three names), and the latest download as the layout preview's example), `layoutGuards` (refusals that keep a folder from mixing layouts: a folder or channel with downloaded videos switches between videos and TV only through the reorganize, so a direct change is answered with a `reorganizeRequired` 409 naming the change for its preview (`reorganizeRequiredError`; routes send `errorBody`) (a channel's downloads include its episodes uploaded under another channel id, found through `video_classifications`, since VEVO/Topic uploads keep the uploader's id on their videos row), nothing switches while a download job is In Progress (the caller passes `isDownloadRunning`), and MP3 download types never target a TV folder: saved channel/playlist settings and explicit download overrides are refused (`assertVideoOnlyDestination`), while `manualDownloadGrouper`/`playlistDownloadGrouper` downgrade a download-time MP3 type to video for each video whose predicted folder is TV (`downloadSettingsResolver.predictFinalSubfolder` + `coerceAudioFormatForLayout`, one layout read per batch)), `channelFolders` (where a tracked channel's files live: its channel folder, or its pinned show folder in a TV folder; `tvChannelCondition` for the `/getchannels?layout=tv` filter; imports `filesystem/pathBuilder` only, since the aggregator would load fs-extra), `channelLayout` (a channel's layout is its folder's layout: `checkChannelSettingsChange`/`applyChannelFolderChange`, called from `channelSettingsModule.updateChannelSettings`, which creates or moves the channel show instead of moving files when TV is involved, rolls the folder change back when that fails, and on leaving TV keeps the show row but removes the show folder left behind once only `tvshow.nfo` and art remain (`showFolderCleanup.cleanupOrphanShowFolder`); `resolveLayoutTarget` for the TV layout toggle; `getChannelTvState`), `showStore` (`tv_shows` rows; a channel show is created once per channel when it switches to TV or first downloads into a TV folder, its folder name sanitized and validated, getting ` (<channel id>)` when taken or invalid), `episodeNaming` (`Season NN` folders, `SxxEyy - <title> [id]` stems, 8-digit MMDDHHMM episodes for date numbering, titles cut to 64 bytes, `episodeFileName` renames a video's files to the stem), `dateNumbering` (UTC year seasons and MMDDHHMM episodes from `timestamp`, falling back to `upload_date`; taken numbers bump to the next free integer; batch numbering adopts "Plex TV Series" preset codes), `episodeAllocator` (stored, unique date numbers in `video_classifications`; a re-download reuses its row; never upsert, because ON DUPLICATE KEY would also fire on the episode key; one retry on a taken number), `routing` (pure rule for where a downloaded video belongs: title show, channel show, or movie-style), `episodePlacement` (the post-processor's TV branch: `planEpisode` returns null for a videos folder or when layouts can't be read, else the show, number, season folder and stem, and throws rather than misplace an episode; `moveEpisodeFiles` moves the video's files renamed to the stem through `filesystem.replaceFileWithRetries` (a `<name>.part` staging file and an atomic rename, because fs-extra's overwrite removes the destination before a cross-device copy), then removes the video's other files in the season folder; MP3 output that still reaches a TV folder (attribution that only succeeds from the .info.json) is placed under the episode stem too, never movie-style, with a warning; `writeEpisodeMetadata` writes the episode NFO, always, and `tvshow.nfo` when its content changes), `tvNfo` (`<episodedetails>` and `<tvshow>` XML; never lockdata, dateadded or artwork paths), `episodeInfo` (episode details for the channel videos, `/getVideos` and video metadata responses, only when the video's file is named with its stored stem). The post-processor tags episodes with `--TVShowName`, `--TVEpisode` and `--stik "TV Show"`, never the season/episode atoms, and copies the show owner's channel art (not the uploader's) into the show folder. **Title shows** (a series inside one channel, `tv_shows.kind = 'title'`, numbered from its titles; classification decides, the match filter only screens downloads): `showFolderNames` (dependency-free folder-name rules, shared with `showStore`), `patternCompiler` (simple syntax (`{season}`/`{episode}` as `[0-9]+`, `{title}`, `*`, `^`; `{episode_end}`/`{part}` recognized as unsupported) or regex mode -> a Python regex with named groups plus its filter form (groups unnamed, leading flags scoped; backreferences and conditional groups are refused, since unnaming and joining a show's patterns renumber groups); seasons 0..199), `titleMatcher` (one Python batch per channel through `titleFilterRegex.classifyTitles`: first show by position, then pattern, without an exclude term; a number the pattern takes from the title but the title skipped (an optional regex group) is `unsupported`, reason `missing-number`; a pending match keeps its title episode for the reorganize as `titleEpisode`), `titleNumbering` (pure: stored rows + matches -> rows with statuses `assigned`/`pending_number`/`duplicate`/`unsupported`/`opted_out`/`error`; the stored holder of a title number keeps it while it still claims it and is available or downloaded, else the earliest available upload (undated videos last); a manual override takes its number from any holder, which is then classified as if it held nothing; a TV channel's episode that loses a title claim keeps its channel-show row; `order` numbers past `tv_show_seasons.order_high_water`, never reissued; year seasons and date episodes wait for the download (`pending_number`), except that a downloaded video's upload year is known (`Videos.originalDate`), so a year season with a title episode is numbered at plan time and a downloaded duplicate the post-processor recorded keeps that verdict, since the planner never has the upload time to redo it; manual and opted-out rows are never touched; gaps, none for date or upload-year seasons), `titleShowDrafts` (validates and compiles a channel's whole set of shows; keys `title:<id>`/`new:<n>`), `titleShowStore` (rows, patterns, seasons, retire/restore, folder availability, show filters for downloads), `titlePlanner` (a channel's plan after a change, for every listed and downloaded video and every row classified for the channel; the preview, direct saves and the reorganize all plan through `planChannel`; a download outside the downloads folder never moves (`staysOutside`, counted in the preview); a show folder renamed only in case or accents moves its files, since the database compares folder names ignoring both and most filesystems don't), `titlePreview` (the editor's tabs), `titleRowWriter` (writes a plan in one transaction, numbers released before they are retaken, each row only while it is as planned: `ROW_CHANGED` otherwise, and the saver plans again once; conflicts are released by the channel's recorded duplicates), `titleShowSaver` (saves directly when no downloaded video's show, number or stem changes, else answers `reorganizeRequired` with a `titleShows` change; listing refreshes classify only new rows through `classifyNew`), `titleShowService` (the API's operations as drafts plus overrides: create, edit, retire, restore, reorder, manual episode, "Not an episode" (title show episodes only), back to automatic, "Use this copy instead", recheck; after each save it rewrites the channel's title show NFO files, which name and season-name edits change without moving a file; the reorganize follow-up does the same for a `titleShows` change), `titleShowQueries` (counts, `/getchannels` `titleShows`, missing episodes, the channel page's show filter, the planned episode chip of not-downloaded videos), `episodeConflicts` (duplicates and classification errors; a not-downloaded duplicate is ignored and suppressed in `complete.list`, and only what Youtarr did is undone on release: the ignore it set (`youtarr_ignored`) and the archive line it wrote (`archive_suppressed`, false when the line was already there, e.g. a deleted download's); a downloaded duplicate is never ignored or deleted; duplicates a listing refresh finds are ignored too), `archiveSuppressor` (the deferred `complete.list` writer: `archive_pending` on the conflict row, applied when no download job is In Progress, through `jobModule.onBeforeNextJob` (awaited before any job starts, queued or new) and at startup, because channel jobs count archive lines), `titleEpisodeAssigner` (the post-processor's step: a stored episode is reused without Python; otherwise the full title is classified at finalize; duplicates, unsupported, opted-out and errors fall back to the channel layout and are recorded), and `tvNfo`'s title show output (`<uniqueid type="youtarr">` + custom UUID, `<namedseason>`, `season.nfo` per named season). The show-only switch (`channels.tv_show_only_downloads`) adds one match filter per show to channel downloads and limits Download All to classified videos (`assigned`/`pending_number`); it does nothing while the channel has no active show. Routes: `routes/tvShows.js` (`GET /api/library-folders` (`?include=usage,files`), `PUT /api/library-folders` (a folder's layout), `GET /api/library-folders/folder/:key`, `PUT /api/library-folders/default`, `GET /api/library-folders/check` and `PUT /api/library-folders/plex-mapping` (the library check, `mediaServers/libraryCheck`; with `replace: true`, any folder's mapping through `mediaServers/plexRefreshMappings`, `libraryId: null` for the default library) and `DELETE /api/library-folders/plex-mapping`, `GET /api/channels/:channelId/tv`, `PUT /api/channels/:channelId/tv/layout`), `routes/subfolders.js` (`POST /api/subfolders` creates the `__` directory and registers the folder through `createLibraryFolder`, taking an optional `layout`: 201 when created, 200 when it already existed) and `routes/tvTitleShows.js` (`/api/channels/:channelId/tv/shows` list/create/edit/retire/restore/order and `/:showId/missing`, `POST .../tv/preview`, `PUT .../tv/show-only`, `POST .../tv/conflicts/:youtubeId/use-copy`, `POST .../tv/recheck`, `GET`/`PUT /api/videos/:youtubeId/episode`). Real channel titles with intended classifications live in `tvShows/__tests__/fixtures/channelTitleCorpus.json` (`titleCorpusClassification.test.js` runs them through the real Python matcher). +- `modules/reorganize/` + `routes/tvReorganize.js`: moves downloaded files when a channel, a library folder or the default folder switches between the Videos and TV layouts (or a TV channel moves to another TV folder), or when a change to a channel's title shows moves downloaded videos (`titleShows` change: the show definitions and every classification row of the channel are written at the start, with a `titleSnapshot` of the previous ones in `settings_change`, restored when no video moved (deleting the shows the change created); title shows are planned under show keys `title:<id>`/`new:<n>`, channel shows under their owner's channel id; `titleTargets` keeps episodes of active title shows in place for every other change type and a folder holding title shows can't switch to Videos (400); `titleAssignments` numbers videos moving into a title show, upload-time numbers from the stored info.json as the post-processor would). `POST /api/tv/reorganize/preview` is the dry run (every move, episode numbers, the shows it creates or moves, missing files, collisions, watch state at risk, complete totals but at most 200 listed moves, the TV folders videos move into (`tvFolders`, for the library check), and a `revision` sha256 over the change, the planned show locations and each source's size and mtime); `POST /api/tv/reorganize` recomputes the plan under the lock and refuses a stale token (409 `STALE_PREVIEW`), applies the settings change and pins the shows at the start (queued downloads already see the new layout), moves the files in the background and rolls the change back only when no video's files reached their destination; a plan with nothing to move is applied as a bare settings change unless its videos could not be planned at all (`planner.applyRefusal`: `blocked.reason` `problems`, 409 on apply), missing files excepted. Planning: `changeContext` (the change before and after), `changeScope` (a channel's rows by channel id, by classified episode and by path under its folder; a folder's rows by path, owners from the channel folder or the episode's classification), `showPlanner` (a moving channel takes its show; in a folder switching to TV an existing show elsewhere keeps its location; folder names planned exactly through `showStore.planChannelShowFolder`), `destinationPlanner` (each source directory read once per plan and indexed by video id, since a flat channel folder holds thousands of files; episode numbers as the post-processor gives them, keeping a number the video holds and adopting Plex TV Series preset codes; every destination checked to stay inside the downloads folder (`unsafe-name` problem), movie-style channel folders from `folder_name` or the rendered name, never the raw uploader; movie-style names from `movieNameRenderer`, which renders the global template's channel/video folder/stem through one batched `yt-dlp --load-info-json` per 200 videos with `formats` stripped and the download's naming options). Running: `reorganizeLock` (dependency-free: `jobModule` keeps download jobs Pending while it is held, both in `addOrUpdateJob` and `_startNextJob`, and restarts the queue on release; `scheduledTaskManager.setExclusiveBlocker` refuses the rescan, library repair, auto-removal, watch sync and yt-dlp update, for scheduled occurrences too; the startup rescan and library repair skip; `DELETE /api/videos` and channel settings saves of covered videos and channels get 409; media-server playlist syncs are deferred and replayed on release), `operationStore` (`tv_reorganize_operations`/`tv_reorganize_items`, resumed at startup by `recoverInterrupted`), `itemExecutor` (one video, safe to repeat: the episode number first, every file through `filesystem.moveFileNoClobber` (rename; across filesystems a `.reorganize.part` copy renamed into place before the source goes; an earlier interrupted copy is finished or cleaned, but never for two paths that differ only in case or accents, which may be one file on a filesystem whose inode numbers vary by spelling), earlier files moved back if a later one fails, then `sidecarWriter`, the old NFO removed, the videos row through `videoRowGuard`, `_actual_*` paths in `jobs/info` through `videoInfoStore`, emptied per-video, season and show folders removed; on a move failure every file at its destination goes back, this attempt's and an earlier interrupted attempt's alike, so the video is moved or home, never split (a sidecar gone since the preview is skipped, the media file is not); the error then says where the files are: `filesMoved` true (some file could not be brought back, or the failure came after the files moved), false (verified back at their sources), unset (the attempt never reached them). `files_moved` on the item follows: set or cleared only on a definite answer, so the video counts as moved (settings kept, holds kept) exactly when its files are, and Retry finishes it), `followUp` (over every done item of the operation, earlier runs included; inside the lock: tvshow.nfo and art, channel art (and Youtarr's `tvshow.nfo` removed from a folder that receives movie-style videos, i.e. a show folder that is a channel folder again), emptied channel/show folders, channel .m3u; after it: `__playlists__` m3u and media-server playlist re-sync for moved videos (again 5 and 15 minutes later, `PLAYLIST_RESYNC_DELAYS_MS`, because a move that keeps file names first resolves to the stale item at the old path), Plex refresh of the old and new folders, Jellyfin/Emby refresh, push-back follow-ups), `operationRunner` (preconditions, re-checked after the plan since a job may have started meanwhile; holds, released in `finally` for videos whose files stayed; settings (a title show change marks the operation applied inside its own write transaction, so a restart never replays shows it created); items; retry of failed and never-reached items unless a newer reorganize covers the channels; recovery waits up to 30 minutes for startup work and otherwise closes the operation as unfinished for Retry). `sidecarWriter` writes a video's NFO (episode NFO always, movie NFO when enabled), fanart/backdrop copies and channel or show art at its current path from the stored info (reusable for a regenerate-metadata task); `videoInfoStore` reads `jobs/info/<id>.info.json` (with a videos-row fallback) and rewrites its stored paths. - `modules/mediaServers/watchStatusHolds.js` + `watchStatusPushBack.js`: watch state a reorganize protects. Before files move, each watched or in-progress `video_watch_status` row (Plex: the owner only; other accounts come from play history, which a move never resets) is snapshotted into `watch_status_holds`; `watchStatusSync` passes every server's matches through `applyHolds`, which drops downgrades of held rows (the moved item looks new and unwatched, and until a rescan the server lists the old path, which the current-copy rule rejects) and ends a hold when the item at the video's current path (matching it with more trailing segments than the path the video came from, kept in the snapshot as `fromPath`: between TV folders the stale item shares the show, season and file names) shows at least the held state or a newer one. A push stamps the server item with the push time: a last-watched time within 10 minutes of the hold's last actual write (`last_pushed_at`; an attempt that found the state already there writes nothing and leaves no echo) is that echo, not a watch, so the row keeps its historical play count and last-watched time (restored holds are consulted for this too), while a later real watch is written as is. Re-holding a row for a later reorganize resets its push record (the earlier push went to the earlier location). Push-back first reads the user's state of the item (`getWatchState`: Plex `/library/metadata/{key}` as the owner, Jellyfin and Emby `/Users/{id}/Items/{id}`; `watchStateCompare` holds the pure `isAtLeast`/`isLaterWatch` the sync uses too) and leaves a state at least as watched, or watched since the snapshot, alone; otherwise it writes the state through the adapters' `setWatchState` (Plex `/:/scrobble` and `/:/progress` as the owner; Jellyfin `/UserPlayedItems` and `/UserItems/{id}/UserData`, falling back to the legacy `/Users/{id}/...` routes; Emby `/Users/{id}/PlayedItems` and `/Users/{id}/Items/{id}/UserData`), finding items with `resolveItemMatchesByPaths` (an item is skipped only when the same item answers for both the new and the old path and fits the old one at least as well, i.e. the stale item before the server rescans; two different items mean the new file is indexed; an explicit retry takes whatever matches); a hold is pushed once, and again only when a sync finds the item at the new path below the held state (`applyHolds` records that as the hold's error), since a blind re-push could undo a watch made in between. It runs 1, 5 and 15 minutes after a reorganize and at the end of every sync, an hour apart per hold. Holds not restored within 14 days become failed (still protecting the row) until retried or dismissed (`GET /api/tv/holds`, `POST /api/tv/holds/:id/retry|dismiss`). - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. - `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10, and episodes by listing show sections with type=4, only when a file in the batch is named SxxEyy), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). -- `modules/mediaServers/libraryMatcher.js` + `libraryLocator.js` + `libraryCheck.js`: which server libraries hold Youtarr's library folders. Adapters list libraries (`listLibraries()`: type `videos`/`tv`/`mixed`/`music`/`other`, server-side locations, Plex agent/scanner, Jellyfin/Emby `nfoSaver`/`onlineFetchers` from `adapters/libraryOptions.js`, a null `MetadataSavers` resolved through the server's `/System/Configuration` defaults) and sample a few file paths per library (`sampleItemPaths`). `libraryMatcher` (pure) finds a folder by a location ending in `__<name>` (its parent is the main folder) and by sampled downloads paired with Youtarr's paths by `[id]` (the shared tail below the downloads folder gives each side's folder; a stale copy in another subfolder is ignored), assumes each subfolder beneath a known main folder, and relates each location to each folder as `exact`/`covers`/`inside`, flagging an exact match whose last segment isn't `__<name>` (`folderSegmentMissing`, a mount at the folder itself). `libraryLocator.scopeFor(adapter)` gives the library ids that may hold Youtarr's files (null when the main folder can't be found): the matcher's related libraries plus every sampled library whose sample was inconclusive (no files, unreadable, or `[id]`-named files Youtarr can't place), so only a library whose sample shows plainly unrelated files is left out. Plex `fetchWatchStates` and `resolveItemMatchesByPaths`/`resolveItemIdsByFilepaths` (watch sync, playlist sync) and Jellyfin/Emby `resolveItemMatchesByPaths` (push-back, per library via `parentId`) take it as `{ libraryIds }`; the configured Plex YouTube library and the sections in `plexSubfolderLibraryMappings` are always in scope. `libraryCheck.check({ folders, layout })` (the `layout` checks the folders given as that layout; the reorganize preview passes `tv` for `tvFolders`, which may still be saved as Videos folders) reports per folder and server `ok`/`warning`/`missing`/`unreachable` with issues: `noLibrary`, `wrongType`, `ambiguous`, `duplicateLibrary`, `plexSeriesAgent`, `plexLegacyAgent`, `nfoSaver`, `onlineFetchers`, `folderNameMissing`, `overlap`/`overlapTv` (Plex shows a nested folder twice; a TV main folder's Plex library is exempt thanks to `.plexignore`), `nestedLibrary` (Jellyfin and Emby show a folder in one library only: a library inside another library's folder is skipped, so a TV folder under a root library counts as missing), `insideFolder`, and for Plex TV subfolders `plexMapping` (`mappedLibraryId`, `suggestedLibraryId`: the one exact TV library) with `plexMappingMissing`/`plexMappingMismatch`. `applyPlexMapping(folder, libraryId)` adds the suggested mapping, never replacing one, and answers with the saved `plexSubfolderLibraryMappings`, which the client's `useLibraryCheck` hands to `useConfig` through `CONFIG_PATCHED_EVENT`: the saved copy takes the list as is and the draft takes only the server's change since that copy (`utils/configPatch.mergeServerChange`, keyed by subfolder), so an open Settings page keeps its unsaved edits, including pending edits to other mappings, and its next save carries the new mapping. A Videos folder counts as held by a music library pointed at it (MP3 channels; one at a parent folder says nothing about the videos); TV folders need a video library. +- `modules/mediaServers/libraryMatcher.js` + `libraryLocator.js` + `libraryCheck.js`: which server libraries hold Youtarr's library folders. Adapters list libraries (`listLibraries()`: type `videos`/`tv`/`mixed`/`music`/`other`, server-side locations, Plex agent/scanner, Jellyfin/Emby `nfoSaver`/`onlineFetchers` from `adapters/libraryOptions.js`, a null `MetadataSavers` resolved through the server's `/System/Configuration` defaults) and sample a few file paths per library (`sampleItemPaths`). `libraryMatcher` (pure) finds a folder by a location ending in `__<name>` (its parent is the main folder) and by sampled downloads paired with Youtarr's paths by `[id]` (the shared tail below the downloads folder gives each side's folder; a stale copy in another subfolder is ignored), assumes each subfolder beneath a known main folder, and relates each location to each folder as `exact`/`covers`/`inside`, flagging an exact match whose last segment isn't `__<name>` (`folderSegmentMissing`, a mount at the folder itself). `libraryLocator.scopeFor(adapter)` gives the library ids that may hold Youtarr's files (null when the main folder can't be found): the matcher's related libraries plus every sampled library whose sample was inconclusive (no files, unreadable, or `[id]`-named files Youtarr can't place), so only a library whose sample shows plainly unrelated files is left out. Plex `fetchWatchStates` and `resolveItemMatchesByPaths`/`resolveItemIdsByFilepaths` (watch sync, playlist sync) and Jellyfin/Emby `resolveItemMatchesByPaths` (push-back, per library via `parentId`) take it as `{ libraryIds }`; the configured Plex YouTube library and the sections in `plexSubfolderLibraryMappings` are always in scope. `libraryCheck.check({ folders, layout })` (the `layout` checks the folders given as that layout; the reorganize preview passes `tv` for `tvFolders`, which may still be saved as Videos folders) reports per folder and server `ok`/`warning`/`missing`/`unreachable` with issues: `noLibrary`, `wrongType`, `ambiguous`, `duplicateLibrary`, `plexSeriesAgent`, `plexLegacyAgent`, `nfoSaver`, `onlineFetchers`, `folderNameMissing`, `overlap`/`overlapTv` (Plex shows a nested folder twice; a TV main folder's Plex library is exempt thanks to `.plexignore`), `nestedLibrary` (Jellyfin and Emby show a folder in one library only: a library inside another library's folder is skipped, so a TV folder under a root library counts as missing), `insideFolder`, and for Plex TV subfolders `plexMapping` (`mappedLibraryId`, `suggestedLibraryId`: the one exact TV library, `choice`: `library`/`default`/`none`) with `plexMappingMissing`/`plexMappingMismatch` (neither for an explicit default choice); each server also reports `downloadsPath`, where it sees the downloads folder in its own spelling (null unless exactly one place is known; `libraryMatcher` keeps the server's root and separator, so `Q:\Youtube_test` and `\\nas\media\yt` stay as the server shows them). `applyPlexMapping(folder, libraryId)` adds the suggested mapping, never replacing an entry (an explicit default choice included), and answers with the saved `plexSubfolderLibraryMappings`, which the client's `useLibraryCheck` hands to `useConfig` through `CONFIG_PATCHED_EVENT`. `plexMappingEntries` (pure) reads that list: an entry by folder, ignoring case (the main folder's entry has `subfolder: null`), its `{ choice, libraryId }` (an entry with `libraryId: null` is the explicit choice of the default library, which automatic mapping leaves alone), and the list with an entry set or removed; `plexModule.getLibraryIdForSubfolder` resolves through it. `plexRefreshMappings` backs the Library folders page: `setMapping(folder, libraryId)` (replace mode, overwriting the folder's entry; a library id must be one Plex lists, so it needs Plex, while `null` doesn't) and `removeMapping(folder)` (no Plex connection needed). A Videos folder counts as held by a music library pointed at it (MP3 channels; one at a parent folder says nothing about the videos); TV folders need a video library. - `modules/subscriptionImport/`: bulk channel import (`importJobRunner`, `takeoutParser`, `cookiesFetcher`, `thumbnailEnricher`, `concurrencyLimiter`, `errorClassifier`). ### Frontend (client/src/) - `App.tsx`: app routing plus a global `fetch()` override that detects 503 `requiresDbFix` responses and surfaces the database error overlay. You can use normal `fetch()` anywhere; database errors are handled automatically. -- `components/`: feature directories and pages. Complex features pair a top-level `FeatureName.tsx` with a same-named `FeatureName/` directory holding `components/`, `hooks/`, and `__tests__/`. Examples of this sibling-file layout: `Subscriptions.tsx` + `Subscriptions/` (renamed from `ChannelManager`), `Configuration.tsx` + `Configuration/`, `ChannelPage.tsx` + `ChannelPage/`, `PlaylistPage.tsx` + `PlaylistPage/`. Newer features (e.g. `SubscriptionImport/`) put the main component at `FeatureName/index.tsx` instead; either layout is acceptable for new features. -- `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards, `LibraryCheck/LibraryCheckNotes` for one folder's library check per media server (setup hints, the Plex refresh mapping button; used by Channel Settings' `ChannelPage/components/ChannelTvMediaServers` (runs the check for the channel's TV folder and adds the Plex mapping automatically), Settings' `LibraryFoldersList` and the reorganize preview), `Reorganize/` for the reorganize review and progress dialog: `ReorganizeDialog` previews a change (computed again every 5 s while a task that ends on its own blocks it, `useReorganizePreview`; with the library check for its `tvFolders`) and follows the operation it starts, opened by Channel Settings (layout toggle and folder saves), the library folder list and Settings saves of the default subfolder whenever the server answers `reorganizeRequired`; `reorganizeErrors` turns those refusals into a `ReorganizeRequiredError` carrying the change; the opener saves the folder optimistically when the move starts (`onApplied`), then follows the operation with `hooks/useReorganizeOutcome` (once per run end, told apart by `finishedAt`; `onRetried` bumps its attempt) and reads the saved folder back at each end, since the server undoes the change when nothing moved and applies it again when a retry moves some). -- `components/ui/`: theme-neutral UI primitives (Button, Card, Dialog, Select, etc.) built on Radix and styled via CSS variables + Tailwind. Use these instead of Material-UI imports in new code. `AccordionContent` stays mounted when collapsed (tests can query it) but is `invisible`, so its controls leave the tab order and accessibility tree. +- `components/`: feature directories and pages. Complex features pair a top-level `FeatureName.tsx` with a same-named `FeatureName/` directory holding `components/`, `hooks/`, and `__tests__/`. Examples of this sibling-file layout: `Subscriptions.tsx` + `Subscriptions/` (renamed from `ChannelManager`), `ChannelPage.tsx` + `ChannelPage/`, `PlaylistPage.tsx` + `PlaylistPage/`. `Configuration/` has no top-level component: it holds the settings sections, hooks and shared settings UI that `Settings/Settings.tsx` mounts. Newer features (e.g. `SubscriptionImport/`, `LibraryFolders/`) put the main component at `FeatureName/index.tsx` instead; either layout is acceptable for new features. +- `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards, `LibraryCheck/LibraryCheckNotes` for one folder's library check per media server (setup hints, the Plex refresh mapping button; used by Channel Settings' `ChannelPage/components/ChannelTvMediaServers` (runs the check for the channel's TV folder and adds the Plex mapping automatically) and the reorganize preview; the Library folders page renders its own server cards), `LayoutChip` (the Videos / TV shows chip of a library folder's layout, on the Settings index and Core's Library folders card), `SubfolderAutocomplete` (the **Library folder** picker of channel, playlist, import and download settings; modes `channel` | `download`, root options reading **Main folder**, an **Add library folder** button and a **Manage library folders** link that opens the Library folders page in a new tab), `Reorganize/` for the reorganize review and progress dialog: `ReorganizeDialog` previews a change (computed again every 5 s while a task that ends on its own blocks it, `useReorganizePreview`; with the library check for its `tvFolders`) and follows the operation it starts, opened by Channel Settings (layout toggle and folder saves) and the Library folders page (layout changes, **Make default**, and **Add folder** asking for TV shows where files already sit, through `LibraryFolders/hooks/useReorganizeHandoff`) whenever the server answers `reorganizeRequired`; `reorganizeErrors` turns those refusals into a `ReorganizeRequiredError` carrying the change; the opener saves the folder optimistically when the move starts (`onApplied`), then follows the operation with `hooks/useReorganizeOutcome` (once per run end, told apart by `finishedAt`; `onRetried` bumps its attempt) and reads the saved folder back at each end, since the server undoes the change when nothing moved and applies it again when a retry moves some). +- `components/ui/`: theme-neutral UI primitives (Button, Card, Dialog, Select, etc.) built on Radix and styled via CSS variables + Tailwind. Use these instead of Material-UI imports in new code. `AccordionContent` stays mounted when collapsed (tests can query it) but is `invisible`, so its controls leave the tab order and accessibility tree. `segmented-control` (`SegmentedControl`) is a single-choice control with radio semantics on `@radix-ui/react-toggle-group` (the Library folders page's **Preview as**). - `components/layout/`: app shell and navigation chrome. `AppShell.tsx` is the outer frame; `NavSidebar.tsx` / `NavHeader.tsx` own desktop and mobile nav; `navLayoutConstants.ts` holds shared sidebar/header sizing constants; `layoutFallback.css` provides fallback CSS variables for themes that skip layout overrides; `ReorganizeBanner.tsx` shows a running reorganize on every page (downloads wait for it). -- `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle, the watch-state restores a reorganize left (`sections/components/WatchStateRestores.tsx` via `hooks/useWatchStateHolds.ts`: pending count, failed restores with Retry and Dismiss) and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. +- `components/Settings/`: Settings page wrapper (`Settings.tsx`, which mounts the `Configuration/` sections and routes `library/*` to `LibraryFolders`; `settingsHeading.ts` builds the "Settings / {title}" heading from the first path segment, none for the Library folders page, which has its own) and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`, grouped by `SETTINGS_PAGES[].group` (`SETTINGS_GROUPS`), with a folder summary on the Library folders row. Core (`Configuration/sections/CoreSettingsSection.tsx`) is built from `Configuration/common/SettingsSection` + `SettingRow` + `SettingNote` in sections with anchors `#downloads`, `#media-server-files`, `#naming`, `#interface` and `#advanced`, headed by `Configuration/sections/components/LibraryFoldersCard` (the downloads folder, the default folder, folder counts and the media server check cell from `libraryCardText`, linking to the Library folders page); `Configuration/sections/components/FlatStructureDialog` confirms a change of the flat-structure default, naming the channels that follow it (`GET /api/channels/using-global-file-structure`, which leaves out channels in TV folders). `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle, the watch-state restores a reorganize left (`sections/components/WatchStateRestores.tsx` via `hooks/useWatchStateHolds.ts`: pending count, failed restores with Retry and Dismiss) and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. - `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`). - `components/ChannelPage/components/TitleShows/`: title shows. `TitleShowsSection` (Channel Settings > TV Show: the list with counts, reorder arrows, remove/restore, the show-only switch, `ConflictList` for duplicates and classification errors) and `TitleShowEditorDialog` (`useTitleShowForm`, `PatternRow` with simple/regex mode, `SeasonNamesEditor`, the TV folder pick, live `TitleShowPreviewTabs` from `hooks/useTitleShowPreview`); changes that move downloads raise `ReorganizeRequiredError` from `hooks/useTitleShows` and open `ReorganizeDialog`. On the channel page, `ChannelShowFilter` (the list's show filter, `showId` on `/getchannelvideos`) and `MissingEpisodesDialog` (`hooks/useMissingEpisodes`); the header and Subscriptions cards/rows show `Subscriptions/components/chips/ShowsChip`. `shared/EpisodeAssign/` (`EpisodeAssignDialog` + `useVideoEpisode`, `GET`/`PUT /api/videos/:youtubeId/episode`: a manual episode, "Not an episode", back to automatic) is opened by the video modal's `VideoModal/components/VideoEpisodeSection` and the conflict list; a change that moves files is followed with `useReorganizeOutcome` and reported saved when the run ends. - `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow. +- `components/LibraryFolders/`: the Library folders page (`index.tsx`; `/settings/library`, `/settings/library/:folder` with `~main` for the main folder). Shelves (**TV show folders**, **Video folders**, unused folders apart) beside an inspector for the selected folder when the page's measured content box (`hooks/useContainerWidth`) is at least 1012px wide, else a list and a `FolderDetailScreen`; a folder shows its media servers first (per-server cards, setup boxes, overlap fixes, `PlexRefreshControl`: **After downloads here, Plex refreshes** / **Remove setting**), then its layout (**Preview as**, **Move to {Target}** / **Use for {Target}**), **Downloads here** and Delete. Dialogs under `components/dialogs/`: Add folder (name and layout, then server setup), Make default, Delete, Start using TV shows and `MainFolderTvDialog`. `LibraryFoldersContext` (`useLibraryPage`) carries the token, data and actions so sections don't drill props; `libraryTypes` (library type names, setup rows, server path joins in the server's own separator), `folderText` (labels, plurals, summaries, delete reasons), `mediaServerText` (inspector intro, card words, issue hints, afterwards lines), `tvSetupPaths` (the detected setup and the Start using TV shows paths A/B/C with their steps; `FULL_GUIDE_URL` points at the usage guide's "Move an existing setup to TV shows"), `layoutPreview` (the example tree and reads-as line, without yt-dlp). Hooks: `useSelectedFolder` (URL <-> selection; an unknown key is replaced with a notice), `useLibraryFolderDetail`, `useCreateLibraryFolder` (`POST /api/subfolders { name, layout }`), `useDefaultFolder` (`PUT /api/library-folders/default`), `usePlexRefreshMapping` (replace mode / `DELETE`), `useLayoutChange`, `useReorganizeHandoff` (opens the shared `ReorganizeDialog` and follows the operation, reading the default folder back at each end, since the server undoes a default switch when nothing moved), `useGuideOpen`. `utils/libraryAttention` (folder states, each server's status per folder, the attention list with library-wide issues counted once, the check status) is shared with Core's Library folders card (so both show the same count) and Channel Settings' `ChannelTvMediaServers`. - `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface. -- `hooks/`: app-wide custom hooks for data fetching and state (e.g. `useLibraryCheck` for `GET /api/library-folders/check` and the Plex mapping, refetched on `LIBRARY_FOLDERS_UPDATED_EVENT`). +- `hooks/`: app-wide custom hooks for data fetching and state (e.g. `useLibraryCheck` for `GET /api/library-folders/check` and the Plex mapping, refetched on `LIBRARY_FOLDERS_UPDATED_EVENT`, with `lastCheckedAt` for the age of the last check; `useLibraryFolders`, whose `include` option adds the usage fields and file counts; `useContainerWidth`, an element's width measured with a ResizeObserver, for layouts that follow the content box instead of the viewport). `useConfig`'s `CONFIG_PATCHED_EVENT` takes fields the server saved outside the Settings form (`defaultSubfolder` from `useDefaultFolder`, `plexSubfolderLibraryMappings` from `useLibraryCheck` and `usePlexRefreshMapping`) into both the draft and the saved copy as a plain replace, no draft merge: those fields are untracked (`trackChanges: false`), and `/updateconfig` keeps the stored `defaultSubfolder`, `plexSubfolderLibraryMappings` and `mainFolderLayout`, re-reading them right before it saves, so only the Library folders endpoints (`/api/library-folders/*`, `/api/subfolders`), and the reorganize they hand off to (`reorganize/settingsApplier`: `defaultSubfolder`, and `mainFolderLayout` through `libraryLayouts.setLayout`), write them. - `contexts/` and `providers/`: React Context for cross-cutting concerns (auth token, WebSocket, theme). `contexts/ThemeEngineContext.tsx` owns the active theme mode, resolves the layout policy for the current viewport, and injects theme CSS variables onto the document root. - `config/configSchema.ts`: the `CONFIG_FIELDS` registry. Use this pattern when adding new configuration fields; it auto-derives types, defaults, and change tracking. - `types/`, `utils/`: shared types and helpers. @@ -183,7 +184,7 @@ These standards apply when you are authoring new code or doing an explicit rewri ├── __tests__/ └── index.tsx ``` - Sibling-file examples: `ChannelManager.tsx`, `Configuration.tsx`, `ChannelPage.tsx`. `index.tsx` example: `SubscriptionImport/`. + Sibling-file examples: `Subscriptions.tsx`, `ChannelPage.tsx`, `PlaylistPage.tsx`. `index.tsx` examples: `SubscriptionImport/`, `LibraryFolders/`. #### Custom Hooks - **Extract data fetching and multi-step state logic** into hooks. Each hook owns one concern. diff --git a/client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts b/client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts index 600c3578b..48b4c25f7 100644 --- a/client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts +++ b/client/src/components/LibraryFolders/__tests__/tvSetupPaths.test.ts @@ -34,11 +34,30 @@ describe('tvSetupPaths', () => { const options = pathOptions(detectSetup(folders, check(true, false), servers), servers); expect(options.map((option) => [option.key, option.recommended, option.disabledReason])).toEqual([ ['A', true, null], - ['B', false, 'You use Video folders (__Kids): Jellyfin and Emby would show each one as an extra show.'], + ['B', false, 'You use other folders (__Kids): Jellyfin and Emby would show each one as an extra show.'], ['C', false, null], ]); }); + test('B unavailable while a TV show folder is in use on Jellyfin', () => { + const tvInUse = [f('', { channels: 3, channelsChosen: 3 }), f('Shows', { layout: 'tv', channels: 2 })]; + const pathB = pathOptions(detectSetup(tvInUse, null, servers), servers).find((option) => option.key === 'B'); + expect(pathB?.disabledReason).toBe('You use other folders (__Shows): Jellyfin and Emby would show each one as an extra show.'); + }); + + test('B stays available with an unused TV show folder', () => { + const tvUnused = [f('', { channels: 3, channelsChosen: 3 }), f('Shows', { layout: 'tv' })]; + const pathB = pathOptions(detectSetup(tvUnused, null, servers), servers).find((option) => option.key === 'B'); + expect(pathB?.disabledReason).toBeNull(); + }); + + test('B notes that Plex skips a TV show folder in use when only Plex is connected', () => { + const plex = [{ serverType: 'plex' as const, name: 'Plex' }]; + const tvInUse = [f('', { channels: 3, channelsChosen: 3 }), f('Shows', { layout: 'tv', channels: 2 })]; + const pathB = pathOptions(detectSetup(tvInUse, null, plex), plex).find((option) => option.key === 'B'); + expect(pathB).toMatchObject({ disabledReason: null, note: "Plex skips the __subfolders; Jellyfin and Emby wouldn't." }); + }); + test('path A steps edit the whole-folder library with exact paths', () => { const steps = pathSteps('A', detectSetup(folders, check(true, false), servers), {}); expect(steps.youtarr.map((step) => step.text)).toEqual([ diff --git a/client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx b/client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx index 71831c885..4f1865c06 100644 --- a/client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx +++ b/client/src/components/LibraryFolders/components/__tests__/StartTvShowsDialog.test.tsx @@ -71,6 +71,6 @@ describe('StartTvShowsDialog', () => { folders: [folder(''), folder('Kids', { channels: 2 })], servers: [{ serverType: 'jellyfin', name: 'Jellyfin' }], }) }); expect(screen.getByRole('radio', { name: /Make the whole downloads folder TV shows/ })).toBeDisabled(); - expect(screen.getByText(/You use Video folders \(__Kids\)/)).toBeInTheDocument(); + expect(screen.getByText(/You use other folders \(__Kids\)/)).toBeInTheDocument(); }); }); diff --git a/client/src/components/LibraryFolders/tvSetupPaths.ts b/client/src/components/LibraryFolders/tvSetupPaths.ts index be1ce6dcb..542966c01 100644 --- a/client/src/components/LibraryFolders/tvSetupPaths.ts +++ b/client/src/components/LibraryFolders/tvSetupPaths.ts @@ -126,16 +126,19 @@ export function pathOptions(setup: DetectedSetup, servers: ServerRef[]): PathOpt }]; if (!setup.main || setup.main.layout === 'videos') { const onlyPlex = servers.length === 1 && servers[0].serverType === 'plex'; - const blocked = setup.videoFolders.length > 0 && !onlyPlex; + // Jellyfin and Emby show a folder in one library only, so a TV library on the downloads + // folder takes in every subfolder in use, a TV show folder as much as a Video folder. + const otherFolders = [...setup.videoFolders, ...setup.tvFolders.filter((f) => f.name && inUse(f))].sort(byLabel); + const blocked = otherFolders.length > 0 && !onlyPlex; options.push({ key: 'B', title: 'Make the whole downloads folder TV shows', description: 'Every channel becomes a show; one TV library shows the downloads folder.', recommended: false, disabledReason: blocked - ? `You use Video folders (${setup.videoFolders.map((f) => folderLabel(f.name)).join(', ')}): Jellyfin and Emby would show each one as an extra show.` + ? `You use other folders (${otherFolders.map((f) => folderLabel(f.name)).join(', ')}): Jellyfin and Emby would show each one as an extra show.` : null, - note: setup.videoFolders.length > 0 && onlyPlex ? "Plex skips the __subfolders; Jellyfin and Emby wouldn't." : null, + note: otherFolders.length > 0 && onlyPlex ? "Plex skips the __subfolders; Jellyfin and Emby wouldn't." : null, }); } if (setup.videoSubfolders.length > 0) { diff --git a/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx b/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx index 51cd67020..917bc5648 100644 --- a/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx +++ b/client/src/components/shared/__tests__/AddSubfolderDialog.story.tsx @@ -23,7 +23,7 @@ export const EmptyDisabled: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await expect(body.getByRole('button', { name: /add subfolder/i })).toBeDisabled(); + await expect(body.getByRole('button', { name: /add library folder/i })).toBeDisabled(); }, }; @@ -37,14 +37,14 @@ export const ValidatesAndSubmits: Story = { await userEvent.clear(input); await userEvent.type(input, '__Bad'); await expect(body.getByText(/cannot start with __/i)).toBeInTheDocument(); - await expect(body.getByRole('button', { name: /add subfolder/i })).toBeDisabled(); + await expect(body.getByRole('button', { name: /add library folder/i })).toBeDisabled(); // Valid await userEvent.clear(input); await userEvent.type(input, 'Sports'); - await expect(body.getByRole('button', { name: /add subfolder/i })).toBeEnabled(); + await expect(body.getByRole('button', { name: /add library folder/i })).toBeEnabled(); - await userEvent.click(body.getByRole('button', { name: /add subfolder/i })); + await userEvent.click(body.getByRole('button', { name: /add library folder/i })); await expect(args.onAdd).toHaveBeenCalledWith('Sports'); }, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 1c70893e6..6ebcf77c2 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -138,26 +138,28 @@ Youtarr must be running at the scheduled time; missed occurrences are not replay - `h265`: Better compression, requires modern devices - `default`: H.264 at 1080p and below, and usually VP9 without HDR above that, where YouTube has no H.264. H.264 files are noticeably larger than the AV1 equivalent at the same resolution, which is the cost of direct play on clients without AV1 decode. (VP9 and AV1 are not selectable values for this key; `-S res,vcodec:av01` in the custom yt-dlp arguments is the way to get AV1.) -### Default Subfolder +### Default Folder - **Config Key**: `defaultSubfolder` - **Type**: `string` -- **Default**: `""` (empty - downloads to root directory) -- **Description**: Default download location for untracked channels and channels set to use "Default Subfolder" -- **Note**: Subfolders are prefixed with `__` on the filesystem (e.g., setting `Sports` creates `__Sports/`) -- **Channel Subfolder Semantics**: - - **"Default Subfolder"** (NULL in database): Channel uses this global default setting - - **"No Subfolder"** (special value): Channel explicitly downloads to root directory, ignoring the global default - - **Specific subfolder**: Channel downloads to that specific subfolder +- **Default**: `""` (empty - the main folder) +- **Description**: The **Default folder**: where channels set to "Default folder" download, and the fallback for downloads with no more specific folder (untracked channels, including their manual downloads) +- **Set via**: Settings -> **Library folders**, **Make default** on a folder's page. `PUT /api/library-folders/default` changes it; `/updateconfig` keeps the stored value, so a Settings save never changes it. Making a folder with the other layout the default folder while channels that follow the default have downloads opens a review of the move first; the reorganize then applies the new default folder (and restores the old one if no video could be moved). +- **Note**: Library folders are prefixed with `__` on the filesystem (e.g., the folder `Sports` is `__Sports/`) +- **Channel Library Folder Semantics** (the channel's **Library folder** setting): + - **"Default folder"** (`##USE_GLOBAL_DEFAULT##` in the database): Channel follows this setting + - **"Main folder"** (NULL in the database): Channel downloads to the main folder, whatever the default folder is + - **Specific library folder**: Channel downloads to that folder - **Use Cases**: - Organize untracked manual downloads into a specific folder - Set a default location while allowing individual channels to override - - Explicitly place specific channels in the root directory using "No Subfolder" + - Explicitly place specific channels in the main folder using "Main folder" ### Flat File Structure Default - **Config Key**: `defaultSkipVideoFolder` - **Type**: `boolean` - **Default**: `false` - **Description**: When `true`, new downloads are saved directly in the channel folder (flat structure) instead of an individual per-video subfolder, for every channel that has not chosen its own File Structure setting. +- **UI**: Settings -> Core -> **Naming**, **Flat file structure by default**. Changing it asks for confirmation and names the channels that follow it. - **TV folders**: Ignored for folders with the TV layout (see Main Folder Layout), where episodes always sit directly in `Season NN` folders. - **Channel Override Semantics** (channel setting `skip_video_folder`, edited via the channel's "Video File Structure" select): - **"Use global setting"** (NULL in database): channel follows this global default @@ -170,8 +172,8 @@ Youtarr must be running at the scheduled time; missed occurrences are not replay - **Type**: `string` - **Default**: `"videos"` - **Options**: `"videos"`, `"tv"` -- **Description**: Layout of the main downloads folder, for files saved directly in it rather than in a `__subfolder`. `"videos"` saves each video movie-style, as before. `"tv"` saves channels as TV shows (`<show>/Season NN/SxxEyy - Title [id].ext`) for a TV-type media server library. Each `__subfolder` has its own layout, set in **Settings > Core > File Structure > Library folders**. -- **Note**: Changed only through the Library folders list. When the main folder holds downloaded videos, the change moves them into the other layout after you review the move (the reorganize); a direct change is refused while a download runs. A Settings save keeps the stored value. Switching the main folder to TV writes a `.plexignore` containing `__*/*` to the main folder, so a Plex TV library pointed there skips the subfolders. +- **Description**: Layout of the main downloads folder, for files saved directly in it rather than in a `__subfolder`. `"videos"` saves each video movie-style, as before. `"tv"` saves channels as TV shows (`<show>/Season NN/SxxEyy - Title [id].ext`) for a TV-type media server library. Each `__subfolder` has its own layout, set on Settings -> **Library folders**. +- **Note**: Changed only on Settings -> **Library folders** (`PUT /api/library-folders`, or the reorganize it hands off to). When the main folder holds downloaded videos, the change moves them into the other layout after you review the move (the reorganize); a direct change is refused while a download runs. `/updateconfig` keeps the stored value, so a Settings save never changes it. Switching the main folder to TV writes a `.plexignore` containing `__*/*` to the main folder, so a Plex TV library pointed there skips the subfolders. - **TV folders are video-only**: TV library scanners skip audio files. A channel or playlist whose download type is MP3 cannot be saved to a TV folder, a folder with MP3 channels or playlists cannot switch to TV, and a download request that names a TV folder together with an MP3 type is refused. When the destination is only known per video (a pasted URL whose channel saves to a TV folder, an MP3 playlist with a video from such a channel), the MP3 type is downgraded to video for that video, so the episode is saved as video; an MP3 playlist's such videos therefore have no audio file and are left out of its music playlist sync (`unsyncable_count`). ### Video Filename Template @@ -188,7 +190,7 @@ Youtarr must be running at the scheduled time; missed occurrences are not replay - **Date prefix** (`%(upload_date>%Y-%m-%d)s - %(title).64B`): `2025-10-17 - ESCAPING 99 Nights ... [Cbq15X05wyY].mp4` - **Plex YouTube-Agent** (`%(upload_date>%Y_%m_%d)s %(title).64B`): `2025_10_17 ESCAPING 99 Nights ... [Cbq15X05wyY].mp4` (compatible with [Absolute-Series-Scanner](https://github.com/ZeroQI/Absolute-Series-Scanner) and [YouTube-Agent.bundle](https://github.com/ZeroQI/YouTube-Agent.bundle)) - **Title only** (`%(title).64B`): `ESCAPING 99 Nights ... [Cbq15X05wyY].mp4` -- **UI**: A live preview in **Settings -> Core Settings -> File Structure Settings** shows the rendered folder and file names against a sample video, with length warnings (yellow > 110 chars, red > 130 chars on the rendered name). +- **UI**: A live preview under **Video filename template** in Settings -> Core -> **Naming** shows the rendered folder and file names against a sample video, with length warnings (yellow > 110 chars, red > 130 chars on the rendered name). ### Enable Subtitles - **Config Key**: `subtitlesEnabled` @@ -223,26 +225,27 @@ Youtarr must be running at the scheduled time; missed occurrences are not replay - **Config Key**: `plexYoutubeLibraryId` - **Type**: `string` - **Default**: `""` (empty) -- **Description**: Default Plex library section ID for YouTube videos. Used for all downloads that do not match a per-subfolder mapping (see below). +- **Description**: Default Plex library section ID for YouTube videos. Used for all downloads whose library folder has no mapping of its own (see below). - **Note**: Library refresh is automatically triggered if configured when new videos are downloaded ### Plex Subfolder Library Mappings - **Config Key**: `plexSubfolderLibraryMappings` -- **Type**: `Array<{ subfolder: string | null, libraryId: string }>` -- **Default**: `[]` (empty — all downloads use the default library above) -- **Description**: Maps channel subfolders to specific Plex library IDs, enabling different subfolders to refresh different Plex libraries after a download. -- **Usage**: Configured via the web UI under **Plex Media Server Integration → Per-Subfolder Library Mappings** once connected to Plex. -- **Format**: Each entry specifies a `subfolder` (the clean name without the `__` filesystem prefix, or `null` for the root/no-subfolder case) and the target `libraryId`. +- **Type**: `Array<{ subfolder: string | null, libraryId: string | null }>` +- **Default**: `[]` (empty - all downloads refresh the default library above) +- **Description**: Which Plex library each library folder's downloads refresh, so different folders can refresh different Plex libraries after a download. +- **Usage**: Set per folder on Settings -> **Library folders**: open the folder and choose a library under **After downloads here, Plex refreshes**, or **Remove setting**. Picking a library there needs a Plex connection: the page lists the libraries (and the default library) only while Plex is connected, and Youtarr checks that Plex lists the one you pick. Without a Plex connection the page shows the current setting, plus **Remove setting** (which still works) when the folder has a setting. The API also accepts the default library (`libraryId: null`) without a Plex connection. `PUT /api/library-folders/plex-mapping` (with `replace: true`) and `DELETE /api/library-folders/plex-mapping` change the list; `/updateconfig` keeps the stored list, so a Settings save never changes it. +- **Format**: Each entry specifies a `subfolder` (the folder name without the `__` filesystem prefix, or `null` for the main folder) and the `libraryId` it refreshes. An entry with `libraryId: null` is the explicit choice of the default library: the folder refreshes `plexYoutubeLibraryId`, and Youtarr never maps it automatically. Folder names match ignoring case (`"KIDS"` is the `__Kids` folder). Deleting a library folder removes its entry. - **Example**: ```json "plexSubfolderLibraryMappings": [ { "subfolder": "kids", "libraryId": "2" }, { "subfolder": "music", "libraryId": "3" }, + { "subfolder": "news", "libraryId": null }, { "subfolder": null, "libraryId": "1" } ] ``` -- **Fallback**: Any subfolder not listed here will fall back to `plexYoutubeLibraryId`. -- **TV folders**: Youtarr adds the entry for a TV subfolder when exactly one Plex TV Shows library holds it: automatically in Channel Settings -> TV Show, or with **Refresh this library** in the library check under Settings -> Core -> File Structure. It never replaces an existing entry. +- **Fallback**: A folder without an entry, or with `libraryId: null`, refreshes `plexYoutubeLibraryId`. +- **TV folders**: Youtarr adds the entry for a TV subfolder that has none when exactly one Plex TV Shows library holds it: automatically when you add a TV folder with **Add folder** or open Channel Settings -> TV Show, or with **Refresh this library** on the folder's page in Settings -> **Library folders**. It never replaces an existing entry, including an explicit choice of the default library. ### Plex IP - **Config Key**: `plexIP` diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 06e9c0bec..768e97492 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -36,7 +36,7 @@ Youtarr uses MariaDB/MySQL for storing: | `subfolders` | `Subfolder` | Durable registry of known subfolder names (id, name unique, created_at, updated_at). Backfilled from channels, playlists, and video file paths by the `add-subfolders-table` migration; kept current by register-on-create and register-on-download-override. `layout` (STRING(10) NOT NULL, default `'videos'`): `'videos'` saves movie-style, `'tv'` saves channels as TV shows; the main folder's layout is the `mainFolderLayout` config field. | | `tv_shows` | `TvShow` | TV shows Youtarr writes into TV-layout folders. `channel_id` (owner's YouTube channel id; not a foreign key, untracked channels get shows too), `kind` (`'channel'`: the whole channel as one show, created on the channel's switch to TV or its first download into a TV folder; `'title'`: a series built from title patterns over one channel's videos, see `tv_show_patterns`), `name`, `folder_name` and `library_folder` (`''` = main folder): the show's location, pinned at creation so uploader renames and default-folder changes never start a second show folder, `external_key` (written to `tvshow.nfo`; the channel id for channel shows, a random UUID for title shows), `previous_videos_folder` (the channel's `sub_folder` before it switched to TV, used when switching back), plus, for title shows, `position` (classification order: the first show whose pattern matches takes the video), `exclude_terms` (JSON list; a title containing one never matches), `poster_source` (unused so far) and `retired_at` (a removed show keeps its row and folder name so it can be restored). Unique `(library_folder, folder_name)` (case-insensitive under `utf8mb4_unicode_ci`); a taken or invalid channel show folder name gets the channel id appended, while a title show with a taken name is refused with a suggested one. | | `video_classifications` | `VideoClassification` | One row per video that belongs to a show (`youtube_id` primary key): `channel_id`, `show_id` (foreign key `video_classifications_show_id_fk` to `tv_shows`, RESTRICT), `status` (`assigned`; for title shows also `pending_number` (matched, numbered from the upload time at download), `duplicate` (another upload holds the number, see `episode_conflicts`), `unsupported` (a compilation, a part, or a season or episode number out of range or missing from the title), `opted_out` (the user marked it "Not an episode"; `show_id` keeps the title show it left) and `error`), `season`/`episode` (channel shows: UTC upload year and MMDDHHMM, bumped to the next free number on a collision; title shows: from the title, the pattern's fixed season, the upload year, the upload date, or the next `order` number past the season's high-water mark), `source` (`date`, `title`, `order`, `manual` or `adopted` (a Plex TV Series preset code kept by a reorganize)), `timestamp_source` (`timestamp` or `upload_date`), `pattern_id` (the matching `tv_show_patterns` row; informational, no foreign key, since patterns are replaced on every save), `episode_title`, `file_stem` (the on-disk name, reused on re-download), `title_opt_out` (kept when an opted-out video of a TV channel becomes a channel show episode, so classification never puts it back in a title show). Unique `(show_id, season, episode)`; rows without a number hold NULLs and never collide. Rows are kept when a video's files are deleted, so a re-download returns to the same number. | -| `tv_reorganize_operations` | `TvReorganizeOperation` | One row per reorganize: moving downloaded files when a channel, a library folder or the default subfolder switches between the Videos and TV layouts. `change_type` (`channel`, `folderLayout`, `defaultSubfolder`), `scope`, `settings_change` (MEDIUMTEXT JSON: the approved change, its label, and the shows it pins), `settings_applied`, `revision` (the preview token it was started with), `status` (`running`, `completed`, `partial`, `failed`), `total_items`/`done_items`/`failed_items`, `error`, `started_at`/`finished_at`. A row left `running` by a restart is resumed at startup. | +| `tv_reorganize_operations` | `TvReorganizeOperation` | One row per reorganize: moving downloaded files when a channel, a library folder or the default folder switches between the Videos and TV layouts. `change_type` (`channel`, `folderLayout`, `defaultSubfolder`), `scope`, `settings_change` (MEDIUMTEXT JSON: the approved change, its label, and the shows it pins), `settings_applied`, `revision` (the preview token it was started with), `status` (`running`, `completed`, `partial`, `failed`), `total_items`/`done_items`/`failed_items`, `error`, `started_at`/`finished_at`. A row left `running` by a restart is resumed at startup. | | `tv_reorganize_items` | `TvReorganizeItem` | One row per video of an operation (foreign key `tv_reorganize_items_operation_id_fk`, CASCADE): `youtube_id`, `video_id`, `channel_id` (owner), `title`, `files` (JSON: every file's source and destination, the old and new video and audio paths, the layouts), `classification` (JSON episode assignment, or NULL when the video becomes movie-style), `status` (`pending`, `done`, `failed`), `files_moved` (the files reached their destination; true for every done item and for a failed item whose metadata or row update failed after the move, which a retry finishes), `error`. Failed items, and pending ones of an operation that ended early, can be retried. | | `watch_status_holds` | `WatchStatusHold` | Watch state Youtarr protects while a reorganize moves a video: one row per (`video_id`, `server_type`, `server_user_id`) (unique) with a `snapshot` (JSON: played, play count, resume position, percent, last watched, and `fromPath`, the file path before the move), `operation_id`, `state` (`pending`, `restored`, `failed` after 14 days, `dismissed`), `attempts`, `last_attempt_at`, `last_error` (push-back attempts), `last_pushed_at` (the last attempt that actually wrote the state to the server, as opposed to finding it there) and `expires_at`. While a hold is pending or failed, watch status sync never downgrades that `video_watch_status` row. | | `tv_show_patterns` | `TvShowPattern` | The title patterns of a title show, tried in `position` order (foreign key `tv_show_patterns_show_id_fk`, CASCADE): `pattern_text` and `pattern_kind` (`simple` or `regex`) as the user wrote them, `compiled_regex` (the Python regex classification runs, with named groups) and `filter_regex` (the same regex as a yt-dlp match filter: groups unnamed, flags scoped), `season_source` (`title`, `fixed` with `season_fixed`, or `year`) and `episode_source` (`title`, `date` or `order`). Replaced on every save of the show. | diff --git a/docs/DOCKER.md b/docs/DOCKER.md index bb96f5fda..dae7ac56e 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -134,7 +134,7 @@ See [Troubleshooting](TROUBLESHOOTING.md#docker-desktop--arm-incorrect-informati ``` - Start containers with `docker compose up -d` - Container auto-creates `config.json` -- **UI Behavior**: YouTube Output Directory field is **read-only** - shows "Docker Volume" chip +- **UI Behavior**: Settings -> Core opens with a **Library folders** card whose **Downloads folder** line shows this path, read-only, with "Docker volume, set by YOUTUBE_OUTPUT_DIR. Edit .env and restart to change it." - **Host Path Reminder**: Create the `/your/host/path` directory ahead of time and ensure it is writable. Docker will otherwise create it as root-owned! ### Network Storage diff --git a/docs/MEDIA_SERVERS.md b/docs/MEDIA_SERVERS.md index 25d8d24f7..b9952d669 100644 --- a/docs/MEDIA_SERVERS.md +++ b/docs/MEDIA_SERVERS.md @@ -31,16 +31,16 @@ Youtarr supports integration with multiple media servers, providing rich metadat All media servers receive: - **Video Files**: MP4 with embedded metadata - **Thumbnails**: Video preview images -- **NFO Files**: XML metadata for each video if enabled in Settings -> Core (Download Settings section) -- **Poster Images**: Channel artwork (poster.jpg) if enabled in Settings -> Core (Download Settings section) +- **NFO Files**: XML metadata for each video if enabled in Settings -> Core -> **Media server files** +- **Poster Images**: Channel artwork (poster.jpg) if enabled in Settings -> Core -> **Media server files** -Optional artwork, both off by default and enabled in the same Download Settings section: +Optional artwork, both off by default and enabled in the same **Media server files** section: - **Video Fanart**: `<video name>-fanart.jpg` beside each video, from the video thumbnail. Some Plex clients (NVIDIA Shield, for example) use it as the background preview. - **Backdrop Images**: `backdrop.jpg` in each channel folder (from the channel banner) plus `<video name>-backdrop.jpg` beside each video, used by Emby and Jellyfin for background art. ## TV Shows -A library folder (the main folder or a `__subfolder`) can use the **TV shows** layout instead of Videos. Youtarr then saves each channel in it as a show: year seasons, episodes numbered by upload time (`S2026E09281530` is September 28, 2026, 15:30 UTC), an episode NFO file for every video, and `tvshow.nfo` plus channel art in the show folder. Set a folder's layout under Settings -> Core -> File Structure -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**. See [TV folders](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders) for the layout and [Save Channels as TV Shows](USAGE_GUIDE.md#save-channels-as-tv-shows) for switching channels that already have downloads. +A library folder (the main folder or a `__subfolder`) can use the **TV shows** layout instead of Videos. Youtarr then saves each channel in it as a show: year seasons, episodes numbered by upload time (`S2026E09281530` is September 28, 2026, 15:30 UTC), an episode NFO file for every video, and `tvshow.nfo` plus channel art in the show folder. Set a folder's layout under Settings -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**. See [TV folders](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders) for the layout, [Save Channels as TV Shows](USAGE_GUIDE.md#save-channels-as-tv-shows) for switching channels that already have downloads, and [Move an existing setup to TV shows](USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows) for changing the libraries you already have. Give each TV folder its own TV library, pointed at the folder itself: @@ -53,9 +53,9 @@ Give each TV folder its own TV library, pointed at the folder itself: Not Jellyfin's `Mixed Movies and Shows` or Emby's `Mixed Content`: those decide per folder whether it holds a movie or a series and behaved inconsistently with Youtarr's files in our testing, so the library check reports them on a TV folder as the wrong type. Use Movies (or Plex's Other Videos) libraries for Videos folders and Shows/TV Shows libraries for TV folders. -**One library per folder.** Jellyfin and Emby show a folder in only one library: while another library includes your downloads folder, a TV library inside it stays empty. Plex shows the episodes in both libraries instead. Either way, once you use a TV folder, point your video libraries at the Videos folders one by one rather than at the downloads folder, and give channels saved directly in the downloads folder a subfolder. +**One library per folder once you use TV shows.** While every folder uses Videos, one library on your whole downloads folder shows them all, on every server. Once a `__subfolder` is a TV folder, it needs a library of its own: Jellyfin and Emby show a folder in only one library, so while another library includes your downloads folder a TV library inside it stays empty, and Plex shows the episodes in both libraries instead. So point your video libraries at the Video folders one by one (one library can list several folders) rather than at the downloads folder, and give channels saved directly in the downloads folder a library folder. When the main folder itself uses TV shows, one TV library on the downloads folder shows it instead; with other folders in use (Video or TV show folders) that works on Plex only. [Move an existing setup to TV shows](USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows) walks through each starting setup. -**The library check.** Youtarr reads your servers' libraries and shows, under each folder in Settings -> Core -> File Structure, which libraries hold it and what to fix: no library yet, the wrong library type, the Plex Series agent or a legacy Plex agent, a Jellyfin or Emby library that saves NFO files or looks items up online, another library that includes the same folder, or a missing Plex refresh mapping. Channel Settings -> **TV Show** shows the same for the channel's TV folder, and the review of a move shows it for the TV folders the videos move into. +**The library check.** Youtarr reads your servers' libraries and shows, in Settings -> **Library folders**, on each folder's page, which libraries hold it and what to fix: no library yet, the wrong library type, the Plex Series agent or a legacy Plex agent, a Jellyfin or Emby library that saves NFO files or looks items up online, another library that includes the same folder, or a missing Plex refresh mapping. Channel Settings -> **TV Show** shows the same for the channel's TV folder, and the review of a move shows it for the TV folders the videos move into. **Watch state.** Moving a video between a Videos folder and a TV folder makes it a new item on every server. Moving a show to another TV folder keeps its watch state on Plex (with Plex NFO Series) and Jellyfin; Emby keys watch state by file path. Wherever the state didn't carry over, Youtarr restores the played state and resume position once the server has scanned the moved files: for every Jellyfin and Emby user, and for the Plex server owner. @@ -82,7 +82,7 @@ See [YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.m ### Essential Settings -Navigate to Settings -> Core, Download Settings section: +Navigate to Settings -> Core -> **Media server files**: *The .nfo and poster files are not used by Plex, but it is recommended to leave them enabled since they provide the best compatibility for all media servers* @@ -90,10 +90,10 @@ Disable if you are using Plex-only and want less "clutter" of files | Setting | Default | Purpose | |---------|---------|---------| -| **Generate video .nfo files** | Enabled | Creates metadata for Kodi/Jellyfin/Emby | -| **Copy channel poster.jpg files** | Enabled | Adds channel artwork to folders | -| **Create video fanart files** | Disabled | Writes `-fanart.jpg` per video; some Plex clients use it as the background preview | -| **Create backdrop images** | Disabled | Writes `backdrop.jpg` per channel and `-backdrop.jpg` per video for Emby/Jellyfin background art | +| **Video .nfo files** | Enabled | Creates metadata for Kodi/Jellyfin/Emby | +| **Channel poster.jpg** | Enabled | Adds channel artwork to folders | +| **Video fanart** | Disabled | Writes `-fanart.jpg` per video; some Plex clients use it as the background preview | +| **Backdrop images** | Disabled | Writes `backdrop.jpg` per channel and `-backdrop.jpg` per video for Emby/Jellyfin background art | ### Platform-Specific Settings @@ -138,7 +138,7 @@ See [Youtarr Downloads Folder Structure](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md) **Embedded MP4 Metadata**: | Field | Description | | -------------------- | ----------------------------------------- | -| `title` | `Channel - Title` by default; just the title when **Prefix channel name in embedded video title** is off | +| `title` | `Channel - Title` by default; just the title when **Channel name in embedded title** (Settings -> Core -> **Naming**) is off | | `artist` | Channel name | | `studio` | Channel name | | `album` | Channel name (for collection grouping) | @@ -166,7 +166,7 @@ Youtarr can pull per-video, per-user watch status from Plex, Jellyfin, and Emby, - [Kodi Guide](media-servers/kodi.md) - [Emby Guide](media-servers/emby.md) -2. **Enable Metadata Generation** in Youtarr (Settings -> Core) +2. **Enable Metadata Generation** in Youtarr (Settings -> Core -> **Media server files**) 3. **Add Channels** and let Youtarr download with metadata @@ -175,7 +175,7 @@ Youtarr can pull per-video, per-user watch status from Plex, Jellyfin, and Emby, ### For Existing Users If you already have downloaded videos: -1. Enable metadata generation in Settings -> Core +1. Enable metadata generation in Settings -> Core -> **Media server files** 2. New downloads will include metadata 3. Existing videos won't be retroactively updated 4. Consider deleting and re-downloading if metadata is important diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index dd2cc9b93..bb39fef60 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -169,7 +169,7 @@ The rescan recognizes `.mp4`, `.webm`, `.mkv`, `.m4v`, `.avi`, and `.mp3`. Files **Diagnosis**: Measure the full path as Plex sees it (drive letter through `.mp4`). At 260 characters or more, this is your problem. -**Solution**: Shorten the video's folder and file names on disk, keeping the `[<youtube-id>]` segment in the filename. Then run **Settings -> Maintenance & Rescan -> Rescan files on disk** so Youtarr picks up the new path, let Plex scan the library, and (for playlists) run **Sync now**. To prevent recurrence, shorten the filename template under **Settings -> Core Settings -> Video Filename Template**: reduce the title truncation to the current recommended `%(title).64B` (or smaller), or use a preset without the channel-name prefix; see [Video Filename Template](CONFIG.md#video-filename-template). Only new downloads are affected; existing files keep their names. +**Solution**: Shorten the video's folder and file names on disk, keeping the `[<youtube-id>]` segment in the filename. Then run **Settings -> Maintenance & Rescan -> Rescan files on disk** so Youtarr picks up the new path, let Plex scan the library, and (for playlists) run **Sync now**. To prevent recurrence, shorten the filename template under **Settings -> Core -> Naming -> Video filename template**: reduce the title truncation to the current recommended `%(title).64B` (or smaller), or use a preset without the channel-name prefix; see [Video Filename Template](CONFIG.md#video-filename-template). Only new downloads are affected; existing files keep their names. ## Docker Issues @@ -789,7 +789,7 @@ If you only enabled cookies to get past a "Sign in to confirm you're not a bot" This is the subtitle request (YouTube's timedtext endpoint), not the video: video streams come from `googlevideo.com`. YouTube throttles or hangs subtitle requests that don't look like they come from a browser, so yt-dlp sends them with browser impersonation, which needs the `curl_cffi` library. Youtarr's Docker image includes it; if you see `WARNING: The extractor specified to use impersonation for this download, but no impersonate target is available` in the logs, you are on an older image and should pull the latest. -Even with impersonation, YouTube's subtitle endpoint is flaky at times. When the subtitle fetch fails, yt-dlp still downloads the video; Youtarr keeps the video, does not count it as a failed download, and marks the job "Complete with Warnings". If the extra retry time bothers you, disable subtitles in **Settings -> Core** until it settles. +Even with impersonation, YouTube's subtitle endpoint is flaky at times. When the subtitle fetch fails, yt-dlp still downloads the video; Youtarr keeps the video, does not count it as a failed download, and marks the job "Complete with Warnings". If the extra retry time bothers you, turn off **Subtitles** in **Settings -> Core -> Downloads** until it settles. ### Downloads Fail with "Conversion failed!" or "No space left on device" {#download-out-of-space} @@ -797,7 +797,7 @@ Even with impersonation, YouTube's subtitle endpoint is flaky at times. When the `Conversion failed!` is what ffmpeg reports when it cannot write the merged file. Merging the video and audio streams needs a second copy of the video, so the folder downloads are staged in needs free space of about **twice the video's size**. When Youtarr measures that this is what happened, the failed job shows a "Not enough disk space to finish the download" diagnosis (Downloads page, Download History, and notifications) and the log has a `Download failed because the temporary download folder is out of space` line with the folder and its free space. Youtarr normally removes the failed video's leftover files when that download run ends, so the space comes back on its own; anything left is cleared when the next download job starts. -Which disk is full depends on **Use external temp directory** (Settings -> Core): +Which disk is full depends on **External temp directory** (Settings -> Core -> **Advanced**): - **Off (default)**: downloads are staged in `.youtarr_tmp/` inside your output folder, so your media drive is the one that is short on space. - **On**: downloads are staged inside the container (`/tmp/youtarr-downloads` unless you changed it). Unless you mounted a volume there, this is Docker's own storage. On Docker Desktop (Windows/macOS) that is a virtual disk with a fixed maximum size, shared with every image, the build cache, and every other container; free space on your drives does not count. @@ -826,9 +826,9 @@ docker exec youtarr df -h /tmp/youtarr-downloads docker image prune -a ``` -If large videos regularly do not fit in the external temp path, mount a volume with enough room there, or turn **Use external temp directory** off so staging uses your output drive (some managed platforms, such as ElfHosted, choose the staging location for you). +If large videos regularly do not fit in the external temp path, mount a volume with enough room there, or turn **External temp directory** off so staging uses your output drive (some managed platforms, such as ElfHosted, choose the staging location for you). -A channel video that failed this way is retried by each scheduled download for as long as it is still among the most recent videos the check looks at (**Files to Download per Channel**) and passes the channel's filters, so it normally completes once there is room. If it has dropped out of that window, download it manually. +A channel video that failed this way is retried by each scheduled download for as long as it is still among the most recent videos the check looks at (**Videos per channel tab and playlist**, Settings -> Core -> **Downloads**) and passes the channel's filters, so it normally completes once there is room. If it has dropped out of that window, download it manually. ### No Download Progress Shown (Downloads Work, Videos "Just Appear") @@ -1066,7 +1066,7 @@ The cookies preview endpoint is rate-limited to 3 requests per minute because ea **Solution**: - Verify poster.jpg exists in each channel folder -- Check that "Copy channel poster.jpg files" is enabled in **Settings -> Core** +- Check that **Channel poster.jpg** is enabled in **Settings -> Core -> Media server files** - Ensure media server has read permissions for image files - Some servers cache artwork - try: - Clearing server cache @@ -1090,7 +1090,7 @@ The cookies preview endpoint is rate-limited to 3 requests per minute because ea **Problem**: Videos download but no .nfo files are generated. **Solution**: -- Check that "Generate video .nfo files" is enabled in **Settings -> Core** +- Check that **Video .nfo files** is enabled in **Settings -> Core -> Media server files** (episodes in TV shows folders always get one) - Verify post-processing completed (check container logs) - Ensure write permissions in video directories - Look for errors in logs during post-processing phase diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index f33c001d1..c32a56cb4 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -15,6 +15,7 @@ This guide provides step-by-step instructions for common tasks in Youtarr. After - [Rescan Files on Disk](#rescan-files-on-disk) - [Organize Channels with Multi-Library Support](#organize-channels-with-multi-library-support) - [Save Channels as TV Shows](#save-channels-as-tv-shows) + - [Move an existing setup to TV shows](#move-an-existing-setup-to-tv-shows) - [Browse and Filter Channel Videos](#browse-and-filter-channel-videos) - [Find Videos on YouTube](#find-videos-on-youtube) - [Preview and Play Videos](#preview-and-play-videos) @@ -62,7 +63,7 @@ Subscribe to YouTube channels to automatically download new videos as they're pu - `https://www.youtube.com/channel/UCX6OQ3DkcsbYNE6H8uQQuVA` - Youtarr looks the channel up on YouTube (this can take a few seconds), then opens the **Add channel** dialog with the usual defaults filled in: - **Auto Downloads**: a toggle for each tab the channel has (**New Videos**, **New Shorts**, **New Live/Streams**) - - **Video Quality**, **Download Type** (Video Only, Video + MP3, or MP3 Only), and **Subfolder** + - **Video Quality**, **Download Type** (Video Only, Video + MP3, or MP3 Only), and **Library folder** - Click **Continue** to keep the defaults or your changes. The channel joins the list as a pending addition; use its edit (pencil) button to change these settings before saving. - A channel you subscribed to before comes back with its saved settings filled in. - Click **Save Changes** to subscribe. Filters, ratings, and auto-removal are set later from the channel page. @@ -77,10 +78,10 @@ Subscribe to YouTube channels to automatically download new videos as they're pu - Click **Open in YouTube** next to the channel name to open the channel on YouTube in a new tab - Click **Edit** (the gear button) in the **Channel Settings** bar to open channel settings. The dialog has five tabs: - **General**: - - **Subfolder**: pick or create a subfolder to organize channels into separate media libraries (e.g., `__kids`, `__music`); the picker has an inline **Add Subfolder** action for new names + - **Library folder**: pick or create a library folder to organize channels into separate media libraries (e.g., `__kids`, `__music`); the picker has an **Add library folder** action for new names and a **Manage library folders** link that opens Settings -> **Library folders** in a new tab - **Resolution Override**: a **Channel Video Quality Override** that takes precedence over the global setting - **Video File Structure**: download videos directly into the channel folder (flat) or into individual video subfolders (see [Folder Structure](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md)) - - **Auto Downloads**: separate toggles for **New Videos**, **New Shorts**, and **New Live/Streams**. These only take effect while the global **Enable Automatic Downloads** toggle in Settings -> Core is on. + - **Auto Downloads**: separate toggles for **New Videos**, **New Shorts**, and **New Live/Streams**. These only take effect while the global **Automatic downloads** toggle in Settings -> Core -> **Downloads** is on. - **Filters**: duration limits and a title regex to control which videos auto-download - **Ratings**: a default content rating for this channel's downloads - **Tags**: custom tags that can be automatically added to downloaded videos separated by '|'. This applies only to videos downloaded in the future. @@ -158,7 +159,7 @@ After uploading, Youtarr displays a review table with all discovered channels. - **Auto-download enabled** - toggle automatic downloads on or off - **Video quality** - set a quality override (720p through 2160p, or use the global default) - **Download type** - choose Videos, Shorts, or Livestreams - - **Subfolder** - assign the channel to a subfolder for multi-library organization + - **Library folder** - assign the channel to a library folder for multi-library organization - **Content rating** - set a default content rating (G, PG, PG-13, R, NC-17) - Use the **Enable auto-download** / **Disable auto-download** button to toggle auto-download for all selected channels at once @@ -193,7 +194,7 @@ Subscribe to a YouTube playlist and Youtarr tracks its videos, downloads them, a - If you opened the dialog without a URL first, paste the link inside it and click **Fetch info** 3. **Choose settings and subscribe** - - Below the preview, set **Automatically download new videos**, **Video Quality**, **Download Type**, and **Default Subfolder**. Automatic downloads only pick up videos added to the playlist from now on; choose existing videos to download from the playlist's detail page. + - Below the preview, set **Automatically download new videos**, **Video Quality**, **Download Type**, and **Library folder**. Automatic downloads only pick up videos added to the playlist from now on; choose existing videos to download from the playlist's detail page. - The dialog shows which media servers the playlist will sync to. If you haven't connected any, the videos still download and a `.m3u` file is still written; you just won't get a native server playlist. - Click **Subscribe**. Youtarr pulls in the video list and opens the playlist's detail page. - A playlist you subscribed to before is restored with its saved settings, shown read-only in the dialog; change them from the playlist page afterwards. If you're already subscribed, the dialog offers **Go to playlist** instead. @@ -204,8 +205,8 @@ Subscribe to a YouTube playlist and Youtarr tracks its videos, downloads them, a Playlists don't get their own folder. Each video is saved under the channel that uploaded it, so a playlist that pulls from five channels lands in five channel folders. -- If you're already subscribed to that channel, the video uses that channel's subfolder and quality settings. -- If you're not, the video uses the playlist's default subfolder (your global default unless you change it), and Youtarr creates a hidden channel record behind the scenes to keep future downloads organized. +- If you're already subscribed to that channel, the video uses that channel's library folder and quality settings. +- If you're not, the video uses the playlist's library folder (the default folder unless you change it), and Youtarr creates a hidden channel record behind the scenes to keep future downloads organized. The same video never downloads twice just because it shows up in a playlist. @@ -221,7 +222,7 @@ Open a playlist to manage it: - **Auto-download new videos**: first enable refreshes the playlist and defaults to following future additions only. You can also preview and select an existing batch during setup. Later runs download newly discovered entries wherever they appear, even when the video itself is old. Your global per-run download count applies to new discoveries. Each scheduled run can also retry up to the same number of older saved selections, starting with those attempted least recently. Extra entries wait for later runs; neither allowance borrows unused slots from the other. Already queued or downloading videos do not take another slot. Pause/resume preserves tracking (see [Configure Automation](#configure-automation)). - **Choose existing videos**: select up to a chosen count by newest publication date, beginning of playlist, or end of playlist. Review the titles and adjust checkboxes before queuing. Missing publication dates require a positional or manual choice. This works for existing playlists too and preserves automatic tracking. Selected older videos remain eligible for retry while auto-download is enabled and a starting point exists. - **Follow from now** (in Playlist settings): refreshes the playlist and skips its current undownloaded backlog, including saved batch requests, after confirmation. This preserves whether automatic downloads are running or paused. Files and already queued downloads are kept. -- **Playlist settings**: set a subfolder, resolution, download type, and default rating for this playlist. A video's own channel settings take precedence; these apply when the channel has no override. The download type also decides whether the playlist syncs to media servers as a video or music playlist (see [Switching a playlist's download type](MEDIA_SERVER_PLAYLISTS.md#switching-a-playlists-download-type)). +- **Playlist settings**: set a library folder, resolution, download type, and default rating for this playlist. A video's own channel settings take precedence; these apply when the channel has no override. The download type also decides whether the playlist syncs to media servers as a video or music playlist (see [Switching a playlist's download type](MEDIA_SERVER_PLAYLISTS.md#switching-a-playlists-download-type)). - **Sync chips**: one per media server. Click to enable or disable sync for that server, or click an unconfigured server to jump to its settings. - **Public on media servers**: makes the playlist visible to other users on Jellyfin and Emby. Plex playlists are always created under one account and shared manually, so this setting doesn't affect Plex. - **Sync now** and **Rebuild .m3u file**: push the current state to your servers or regenerate the `.m3u` on demand. Sync runs in the background and can take a minute or two while your media server's library scan finishes. @@ -428,7 +429,8 @@ Create separate media server libraries for different content types (e.g., kids c ### How to Set Up Multi-Library Organization 1. **Plan your library structure** - - Decide on subfolder names (convention: use `__` prefix like `__kids`, `__music`) + - Decide on folder names. Youtarr saves library folders with a `__` prefix on disk, like `__kids` and `__music`, so leave the underscores out when you name one. + - You can create them ahead of time on Settings -> **Library folders** with **Add folder**, or while assigning a channel (next step). - Examples: - `__kids` - Child-friendly YouTube channels - `__music` - Music videos and concerts @@ -436,20 +438,20 @@ Create separate media server libraries for different content types (e.g., kids c - `__education` - Educational content - `__gaming` - Gaming content -2. **Assign channels to subfolders** +2. **Assign channels to library folders** - Go to the Channels & Playlists page - Click on a channel - Click the settings icon (gear) - - Pick or create a subfolder with the **Subfolder** field (use its **Add Subfolder** action for a new name) + - Pick or create a folder with the **Library folder** field (use its **Add library folder** action for a new name) - Save changes 3. **Configure your media server** - Create separate libraries in your media server (Plex/Jellyfin/etc.) - - Point each library to a specific subfolder: + - Point each library to a specific library folder: - Library 1: `/path/to/downloads/__kids` - Library 2: `/path/to/downloads/__music` - - Library 3: `/path/to/downloads` (for channels without a subfolder) - - Jellyfin and Emby show a folder in only one library, so a library at `/path/to/downloads` leaves libraries for its subfolders empty there; give every channel a subfolder instead. Plex shows those videos in both libraries. + - Library 3: `/path/to/downloads` (Plex only, for channels saved in the main folder) + - Jellyfin and Emby show a folder in only one library, so a library at `/path/to/downloads` leaves libraries for its subfolders empty there; give every channel a library folder instead. Plex shows those videos in both libraries. See [Supported library setups](#supported-library-setups). 4. **Apply restrictions and sharing** - Configure library-specific access controls in your media server @@ -463,16 +465,113 @@ Media servers show Youtarr's videos as movies. A **TV folder** shows them as TV ### Set up a TV folder - **From a channel**: open the channel page, click the settings icon (gear), open **TV Show** and click **TV show**. Without a TV folder yet, Youtarr asks for a name (default `TV Shows`, saved as `__TV Shows`), creates the folder and switches the channel to it. With several TV folders, you pick one. -- **From Settings**: under Settings -> Core -> File Structure -> **Library folders**, set a folder's layout to **TV shows**. Every channel that downloads to that folder becomes a show. +- **From Settings**: on Settings -> **Library folders**, click **Add folder** and choose the **TV shows** layout, or open an existing folder and choose **Use for TV shows** (**Move to TV shows** when its downloads have to move). Every channel that downloads to that folder becomes a show. -Then add a TV library for the folder on your media server (see [Media Server Integration](MEDIA_SERVERS.md#tv-shows)). The **Media servers** box under Channel Settings -> **TV Show** shows, per server, whether a library holds the folder and what to fix. For Plex, Youtarr adds the folder's refresh mapping as soon as it finds the one TV Shows library that holds it. +Then add a TV library for the folder on your media server (see [Media Server Integration](MEDIA_SERVERS.md#tv-shows)). If your media server already has libraries for your downloads, read [Move an existing setup to TV shows](#move-an-existing-setup-to-tv-shows) first. The folder's page in Settings -> **Library folders** and the **Media servers** box under Channel Settings -> **TV Show** show, per server, whether a library holds the folder and what to fix. For Plex, Youtarr adds the folder's refresh mapping as soon as it finds the one TV Shows library that holds it. What changes for a TV channel: - Episodes go to `<TV folder>/<channel>/Season <year>/` and are named by upload time in UTC: `S2026E09281530 - Title [id].mp4` was uploaded on September 28 at 15:30. See [TV folders](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders). - The download type is video only, and the file structure and channel playlist (.m3u) options don't apply. - The channel page and Subscriptions show a **TV** chip, and videos show their episode code. -- If the default subfolder is a TV folder, a download from a channel you haven't subscribed to becomes a show of its own. +- If the default folder is a TV folder, a download from a channel you haven't subscribed to becomes a show of its own. + +### Move an existing setup to TV shows + +TV shows is optional. Videos is Youtarr's existing movie-style layout, and upgrading Youtarr changes nothing: your folders keep their layout, channels and playlists keep their library folders, and the default folder, your file names and your Plex refresh mappings stay as they are. Youtarr never switches a folder to TV shows on its own, even one named `TV Shows`. + +On Settings -> **Library folders**, **Start using TV shows** reads your folders and your media server libraries, offers the paths below that fit your setup, and fills in your library names and paths for each server. This section covers every setup, including media servers Youtarr isn't connected to. + +#### Supported library setups + +A Video folder is shown by a Videos library pointed at the folder itself or at a folder above it, such as your whole downloads folder. A TV show folder needs a TV library pointed at the folder itself. A folder is in use when channels or playlists download into it, it is the default folder, or it holds downloaded videos. + +| Your setup | Supported | Why | +|---|---|---| +| One library on your whole downloads folder, while every folder uses Videos | Yes | Most upgraded installs look like this. The library shows the main folder and every Video folder. | +| A library for each folder, or one library that lists several folders | Yes | The setup to use once you have a TV show folder. Plex, Jellyfin and Emby libraries can each list several folders. | +| A library on your whole downloads folder, plus a TV show folder | No | Jellyfin and Emby show a folder in one library only, so the TV library stays empty. Plex shows every episode twice: in the TV library and again as a plain video. | +| The main folder uses TV shows, with no other folder in use | Yes | One TV library on the downloads folder. Youtarr writes a `.plexignore` there so Plex skips the `__subfolders`. | +| The main folder uses TV shows, with other folders in use (Video or TV show folders) | Plex only | Jellyfin and Emby show a folder in one library only: the TV library on the downloads folder shows each `__subfolder` as an extra show, and the subfolder's own library stays empty. | + +The library check on each folder's page in Settings -> **Library folders** reports an unsupported setup and shows the library edit that fixes it. + +A library's type can't be changed after you create it, on Plex, Jellyfin or Emby. You can still edit which folders a library shows: it keeps its type, and the watch state of the videos it still shows stays. So moving a folder from Videos to TV shows means a new TV library for that folder on each server. + +| Server | Library for Video folders | Library for TV show folders | +|---|---|---| +| Plex | Other Videos | TV Shows | +| Jellyfin | Movies | Shows | +| Emby | Movies | TV shows | +| Kodi | Movies source | TV shows source | + +The settings for each kind of library are in [Media Server Integration](MEDIA_SERVERS.md#tv-shows). **Start using TV shows** shows them too, under **Library settings**. + +#### Path A: Add a TV show folder next to your Video folders + +Recommended when you already have downloads. Your Video folders stay as they are, and the new folder gets its own TV library. + +In Youtarr: + +1. If channels download straight into the main folder and one library shows your whole downloads folder, give each of those channels a Video folder first: Channel Settings -> **Library folder** moves its videos. Once that library stops showing the whole downloads folder, it can't show videos saved in the main folder. Youtarr doesn't restore watch state for moves between Video folders, so on Jellyfin and Emby these videos can come back unwatched. +2. On Settings -> **Library folders**, click **Add folder**, enter a name and choose the **TV shows** layout. +3. Switch the channels you want as shows to the new folder: Channel Settings -> **Library folder**, or Channel Settings -> **TV Show** -> **Show this channel as** -> **TV show**. A channel that has downloads shows its moves in **Review the move** first (see [Switch a channel or folder that already has downloads](#switch-a-channel-or-folder-that-already-has-downloads)). + +On each media server: + +- **One library on your whole downloads folder**: edit it, remove the downloads folder and add each Video folder instead, for example `/path/to/downloads/__kids`. It keeps its type, and the watch state of its videos stays. Then add a TV library pointed at the new TV show folder only. +- **A library for each folder**: add a TV library pointed at the new TV show folder only. +- **No library yet**: add a TV library pointed at the new TV show folder only. Your Video folders need a Videos library too. + +#### Path B: Make the whole downloads folder TV shows + +Every channel in the main folder becomes a show, and one TV library shows the downloads folder. This fits when no other folder is in use. With other folders in use, Video or TV show folders, it works on Plex only: Plex skips the `__subfolders`, but Jellyfin and Emby would show each one as an extra show and leave its own library empty. + +In Youtarr: + +1. On Settings -> **Library folders**, open the main folder and choose **Move to TV shows** (**Use for TV shows** when nothing has to move). Youtarr asks you to confirm; when downloads have to move, it then shows every move in **Review the move**. + +On each media server: + +- **A Videos library (Other Videos, Movies) on your whole downloads folder, and no Video folder in use**: its type can't change, so remove it and create a TV library pointed at the downloads folder. +- **The same library, with Video folders in use (Plex only)**: keep it for your Video folders. Edit it, remove the downloads folder and add your Video folders instead. It keeps its type, and the watch state of their videos stays. Then create a TV Shows library pointed at the downloads folder. +- **Already a TV library on your downloads folder** (for example from the Plex TV Series preset): keep it. +- **No library on the downloads folder**: create a TV library pointed at the downloads folder. +- **Video folders in use (Plex only) with no Other Videos library of their own**: the TV library skips them, so point an Other Videos library at your Video folders as well. +- **TV show folders in use (Plex only)**: keep their TV libraries as they are. The TV library on the downloads folder skips them. + +#### Path C: Turn a Video folder into a TV show folder + +The folder's channels become shows and their videos move. You review every move first. + +In Youtarr: + +1. If channels download straight into the main folder and one library shows your whole downloads folder, give them a Video folder first, as in path A. Youtarr doesn't restore watch state for those moves between Video folders. +2. On Settings -> **Library folders**, open the folder and choose **Move to TV shows** (**Use for TV shows** when nothing has to move). Review the moves, then click **Move N videos**. + +On each media server: + +- **One library on your whole downloads folder**: edit it, remove the downloads folder and add your other Video folders. It keeps its type, and the watch state of its videos stays. If no other Video folder is in use, remove the library instead. Then add a TV library pointed at this folder only. +- **A library on this folder alone**: its type can't change, so remove it and create a TV library pointed at the folder. +- **No library on this folder**: create a TV library pointed at the folder. + +Kodi isn't checked by Youtarr. On every path, add each TV show folder to Kodi as its own **TV shows** source set to **Local information only**, and keep your Video folders in a **Movies** source (see [Kodi](media-servers/kodi.md#tv-shows)). + +#### Watch state + +For the moves Youtarr shows in **Review the move** (into, out of or between TV show folders), Youtarr restores played state and resume positions once the new library has scanned them: for every Jellyfin and Emby user, and for the Plex owner account (see [Restore watch state after a move](#restore-watch-state-after-a-move)). Moves between Video folders, such as step 1 of paths A and C, get no restore. Editing a library's folders keeps the watch state of the videos it still shows. A library you remove and create again starts over: played state and resume positions in it aren't restored. + +Do the steps in Youtarr first, then change your libraries right away, before the next watch status sync (every 4 hours by default): + +- Removing or editing a library before the move can lose the watch state Youtarr would restore. +- Until you remove or edit it, the old library still shows the moved files, so Youtarr restores the watch state there, and the next sync counts the restore as done. A TV library you create after that starts over. + +Settings -> **Watch Status** shows, under **Watch state restores**, how many restores are still waiting for the media servers. + +#### Plex only + +- **.plexignore**: when the main folder uses TV shows, Youtarr writes a `.plexignore` there with `__*/*`, so a Plex TV library on the downloads folder skips your `__subfolders`. Jellyfin and Emby can't skip them. +- **Episodes shown twice**: Plex shows a folder in every library that includes it. A library on your whole downloads folder next to a TV library shows each episode a second time as a plain video, and Youtarr restores watch state to only one of the copies. The library check reports this on the TV show folder's page. ### Switch a channel or folder that already has downloads @@ -490,7 +589,7 @@ Click **Move N videos** to start. While the files move, a banner shows on every - **Videos not moved**: the result lists them with **Retry**. The channel's settings also say how many videos were not moved, with a **Review** link to the result. - **Restarted**: a move interrupted by a restart resumes when Youtarr starts. -The same review opens when you change the default subfolder (Settings -> Core) to a folder with the other layout while channels on the default have downloads. Save again afterwards to apply your other changes. +The same review opens when you make a folder with the other layout the default folder (Settings -> Library folders -> **Make default**) while channels that follow the default have downloads. ### Title shows: series inside a channel @@ -498,7 +597,7 @@ Some channels upload real series: "Hermitcraft 10: Episode 5 - ...", "BEYBLADE E Open the channel page, click the settings icon (gear), open **TV Show**, and under **Shows in this channel** click **Add show**: -- **Name** and **Folder name** (the show folder; the name by default). A title show needs a TV folder: Youtarr uses the channel's TV folder, else the default subfolder if it is a TV folder, else your only TV folder, and asks when there is more than one. Two shows can't share a folder name in one TV folder; Youtarr suggests `<Name> (<Channel>)`, or offers to restore a removed show that used the name. +- **Name** and **Folder name** (the show folder; the name by default). A title show needs a TV folder: Youtarr uses the channel's TV folder, else the default folder if it is a TV folder, else your only TV folder, and asks when there is more than one. Two shows can't share a folder name in one TV folder; Youtarr suggests `<Name> (<Channel>)`, or offers to restore a removed show that used the name. - **Title patterns**, tried in order. Text matches ignoring case and spaces match any spacing; `*` matches any text; a pattern matches anywhere in the title unless it starts with `^`. Placeholders capture the numbers and the episode title: `{season}`, `{episode}` and `{title}`. For example `Hermitcraft {season}: Episode {episode} - {title}`. **Edit as regular expression** shows the pattern as a Python regular expression with the named groups `season`, `episode` and `title`; backreferences and conditional groups aren't supported there (channel downloads use the same regular expression with its groups unnamed). - **Season** and **Episode** per pattern: the season from the title, a fixed season (0 is specials), or the upload year; the episode from the title, the next number in the season (given once, oldest upload first, and never reused), or the upload time (with upload-year seasons only, like channel shows). A video not downloaded yet gets its upload-year season when it downloads; a downloaded video's year is already known, so the preview shows its number. - **Exclude titles containing**: a title with any of these words never joins the show, for example `Official Clip`. diff --git a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md index 4f67e8a51..de1c93195 100644 --- a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md +++ b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md @@ -6,7 +6,7 @@ By default, videos in each channel folder are placed into their own subfolders w ### File and Folder Names -The examples below use the default **Video Filename Template** (Settings -> Core -> File Structure Settings), which names files `<Channel> - <Title>` with the title capped at 64 bytes. You can change the template or pick a preset (Date prefix, Plex YouTube-Agent, Plex TV Series, Title only); see [Video Filename Template](CONFIG.md#video-filename-template). Whatever template you choose, Youtarr always appends: +The examples below use the default **Video filename template** (Settings -> Core -> **Naming**), which names files `<Channel> - <Title>` with the title capped at 64 bytes. You can change the template or pick a preset (Date prefix, Plex YouTube-Agent, Plex TV Series, Title only); see [Video Filename Template](CONFIG.md#video-filename-template). Whatever template you choose, Youtarr always appends: - ` [<youtube-id>]` to file names, for example `Channel - Title [dQw4w9WgXcQ].mp4` - ` - <youtube-id>` to per-video folder names, for example `Channel - Title - dQw4w9WgXcQ/` (a dash, not brackets) @@ -15,26 +15,26 @@ Youtarr uses these ID suffixes to find your videos on disk, so they cannot be ch ### Expected Default Layout -This is the folder/file layout for channels that do not have a configured subfolder setting. It -is also used for manually downloaded files from channels that are not set up in `Channels & Playlists`, as long as the global **Default Subfolder** setting is empty. +This is the folder/file layout for channels saved to the main folder: their **Library folder** setting is **Main folder**, or **Default folder** while the main folder is the default folder. It +is also used for manually downloaded files from channels that are not set up in `Channels & Playlists`, as long as the main folder is the **Default folder** (Settings -> **Library folders**). ``` <YOUTUBE_OUTPUT_DIR>/ ├── Channel Name/ │ ├── poster.jpg # Channel poster -│ ├── backdrop.jpg # Optional; written from the channel banner when "Create backdrop images" is on +│ ├── backdrop.jpg # Optional; written from the channel banner when "Backdrop images" is on │ ├── Channel Name.m3u # Optional; written when the channel's "Generate channel playlist file" setting is on │ └── Channel - Title - id/ │ ├── Channel - Title [id].mp4 # Video file │ ├── Channel - Title [id].nfo # Video metadata │ ├── Channel - Title [id].[lang].srt # Subtitle file(s) │ ├── Channel - Title [id].jpg # Video thumbnail -│ ├── Channel - Title [id]-fanart.jpg # Optional; written when "Create video fanart files" is on -│ └── Channel - Title [id]-backdrop.jpg # Optional; written when "Create backdrop images" is on +│ ├── Channel - Title [id]-fanart.jpg # Optional; written when "Video fanart" is on +│ └── Channel - Title [id]-backdrop.jpg # Optional; written when "Backdrop images" is on ├── Another Channel/ ``` -The optional artwork and `.m3u` files follow the channel wherever it lives; they are written the same way in the subfolder and flat layouts below. +The optional artwork is turned on under Settings -> Core -> **Media server files** (**Video fanart**, **Backdrop images**). The optional artwork and `.m3u` files follow the channel wherever it lives; they are written the same way in the subfolder and flat layouts below. ## Layout For Channels with Subfolder Settings @@ -52,13 +52,13 @@ YouTube Downloads/ ├── __Music/ # Music subfolder │ └── Music Channel/ │ └── [videos] -└── Regular Channel/ # Channel with no subfolder setting +└── Regular Channel/ # Channel saved to the main folder └── [videos] ``` ## TV Folders -A library folder (the main folder or a `__subfolder`) can use the **TV shows** layout instead of Videos: set it under Settings -> Core -> File Structure -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**. Every channel that downloads to a TV folder is saved as a show: +A library folder (the main folder or a `__subfolder`) can use the **TV shows** layout instead of Videos: set it on Settings -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**. Every channel that downloads to a TV folder is saved as a show: ``` YouTube Downloads/ @@ -66,7 +66,7 @@ YouTube Downloads/ │ └── Channel Name/ # The show folder │ ├── tvshow.nfo # Show metadata (always written) │ ├── poster.jpg # Channel poster -│ ├── backdrop.jpg # Optional; "Create backdrop images" +│ ├── backdrop.jpg # Optional; "Backdrop images" │ ├── Season 2025/ │ │ └── S2025E12011500 - Title [id].mp4 │ └── Season 2026/ @@ -74,8 +74,8 @@ YouTube Downloads/ │ ├── S2026E09281530 - Title [id].nfo # Episode metadata (always written) │ ├── S2026E09281530 - Title [id].jpg # Episode thumbnail │ ├── S2026E09281530 - Title [id].[lang].srt -│ ├── S2026E09281530 - Title [id]-fanart.jpg # Optional; "Create video fanart files" -│ └── S2026E09281530 - Title [id]-backdrop.jpg # Optional; "Create backdrop images" +│ ├── S2026E09281530 - Title [id]-fanart.jpg # Optional; "Video fanart" +│ └── S2026E09281530 - Title [id]-backdrop.jpg # Optional; "Backdrop images" └── __Kids/ # A Videos folder, laid out as above ``` @@ -84,10 +84,10 @@ YouTube Downloads/ - **No video folders and no channel `.m3u`**: TV folders are always flat inside season folders, and video-only (MP3 download types aren't offered for them). - **Titles** in file names are cut to 64 bytes; the full title is in the episode NFO file and the embedded MP4 title (never prefixed with the channel name). - **Deleting** an episode in Youtarr removes its files, then an empty season folder, then the show folder once it holds only show files. -- **Downloads from channels you haven't subscribed to** that land in a TV folder (a manual download, a playlist, or a TV default subfolder) each become a show of their own channel. +- **Downloads from channels you haven't subscribed to** that land in a TV folder (a manual download, a playlist, or a TV default folder) each become a show of their own channel. - **Main folder as TV**: when the main folder uses the TV shows layout, Youtarr writes a `.plexignore` containing `__*/*` there so a Plex TV library pointed at it skips your subfolders. Jellyfin and Emby can't skip them, so they'd show each `__subfolder` as an extra show. -Switching a channel or a folder that already has downloads between Videos and TV shows moves its files into the other layout; you review the move first. See [Save Channels as TV Shows](USAGE_GUIDE.md#save-channels-as-tv-shows). +Switching a channel or a folder that already has downloads between Videos and TV shows moves its files into the other layout; you review the move first. See [Save Channels as TV Shows](USAGE_GUIDE.md#save-channels-as-tv-shows), and [Move an existing setup to TV shows](USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows) for changing the libraries you already have. ### Title shows @@ -113,7 +113,7 @@ Videos that match no title show stay where the channel puts them: its Videos fol You can use a flat file structure, where video files are placed directly in the channel folder instead of individual video subfolders. It only affects new downloads. There are three places to set it: -- **Global default**: turn on **Flat file structure by default** in Settings -> Core -> File Structure Settings. Every channel that has not chosen its own structure follows this, as do downloads from untracked channels. See [Flat File Structure Default](CONFIG.md#flat-file-structure-default). +- **Global default**: turn on **Flat file structure by default** in Settings -> Core -> **Naming**. Every channel that has not chosen its own structure follows this, as do downloads from untracked channels. See [Flat File Structure Default](CONFIG.md#flat-file-structure-default). - **Per channel**: in the channel settings dialog, set "Video File Structure" to "Flat (no video subfolders)" or "Video subfolders" to override the global default. The default option, "Use global setting", follows the global default. - **One download**: the manual download settings dialog can force either structure for a single download. diff --git a/docs/development/ELFHOSTED.md b/docs/development/ELFHOSTED.md index 5484d5477..dcad6a846 100644 --- a/docs/development/ELFHOSTED.md +++ b/docs/development/ELFHOSTED.md @@ -29,36 +29,36 @@ The codebase encodes these assumptions through four orthogonal environment varia ### `PLATFORM` -The Elfhosted-specific switch. Detected by `configModule.isElfhostedPlatform()` in `server/modules/configModule.js:276` (case-insensitive `=== 'elfhosted'`). Also surfaced to the frontend as `deploymentEnvironment.platform` via `/getconfig` (`server/routes/config.js:127-128`). +The Elfhosted-specific switch. Detected by `configModule.isElfhostedPlatform()` in `server/modules/configModule.js:321` (case-insensitive `=== 'elfhosted'`). Also surfaced to the frontend as `deploymentEnvironment.platform` via `/getconfig` (`server/routes/config.js:164-165`). When `PLATFORM=elfhosted`: -- **Backend, on every config save and reload:** `useTmpForDownloads` is forced to `true` and `tmpFilePath` is forced to `/app/config/temp_downloads` (`configModule.js:61, 322, 429`). These overrides are runtime-only; they are stripped from the persisted `config.json` so they do not survive a platform switch (`configModule.js:343`). -- **Backend, on startup with `DATA_PATH` also set:** the temp downloads directory `/app/config/temp_downloads` is auto-created (`configModule.js:295`). -- **Backend, nightly cron (4:00 AM):** the yt-dlp auto-update job no-ops, regardless of the `autoUpdateYtdlp` config toggle (`server/modules/cronJobs.js:116`). -- **Backend, manual update route:** `POST /api/ytdlp/update` returns `403` with `{ success: false, message: 'yt-dlp is managed by the platform and cannot be updated from Youtarr.' }` (`server/routes/health.js:248-252`). -- **Backend, `/getconfig` response:** the `isPlatformManaged` object exposes `useTmpForDownloads: true` and `ytdlpUpdates: true` so the frontend can disable the corresponding controls (`server/routes/config.js:123-124`). -- **Frontend:** the Youtarr-version-update banner is suppressed (`client/src/App.tsx:91, 95`). When the `/tmp` directory warning fires, an Elfhosted setup-guide link is appended (`App.tsx:576-588`). In Settings, the "Use external temp directory" control (Settings -> Core) and the yt-dlp card (Settings -> YT-DLP) show "Managed by Elfhosted" chips and platform-managed messages (`client/src/components/Configuration/sections/CoreSettingsSection.tsx:640-652`, `client/src/components/Configuration/sections/YtdlpUpdateSection.tsx:104-116`). +- **Backend, on every config save and reload:** `useTmpForDownloads` is forced to `true` and `tmpFilePath` is forced to `/app/config/temp_downloads` (`configModule.js:107-108, 372-373, 486-487`). These overrides are runtime-only; they are stripped from the persisted `config.json` so they do not survive a platform switch (`configModule.js:393-394`). +- **Backend, on startup with `DATA_PATH` also set:** the temp downloads directory `/app/config/temp_downloads` is auto-created (`configModule.js:340-346`). +- **Backend, nightly cron (4:00 AM):** the yt-dlp auto-update job no-ops, regardless of the `autoUpdateYtdlp` config toggle (`server/modules/cronJobs.js:178`). +- **Backend, manual update route:** `POST /api/ytdlp/update` returns `403` with `{ success: false, message: 'yt-dlp is managed by the platform and cannot be updated from Youtarr.' }` (`server/routes/health.js:277-282`). +- **Backend, `/getconfig` response:** the `isPlatformManaged` object exposes `useTmpForDownloads: true` and `ytdlpUpdates: true` so the frontend can disable the corresponding controls (`server/routes/config.js:160-161`). +- **Frontend:** the Youtarr-version-update banner is suppressed (`client/src/App.tsx:92, 96`). When the `/tmp` directory warning fires, an Elfhosted setup-guide link is appended (`App.tsx:577-589`). In Settings, the "External temp directory" control (Settings -> Core -> **Advanced**) and the yt-dlp card (Settings -> YT-DLP) show "Managed by Elfhosted" chips and platform-managed messages (`client/src/components/Configuration/sections/CoreSettingsSection.tsx:191-198`, `client/src/components/Configuration/sections/YtdlpUpdateSection.tsx:105-121`), and the downloads folder on Core's **Library folders** card says the platform configures it (`client/src/components/Configuration/sections/components/LibraryFoldersCard.tsx:32-36, 82-83`). ### `AUTH_ENABLED` Independent of `PLATFORM`. When `AUTH_ENABLED=false`: - `/setup/status` returns `{ requiresSetup: false, platformManaged: true, message: 'Authentication is managed by the platform' }` (`server/routes/setup.js`). -- The frontend auto-logs in with the synthetic token `'platform-managed-auth'` and skips the login screen (`client/src/App.tsx:344-349`). -- All API endpoints accept requests without a token. The bypass is implemented in two places: `verifyToken` short-circuits when `AUTH_ENABLED === 'false'` (`server/server.js:395`), and the local-login route does the same (`server/routes/auth.js:101`). -- Login and logout buttons are hidden in the UI; `isPlatformManaged.authEnabled` is `false` in `/getconfig` (`server/routes/config.js:122`). The frontend logout button gates on the App-level `isPlatformManaged` boolean (`client/src/components/layout/NavHeaderActions.tsx:157`), and the Youtarr-version-update tooltip is suppressed by the same flag (`NavHeaderActions.tsx:43`). +- The frontend auto-logs in with the synthetic token `'platform-managed-auth'` and skips the login screen (`client/src/App.tsx:345-349`). +- All API endpoints accept requests without a token. The bypass is implemented in two places: `verifyToken` short-circuits when `AUTH_ENABLED === 'false'` (`server/server.js:443`), and the local-login route does the same (`server/routes/auth.js:101`). +- Login and logout buttons are hidden in the UI; `isPlatformManaged.authEnabled` is `false` in `/getconfig` (`server/routes/config.js:159`). The frontend logout button gates on the App-level `isPlatformManaged` boolean (`client/src/components/layout/NavHeaderActions.tsx:159`), and the Youtarr-version-update tooltip is suppressed by the same flag (`NavHeaderActions.tsx:44`). -This is the standard way Elfhosted bypasses Youtarr's local-account flow. It can also be enabled in dev with the `--no-auth` flag to `start-dev.sh` (`scripts/_shared_start_tasks.sh:90-94`). +This is the standard way Elfhosted bypasses Youtarr's local-account flow. It can also be enabled in dev with the `--no-auth` flag to `start-dev.sh` (`scripts/_shared_start_tasks.sh:313-318`). ### `DATA_PATH` -Independent of `PLATFORM`. Detected by `configModule.isPlatformDeployment()` (`configModule.js:272`). When set: +Independent of `PLATFORM`. Detected by `configModule.isPlatformDeployment()` (`configModule.js:317`). When set: -- The container reads/writes downloaded videos under `DATA_PATH` instead of the default `/usr/src/app/data` (`configModule.js:30, 533`). -- Persistent data relocates from `./jobs` to `/app/config/jobs/`, and from `./server/images` to `/app/config/images/` (`configModule.js:304-316`). -- `/getconfig` reports `isPlatformManaged.youtubeOutputDirectory: true` so the UI shows the path read-only (`server/routes/config.js:120`). -- `youtubeOutputDirectory` in the UI resolves to `process.env.YOUTUBE_OUTPUT_DIR || process.env.DATA_PATH` (`server/routes/config.js:133`). +- The container reads/writes downloaded videos under `DATA_PATH` instead of the default `/usr/src/app/data` (`configModule.js:52, 634`). +- Persistent data relocates from `./jobs` to `/app/config/jobs/`, and from `./server/images` to `/app/config/images/` (`configModule.js:349-361`). +- `/getconfig` reports `isPlatformManaged.youtubeOutputDirectory: true` so the UI shows the path read-only (`server/routes/config.js:157`). +- `youtubeOutputDirectory` in the UI resolves to `process.env.YOUTUBE_OUTPUT_DIR || process.env.DATA_PATH` (`server/routes/config.js:171`). `DATA_PATH` is documented in `docs/ENVIRONMENT_VARIABLES.md` and `docs/DOCKER.md`. Most self-hosters never need it. @@ -66,8 +66,8 @@ Independent of `PLATFORM`. Detected by `configModule.isPlatformDeployment()` (`c Independent of `PLATFORM`. When set: -- The Plex IP, port, and HTTPS fields are disabled in the UI (`isPlatformManaged.plexUrl: true` from `server/routes/config.js:121`). -- The value seeds `config.plexUrl` on first-run config creation (`configModule.js:228-231`). +- The Plex IP, port, and HTTPS fields are disabled in the UI (`isPlatformManaged.plexUrl: true` from `server/routes/config.js:158`). +- The value seeds `config.plexUrl` on first-run config creation (`configModule.js:273-276`). - `config.plexUrl` takes precedence over `plexIP`/`plexPort`/`plexViaHttps` everywhere it is consulted (see `docs/CONFIG.md`). Used by Elfhosted to point Youtarr at the bundled Plex instance. @@ -106,7 +106,7 @@ This exports `PLATFORM=elfhosted` and `AUTH_ENABLED=false` for the run, prints a The flag does **not** set `DATA_PATH` or `PLEX_URL`, because those need real values, not constants. Without them: - Plex IP/port fields in Settings remain editable (no "Platform Managed" lock). - Persistent data stays in the normal dev locations (`./jobs/`, `./server/images/`) instead of relocating to `/app/config/jobs/` and `/app/config/images/`. -- The `youtubeOutputDirectory` field shows the env-var-managed copy, not the platform-managed copy. +- The **Downloads folder** line on Core's **Library folders** card still shows the Elfhosted copy: it follows `PLATFORM`, not `DATA_PATH` (`DATA_PATH` alone, without `PLATFORM`, shows "Set by the DATA_PATH environment variable."). The Library folders page header follows `DATA_PATH` only, so it still says "set by YOUTUBE_OUTPUT_DIR". That is fine for almost all dev work; downloads and the new yt-dlp gating both work end-to-end. Set the extra vars only when you specifically need to test code that consults `DATA_PATH` (storage-status path, persistent-data relocation, image/jobs directories) or `PLEX_URL` (Plex URL locking, `plexUrl` config seeding). @@ -143,17 +143,18 @@ After `./scripts/start-dev.sh --as-elfhosted`: - The startup log includes a `yt-warn` line "Spoofing Elfhosted deployment for this run." followed by what is and is not spoofed. - No login screen - the app auto-authenticates with the synthetic `'platform-managed-auth'` token (from the `AUTH_ENABLED=false` half of the spoof). -- The logout button is hidden in the nav header (`NavHeaderActions.tsx:157`). -- The Youtarr self-update banner does not appear, even when a newer Youtarr version is published to Docker Hub (suppressed via `isElfHosted` in `client/src/App.tsx:91, 95`). -- Settings -> Core Settings, in the **Use external temp directory** row: "Managed by Elfhosted" chip, the checkbox is disabled and forced on. +- The logout button is hidden in the nav header (`NavHeaderActions.tsx:159`). +- The Youtarr self-update banner does not appear, even when a newer Youtarr version is published to Docker Hub (suppressed via `isElfHosted` in `client/src/App.tsx:92, 96`). +- Settings -> Core -> **Advanced**, in the **External temp directory** row: "Managed by Elfhosted" chip, the switch is disabled and forced on. +- Settings -> Core, the **Library folders** card: the **Downloads folder** line reads "This path is configured by your platform deployment and cannot be changed here." (`LibraryFoldersCard.tsx:32-36, 82-83`). - Settings -> YT-DLP, in the **yt-dlp Version & Updates** card: "Managed by Elfhosted" chip, no Update button, no release-channel picker, no auto-update toggle, replacement caption: "yt-dlp is managed by Elfhosted and cannot be updated from Youtarr. Updates are applied automatically by the platform." - `curl -i -X POST http://localhost:3087/api/ytdlp/update` returns `403` with the platform-managed message body. - Downloads still work; they stage in `./config/temp_downloads/` on the host (auto-created on first download). -- If the `/tmp` warning fires (e.g., your `YOUTUBE_OUTPUT_DIR` resolves to a `/tmp` path), the warning Snackbar gets an extra "Elfhosted setup guide" link (`App.tsx:576-588`). +- If the `/tmp` warning fires (e.g., your `YOUTUBE_OUTPUT_DIR` resolves to a `/tmp` path), the warning Snackbar gets an extra "Elfhosted setup guide" link (`App.tsx:577-589`). Additional behavior only when you also set `DATA_PATH` in `.env`: -- Settings -> Core Settings, the `youtubeOutputDirectory` field's helper text changes to "This path is configured by your platform deployment and cannot be changed here." (`CoreSettingsSection.tsx:568-569`). +- Settings -> **Library folders**: the page header says the downloads folder is "set by DATA_PATH" instead of "set by YOUTUBE_OUTPUT_DIR" (`client/src/components/LibraryFolders/components/PageHeader.tsx:17`). It follows `isPlatformManaged.youtubeOutputDirectory` and ignores `PLATFORM`, unlike the Core card. - Persistent data is read from `/app/config/jobs/` and `/app/config/images/` (bind-mounted to `./config/jobs/` and `./config/images/` on the host) instead of `./jobs/` and `./server/images/`. Additional behavior only when you also set `PLEX_URL` in `.env`: @@ -171,46 +172,47 @@ When you make a change that depends on Elfhosted detection, audit these location **Backend** - `server/modules/configModule.js` - - `isElfhostedPlatform()` (line 276) - the canonical check - - `isPlatformDeployment()` (line 272) - `DATA_PATH`-based, often paired with the above + - `isElfhostedPlatform()` (line 321) - the canonical check + - `isPlatformDeployment()` (line 317) - `DATA_PATH`-based, often paired with the above - `getImagePath()`, `getJobsPath()`, `getStorageStatus()` - relocate paths when `DATA_PATH` is set - The constructor and `updateConfig()` / `saveConfig()` / `watchConfig()` re-apply the temp-downloads override -- `server/modules/cronJobs.js:116` - nightly yt-dlp auto-update skip -- `server/routes/health.js:248` - `POST /api/ytdlp/update` returns 403 -- `server/routes/config.js:119-133` - `/getconfig` exposes `isPlatformManaged.{useTmpForDownloads, ytdlpUpdates, youtubeOutputDirectory, plexUrl, authEnabled}` and `deploymentEnvironment.platform` +- `server/modules/cronJobs.js:178` - the yt-dlp update task skips every run; its Run now is refused (`getRunBlocker`, lines 236-238) and its timer stays off (line 254) +- `server/routes/health.js:277` - `POST /api/ytdlp/update` returns 403 +- `server/routes/config.js:156-171` - `/getconfig` exposes `isPlatformManaged.{youtubeOutputDirectory, plexUrl, authEnabled, useTmpForDownloads, ytdlpUpdates}`, `deploymentEnvironment.platform` and `youtubeOutputDirectory` - `server/routes/setup.js:74-81` - `AUTH_ENABLED=false` short-circuits setup status (returns `platformManaged: true`) - `server/routes/auth.js:101` - local-login route is a no-op when `AUTH_ENABLED=false` -- `server/server.js:395` - the `verifyToken` middleware bypasses auth when `AUTH_ENABLED=false` +- `server/server.js:443` - the `verifyToken` middleware bypasses auth when `AUTH_ENABLED=false` **Frontend** - `client/src/App.tsx` - - `isPlatformManaged` boolean state (line 76), set from the `/setup/status` response and threaded into the layout (lines 494, 511) - - `isElfHosted` (line 91) and version-banner suppression (line 95) - - `/setup/status` `platformManaged` handling and synthetic-token auto-login (lines 344-349) - - `/tmp` warning Elfhosted link (lines 576-588) -- `client/src/hooks/useConfig.ts:22-70` - `isPlatformManaged` (the per-flag object) and `deploymentEnvironment` are split out of the `/getconfig` response and exposed via context to the rest of the app. Note: this is a different object from the App-level `isPlatformManaged` boolean above; both exist for historical reasons. + - `isPlatformManaged` boolean state (line 77), set from the `/setup/status` response and threaded into the layout (lines 495, 512) + - `isElfHosted` (line 92) and version-banner suppression (line 96) + - `/setup/status` `platformManaged` handling and synthetic-token auto-login (lines 345-349) + - `/tmp` warning Elfhosted link (lines 577-589) +- `client/src/hooks/useConfig.ts:29-38, 65-85` - `isPlatformManaged` (the per-flag object) and `deploymentEnvironment` are split out of the `/getconfig` response and exposed via context to the rest of the app. Note: this is a different object from the App-level `isPlatformManaged` boolean above; both exist for historical reasons. - `client/src/components/layout/NavHeaderActions.tsx` - - Hides the logout button when the App-level `isPlatformManaged` boolean is true (line 157) - - Suppresses the Youtarr-version-update tooltip via the same flag (line 43) -- `client/src/components/layout/NavHeader.tsx:28, 245` and `client/src/components/layout/AppShell.tsx:20, 203` - thread `isPlatformManaged` from `App.tsx` down to `NavHeaderActions.tsx` + - Hides the logout button when the App-level `isPlatformManaged` boolean is true (line 159) + - Suppresses the Youtarr-version-update tooltip via the same flag (line 44) +- `client/src/components/layout/NavHeader.tsx:28, 245` and `client/src/components/layout/AppShell.tsx:22, 206` - thread `isPlatformManaged` from `App.tsx` down to `NavHeaderActions.tsx` - `client/src/components/Settings/Settings.tsx` - - Uses `isPlatformManaged.plexUrl` to determine whether a Plex server is "configured" (line 80) - - Passes `isPlatformManaged` into the Core, Plex, and YT-DLP sections and `isPlatformManaged.authEnabled` into Account Security (lines 303, 318, 404, 445) - - Skips the post-save yt-dlp version re-check when `isPlatformManaged.ytdlpUpdates` is true (line 220) -- `client/src/components/Configuration/sections/CoreSettingsSection.tsx` - - YouTube Output Directory helper text branches on `deploymentEnvironment.platform === 'elfhosted'` (lines 568-569) - - "Use external temp directory" chip and disabled state (lines 627-657) -- `client/src/components/Configuration/sections/YtdlpUpdateSection.tsx` - the whole yt-dlp card: version display, Update button, release-channel picker, auto-update toggle. When `isPlatformManaged.ytdlpUpdates` is true, all of those are replaced by the chip and the platform-managed caption (lines 59-60, 104-116) -- `client/src/components/Configuration/sections/PlexIntegrationSection.tsx:160-180` - Plex URL field locking when `PLEX_URL` is set -- `client/src/components/Configuration/types.ts:78-83` - `PlatformManagedState` type; add a new flag here when you add a new platform-managed control + - Uses `isPlatformManaged.plexUrl` to determine whether a Plex server is "configured" (line 86) + - Passes `isPlatformManaged` into the Core section, the Library folders page, and the Scheduling, Plex and YT-DLP sections, and `isPlatformManaged.authEnabled` into Account Security (lines 307, 322, 336, 348, 433, 486) + - Skips the post-save yt-dlp version re-check when `isPlatformManaged.ytdlpUpdates` is true (line 226) +- `client/src/components/Configuration/sections/CoreSettingsSection.tsx` - "External temp directory" chip and disabled state, in the **Advanced** section (lines 191-198) +- `client/src/components/Configuration/sections/components/LibraryFoldersCard.tsx` - the **Downloads folder** line's text branches on `deploymentEnvironment.platform === 'elfhosted'`, then `isPlatformManaged.youtubeOutputDirectory` (lines 32-36, 82-83) +- `client/src/components/LibraryFolders/components/PageHeader.tsx:17` - the Library folders page header's "set by DATA_PATH" / "set by YOUTUBE_OUTPUT_DIR" follows `isPlatformManaged.youtubeOutputDirectory` only, not `PLATFORM` +- `client/src/components/Configuration/sections/SchedulingSection.tsx:115` - the yt-dlp update schedule row is marked managed when `isPlatformManaged.ytdlpUpdates` is true: no Run now (`components/ScheduleTaskRow.tsx:135`) and "Updates are managed by your hosting platform." (`components/ScheduleTaskDetails.tsx:10`) +- `client/src/components/Configuration/sections/YtdlpUpdateSection.tsx` - the whole yt-dlp card: version display, Update button, release-channel picker, auto-update toggle. When `isPlatformManaged.ytdlpUpdates` is true, all of those are replaced by the chip and the platform-managed caption (lines 60-61, 105-121) +- `client/src/components/Configuration/sections/PlexIntegrationSection.tsx:159-216` - Plex URL field locking when `PLEX_URL` is set +- `client/src/components/Configuration/types.ts:83-90` - `PlatformManagedState` type; add a new flag here when you add a new platform-managed control **Tests** -- `server/modules/__tests__/configModule.test.js:406-568` - covers `isElfhostedPlatform()`, `isPlatformDeployment()`, and the temp-download override behavior. Uses `process.env.PLATFORM = 'elfhosted'` directly. +- `server/modules/__tests__/configModule.test.js:519-616, 671-683` - covers `isElfhostedPlatform()`, `isPlatformDeployment()`, and the temp-download override behavior. Uses `process.env.PLATFORM = 'elfhosted'` directly. - `server/modules/__tests__/cronJobs.test.js` - see the `'yt-dlp auto-update cron job (4:00 AM)'` describe block; `mockConfigModule.isElfhostedPlatform.mockReturnValue(true)` is the gating pattern. - `server/__tests__/server.routes.test.js` - `'server routes - configuration'` and `'server routes - yt-dlp update'` describe blocks; `configModuleMock.isElfhostedPlatform.mockReturnValue(true)` toggles platform mode per-test. -- `client/src/App.test.tsx:429-540` - frontend Elfhosted-detection tests; injects `deploymentEnvironment: { platform: 'elfhosted' }` into the mocked `useConfig`. +- `client/src/App.test.tsx:429-542` - frontend Elfhosted-detection tests; injects `deploymentEnvironment: { platform: 'elfhosted' }` into the mocked `useConfig`. - `client/src/components/Configuration/sections/__tests__/YtdlpUpdateSection.test.tsx` - platform-managed coverage for the yt-dlp card; uses `createPlatformManagedState({ ytdlpUpdates: true })` and `createDeploymentEnvironment({ platform: 'elfhosted' })`. ## Testing Patterns diff --git a/docs/media-servers/emby.md b/docs/media-servers/emby.md index 45ec1958e..c746c5d2b 100644 --- a/docs/media-servers/emby.md +++ b/docs/media-servers/emby.md @@ -54,7 +54,7 @@ Configure folder settings: - `/path/to/youtube/__kids` - `/path/to/youtube/__music` - Emby shows a folder in only one library, so don't add a library at `/path/to/youtube` next to subfolder libraries: the subfolder libraries would stay empty. One library per folder; see [One library per folder](#one-library-per-folder). + While every folder uses Videos, one library at `/path/to/youtube` shows them all. Emby shows a folder in only one library, though, so don't add a library at `/path/to/youtube` next to subfolder libraries: the subfolder libraries would stay empty. Once you use a TV folder, give each TV folder its own library and list the Video folders in your Movies library; see [One library per folder](#one-library-per-folder). ### Step 3: Configure Library Settings @@ -72,13 +72,13 @@ In the library's settings (Emby 4.10 labels; everything not listed can keep its | Movie Image Fetchers | All internet fetchers off | Youtarr writes `poster.jpg` per channel and a thumbnail per video; Emby reads local images on its own. **Image Capture** can stay on: it only grabs a frame when no thumbnail exists | | Save artwork into media folders | Off | | | Generate chapters for videos that don't contain embedded chapter information | Off | Youtarr's files carry no chapter markers, so Emby would add one every 5 minutes to every video and extract a frame for each at scan time | -| Subtitle Downloaders / Automatic Subtitle Downloads | Off / no download languages | OpenSubtitles has nothing for YouTube videos. Youtarr's own subtitle option (Settings -> Core) saves YouTube captions as an `.srt` next to the video, which Emby picks up | +| Subtitle Downloaders / Automatic Subtitle Downloads | Off / no download languages | OpenSubtitles has nothing for YouTube videos. Youtarr's own **Subtitles** option (Settings -> Core -> **Downloads**) saves YouTube captions as an `.srt` next to the video, which Emby picks up | > **Warning**: Do NOT enable Emby's NFO metadata saver. Youtarr generates and maintains the `.nfo` file for every video it downloads. If the saver is enabled, Emby will update and overwrite those files with its own data (for example, incorrectly guessed season/episode tags), which can cause problems for your library. ## TV Shows -Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> Core -> File Structure -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo`, `poster.jpg` and (when enabled) `backdrop.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC, so Emby lists episodes in upload order with large numbers (`S2026:E9281530` is September 28 at 15:30). +Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo`, `poster.jpg` and (when enabled) `backdrop.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC, so Emby lists episodes in upload order with large numbers (`S2026:E9281530` is September 28 at 15:30). ### Library setup @@ -96,19 +96,23 @@ Add a library with content type **TV shows** for each TV folder and point it at | Series, Season and Episode Image Fetchers | All internet fetchers off | Youtarr writes `poster.jpg`, `backdrop.jpg` and an episode thumbnail; Emby reads local images on its own. Episode **Image Capture** can stay on: it only grabs a frame when no thumbnail exists | | Save artwork into media folders | Off | | | Generate chapters for videos that don't contain embedded chapter information | Off | Youtarr's files carry no chapter markers, so Emby would add one every 5 minutes to every episode and extract a frame for each at scan time | -| Subtitle Downloaders / Automatic Subtitle Downloads | Off / no download languages | OpenSubtitles has nothing for YouTube videos. Youtarr's own subtitle option (Settings -> Core) saves YouTube captions as an `.srt` next to the video, which Emby picks up | +| Subtitle Downloaders / Automatic Subtitle Downloads | Off / no download languages | OpenSubtitles has nothing for YouTube videos. Youtarr's own **Subtitles** option (Settings -> Core -> **Downloads**) saves YouTube captions as an `.srt` next to the video, which Emby picks up | | Enable support for .plexignore files as an alias to .embyignore | Off | Youtarr writes a `.plexignore` only in a TV main folder, for Plex; a library at a subfolder never sees one | Youtarr's library check (below) reports the NFO saver and the online metadata downloaders when they are on. ### One library per folder -Emby shows a folder in only one library, so a TV Shows library for `__TV Shows` stays empty while another library includes your downloads folder. Once you use a TV folder: +While every folder uses Videos, one Movies library on your whole downloads folder shows them all. Emby shows a folder in only one library, though, so a TV shows library for `__TV Shows` stays empty while another library includes your downloads folder. Once a `__subfolder` is a TV folder: -- Point your Movies library at your Videos folders one by one, not at the downloads folder. -- Channels saved directly in the downloads folder (no subfolder) can only be reached through a library at the downloads folder, which would include the TV folder. Give those channels a subfolder first (Channel Settings -> Subfolder). +- Point your Movies library at your Video folders one by one, not at the downloads folder. Editing a library's folders keeps its type, and the watch state of its videos stays. +- Channels saved directly in the downloads folder (no subfolder) can only be reached through a library at the downloads folder, which would include the TV folder. Give those channels a library folder first (Channel Settings -> **Library folder**). -Youtarr's library check points this out: Settings -> Core -> File Structure lists, under each folder, the libraries that hold it and anything to fix (also a library of the wrong type, the NFO saver, or internet metadata providers). Channel Settings -> **TV Show** shows the same for the channel's TV folder. +When the main folder itself uses TV shows, point one TV shows library at the downloads folder instead. Emby shows each `__subfolder` there as an extra show, and the subfolder's own library stays empty, so this fits only while no other folder is in use (Video or TV show). + +[Move an existing setup to TV shows](../USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows) walks through each starting setup. + +Youtarr's library check points this out in Settings -> **Library folders**, on each folder's page: the libraries that hold it and anything to fix (also a library of the wrong type, the NFO saver, or internet metadata providers). Channel Settings -> **TV Show** shows the same for the channel's TV folder. ### Watch state @@ -134,7 +138,7 @@ Emby reads comprehensive NFO files containing: Youtarr provides: - **`poster.jpg`**: Channel artwork in channel folders - **`<VIDEO NAME>.jpg`**: Video thumbnails in video folders -- **`backdrop.jpg`**: Channel background art from the YouTube channel banner, written when "Create backdrop images" is enabled in Settings -> Core (off by default) +- **`backdrop.jpg`**: Channel background art from the YouTube channel banner, written when "Backdrop images" is enabled in Settings -> Core -> **Media server files** (off by default) - **`<VIDEO NAME>-backdrop.jpg`**: Per-video background art from the video thumbnail, controlled by the same setting (new downloads only) - Proper naming conventions for Emby recognition @@ -274,7 +278,7 @@ Configure in Advanced settings: **Cause**: Emby's mixed-library TV-detection heuristics misread episode-like video titles. Emby's own documentation notes that support for mixed content is limited. -**Solution**: Change the library to `Movies` (or recreate it as `Movies`) and rescan. Channel `.m3u` playlists will no longer auto-import; see [Choosing a library type](#choosing-a-library-type) for the tradeoff. +**Solution**: Emby can't change a library's type, so remove the Mixed Content library, create a `Movies` library for the same folders, and scan. A library you remove and create again starts over: played state and resume positions in it aren't restored. Channel `.m3u` playlists will no longer auto-import; see [Choosing a library type](#choosing-a-library-type) for the tradeoff. ### Metadata Not Loading @@ -322,7 +326,7 @@ Configure in Advanced settings: **Cause**: Another library includes the TV folder (usually a library at the downloads folder). Emby shows a folder in one library only. -**Solution**: Point the other library at your Videos folders one by one instead of the downloads folder, then scan. See [One library per folder](#one-library-per-folder). +**Solution**: Edit the other library: remove the downloads folder and add your Video folders one by one instead, then scan. It keeps its type, and the watch state of its videos stays. See [One library per folder](#one-library-per-folder) and [Move an existing setup to TV shows](../USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows). ### Duplicate Entries diff --git a/docs/media-servers/jellyfin.md b/docs/media-servers/jellyfin.md index a9ba3bf7b..573bbdd30 100644 --- a/docs/media-servers/jellyfin.md +++ b/docs/media-servers/jellyfin.md @@ -53,7 +53,7 @@ Add your Youtarr download directory: - Kids: `/path/to/youtube/__kids` - Music: `/path/to/youtube/__music` - Jellyfin shows a folder in only one library, so don't add a library at `/path/to/youtube` next to subfolder libraries: the subfolder libraries would stay empty. One library per folder; see [One library per folder](#one-library-per-folder). + While every folder uses Videos, one library at `/path/to/youtube` shows them all. Jellyfin shows a folder in only one library, though, so don't add a library at `/path/to/youtube` next to subfolder libraries: the subfolder libraries would stay empty. Once you use a TV folder, give each TV folder its own library and list the Video folders in your Movies library; see [One library per folder](#one-library-per-folder). ### Step 3: Configure Metadata Sources @@ -73,7 +73,7 @@ In the library's settings (everything not listed can keep its default): ## TV Shows -Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> Core -> File Structure -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo`, `poster.jpg` and (when enabled) `backdrop.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC, so Jellyfin lists episodes in upload order with large numbers (`9281530. Title` is September 28 at 15:30). +Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo`, `poster.jpg` and (when enabled) `backdrop.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC, so Jellyfin lists episodes in upload order with large numbers (`9281530. Title` is September 28 at 15:30). ### Library setup @@ -94,12 +94,16 @@ Youtarr's library check (below) reports the Nfo saver and the online metadata do ### One library per folder -Jellyfin shows a folder in only one library. A library whose folder sits inside another library's folder is skipped (the Jellyfin log says `Found duplicate path`), so a Shows library for `__TV Shows` stays empty while another library includes your downloads folder. Once you use a TV folder: +While every folder uses Videos, one Movies library on your whole downloads folder shows them all. Jellyfin shows a folder in only one library, though. A library whose folder sits inside another library's folder is skipped (the Jellyfin log says `Found duplicate path`), so a Shows library for `__TV Shows` stays empty while another library includes your downloads folder. Once a `__subfolder` is a TV folder: -- Point your Movies library at your Videos folders one by one, not at the downloads folder. -- Channels saved directly in the downloads folder (no subfolder) can only be reached through a library at the downloads folder, which would include the TV folder. Give those channels a subfolder first (Channel Settings -> Subfolder). +- Point your Movies library at your Video folders one by one, not at the downloads folder. Editing a library's folders keeps its type, and the watch state of its videos stays. +- Channels saved directly in the downloads folder (no subfolder) can only be reached through a library at the downloads folder, which would include the TV folder. Give those channels a library folder first (Channel Settings -> **Library folder**). -Youtarr's library check points this out: Settings -> Core -> File Structure lists, under each folder, the libraries that hold it and anything to fix (also a library of the wrong type, the Nfo saver, or online metadata downloaders). Channel Settings -> **TV Show** shows the same for the channel's TV folder. +When the main folder itself uses TV shows, point one Shows library at the downloads folder instead. Jellyfin shows each `__subfolder` there as an extra show, and the subfolder's own library stays empty, so this fits only while no other folder is in use (Video or TV show). + +[Move an existing setup to TV shows](../USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows) walks through each starting setup. + +Youtarr's library check points this out in Settings -> **Library folders**, on each folder's page: the libraries that hold it and anything to fix (also a library of the wrong type, the Nfo saver, or online metadata downloaders). Channel Settings -> **TV Show** shows the same for the channel's TV folder. ### Watch state @@ -129,7 +133,7 @@ Jellyfin reads NFO files containing: Youtarr provides: - **`poster.jpg`**: Channel artwork in each channel folder - **`<VIDEO NAME>.jpg`**: Video thumbnail in each video folder -- **`backdrop.jpg`**: Channel background art from the YouTube channel banner, written when "Create backdrop images" is enabled in Settings -> Core (off by default) +- **`backdrop.jpg`**: Channel background art from the YouTube channel banner, written when "Backdrop images" is enabled in Settings -> Core -> **Media server files** (off by default) - **`<VIDEO NAME>-backdrop.jpg`**: Per-video background art from the video thumbnail, controlled by the same setting (new downloads only) - Proper image naming for Jellyfin recognition @@ -237,7 +241,7 @@ Organize content by type: **Cause**: Jellyfin's mixed-library TV-detection heuristics misread episode-like video titles or folder names starting with digits. Jellyfin has deprecated this library type. -**Solution**: Change the library's content type to `Movies` (or recreate the library as `Movies`) and rescan. Channel `.m3u` playlists will no longer auto-import; see [Choosing a library type](#choosing-a-library-type) for the tradeoff. +**Solution**: Jellyfin can't change a library's content type, so remove the Mixed library, create a `Movies` library for the same folders, and scan. A library you remove and create again starts over: played state and resume positions in it aren't restored. Channel `.m3u` playlists will no longer auto-import; see [Choosing a library type](#choosing-a-library-type) for the tradeoff. ### Poster Issues @@ -259,7 +263,7 @@ Organize content by type: **Cause**: Another library includes the TV folder (usually a library at the downloads folder). Jellyfin shows a folder in one library only and skips the nested one; its log says `Found duplicate path`. -**Solution**: Point the other library at your Videos folders one by one instead of the downloads folder, then scan. See [One library per folder](#one-library-per-folder). +**Solution**: Edit the other library: remove the downloads folder and add your Video folders one by one instead, then scan. It keeps its type, and the watch state of its videos stays. See [One library per folder](#one-library-per-folder) and [Move an existing setup to TV shows](../USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows). ## File Structure diff --git a/docs/media-servers/kodi.md b/docs/media-servers/kodi.md index 6965f41b0..2bee1cafe 100644 --- a/docs/media-servers/kodi.md +++ b/docs/media-servers/kodi.md @@ -47,7 +47,7 @@ Configure the following settings: ## TV Shows -Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> Core -> File Structure -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo` and `poster.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC. +Youtarr can save channels as TV shows in a **TV folder**: a library folder whose layout is TV shows (Settings -> **Library folders**, or Channel Settings -> **TV Show**). Each channel becomes a show with year seasons and episodes named `Season 2026/S2026E09281530 - Title [id].mp4`, each with an episode NFO file and a thumbnail, plus `tvshow.nfo` and `poster.jpg` in the show folder. The episode number is the upload's month, day, hour and minute in UTC. To add a TV folder to Kodi: diff --git a/docs/media-servers/plex.md b/docs/media-servers/plex.md index 2fce1fa66..75e9840d6 100644 --- a/docs/media-servers/plex.md +++ b/docs/media-servers/plex.md @@ -26,7 +26,7 @@ Youtarr provides full Plex integration with: ## Library Setup -Each Youtarr library folder (the main downloads folder and each `__subfolder`) has a layout, set under Settings -> Core -> File Structure -> **Library folders**: +Each Youtarr library folder (the main downloads folder and each `__subfolder`) has a layout, set under Settings -> **Library folders**: - **Videos** (the default): give the folder an **Other Videos** library. - **TV shows**: give the folder a **TV Shows** library. Youtarr saves each channel there as a show, with year seasons and an episode NFO file for every video; see [TV Shows](#tv-shows). @@ -71,11 +71,11 @@ Point the library to your Youtarr download directory: - Default: `/path/to/youtube` - Or specific subfolder: `/path/to/youtube/__kids` -Once one of your folders is a TV folder, point the Other Videos library at your Videos folders only (one location per folder). A library at the whole download directory would show the TV folder's episodes a second time. +Once one of your folders is a TV folder, point the Other Videos library at your Video folders only (one location per folder). A library at the whole download directory would show the TV folder's episodes a second time. See [Move an existing setup to TV shows](../USAGE_GUIDE.md#move-an-existing-setup-to-tv-shows). ### TV Shows -Use a TV Shows library for each Youtarr **TV folder**: a library folder whose layout is TV shows. To make one, set a folder's layout under Settings -> Core -> File Structure -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**, which can create the folder for you. Files are saved as `__TV Shows/<Show>/Season 2026/S2026E09281530 - Title [id].mp4`, where the episode number is the upload's month, day, hour and minute (UTC); see [TV folders](../YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders). +Use a TV Shows library for each Youtarr **TV folder**: a library folder whose layout is TV shows. To make one, set a folder's layout under Settings -> **Library folders**, or switch a channel to **TV show** under Channel Settings -> **TV Show**, which can create the folder for you. Files are saved as `__TV Shows/<Show>/Season 2026/S2026E09281530 - Title [id].mp4`, where the episode number is the upload's month, day, hour and minute (UTC); see [TV folders](../YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md#tv-folders). #### Step 1: Create the library @@ -103,17 +103,17 @@ Don't use the **Plex Series** agent: it looks shows up online and can match a ch #### Step 3: Refresh mapping -After a download Youtarr refreshes the Plex library mapped to the folder the video landed in (Settings -> Plex -> subfolder library mappings), or the default YouTube library when the folder has no mapping. Youtarr fills this in for TV folders: whenever Channel Settings -> **TV Show** opens for a channel in a TV folder that has no mapping yet, it checks your Plex libraries and adds the mapping as soon as it finds the one TV Shows library that holds the folder. You can also add it from the library check under Settings -> Core -> File Structure. Youtarr never changes a mapping that exists, so to have a TV folder refresh a different library, change its mapping under Settings -> Plex rather than deleting it (a deleted mapping is filled in again the next time the check runs). +After a download Youtarr refreshes the Plex library mapped to the folder the video landed in (the folder's page in Settings -> **Library folders**, **After downloads here, Plex refreshes**), or the default YouTube library when the folder has no mapping. Youtarr fills this in for TV folders: when you add a TV folder with **Add folder**, or whenever Channel Settings -> **TV Show** opens for a channel in a TV folder that has no mapping yet, it checks your Plex libraries and adds the mapping as soon as it finds the one TV Shows library that holds the folder. You can also add it from the library check in Settings -> **Library folders**, on each folder's page. Youtarr never changes a mapping that exists, so to have a TV folder refresh a different library, pick that library in the same list rather than removing the setting. Choosing the default library there is kept as your choice too: Youtarr won't replace it with the TV Shows library. **Remove setting** clears the folder's mapping, and works even while Youtarr can't reach Plex; a TV folder's removed mapping is filled in again the next time Channel Settings -> **TV Show** opens for one of its channels. #### Checking your setup -Under Settings -> Core -> File Structure -> **Library folders**, each folder lists the Plex libraries that hold it and anything to fix: no TV Shows library yet, a library of the wrong type, the Plex Series agent or a legacy agent, another library that shows the same episodes again, or a missing refresh mapping. Channel Settings -> **TV Show** shows the same for the channel's TV folder, and the review of a move shows it for the TV folders the videos move into. +In Settings -> **Library folders**, each folder's page lists the Plex libraries that hold it and anything to fix: no TV Shows library yet, a library of the wrong type, the Plex Series agent or a legacy agent, another library that shows the same episodes again, or a missing refresh mapping. Channel Settings -> **TV Show** shows the same for the channel's TV folder, and the review of a move shows it for the TV folders the videos move into. Avoid libraries that include a TV folder from a parent folder, such as a "YouTube - All" library pointed at your downloads folder: Plex shows every episode there a second time as a plain video, and Youtarr restores watch state to only one of the copies. Two TV Shows libraries pointed at the same folder have the same problem. #### Plex TV Series filename preset -The **Plex TV Series** preset (Settings -> Core -> Video Filename Template) names files like episodes but keeps everything else movie-style: no season folders, no episode NFO files. If you used it and your Plex TV Shows library points at your downloads folder, you can set the main folder's layout to **TV shows**: Youtarr writes a `.plexignore` with `__*/*` there so that library skips your subfolders, and when your channels' files move into season folders they keep the episode numbers the preset gave them. For a new setup, use a TV folder instead. +The **Plex TV Series** preset (Settings -> Core -> **Naming**, **Video filename template**) names files like episodes but keeps everything else movie-style: no season folders, no episode NFO files. If you used it and your Plex TV Shows library points at your downloads folder, you can keep that library and switch the main folder to **TV shows** on Settings -> **Library folders** (path B in [Move an existing setup to TV shows](../USAGE_GUIDE.md#path-b-make-the-whole-downloads-folder-tv-shows)): Youtarr writes a `.plexignore` with `__*/*` there so that library skips your subfolders, and when your channels' files move into season folders they keep the episode numbers the preset gave them. For a new setup, use a TV folder instead. ## Youtarr Settings @@ -221,7 +221,7 @@ See: [docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md](../YOUTARR_DOWNLOADS_FOLDER_ST <img width="1478" height="1248" alt="Plex Video Details" src="https://github.com/user-attachments/assets/f146ba72-abe0-4e4d-93bb-6f34cea8e5e5" /> ### Metadata Display -- **Title**: Video title with channel prefix, from the embedded MP4 title (turn off **Prefix channel name in embedded video title** in Settings -> Core for plain titles). Episodes in a TV folder are always tagged with the plain title. +- **Title**: Video title with channel prefix, from the embedded MP4 title (turn off **Channel name in embedded title** in Settings -> Core -> **Naming** for plain titles). Episodes in a TV folder are always tagged with the plain title. - **Description**: Full YouTube description - **Studio**: Channel name for grouping - **Album**: Channel name (alternative grouping) diff --git a/docs/platforms/synology.md b/docs/platforms/synology.md index 52b218d67..25ac09d6e 100644 --- a/docs/platforms/synology.md +++ b/docs/platforms/synology.md @@ -797,7 +797,7 @@ If you have existing data in `./database/` that you want to preserve: **Solutions**: - Schedule downloads during off-peak hours (configure via Youtarr UI) -- Lower **Files to Download per Channel/Playlist** in Settings -> Core, or set a **Download Rate Limit** in Settings -> YT-DLP +- Lower **Videos per channel tab and playlist** in Settings -> Core -> **Downloads**, or set a **Download Rate Limit** in Settings -> YT-DLP - Lower video quality settings to reduce processing time - Disable SponsorBlock integration (if enabled) @@ -953,7 +953,7 @@ If your NAS has SSD cache: ### Schedule Downloads During Low-Activity Periods Configure Youtarr's cron schedule for late night: -- Settings -> Core, "Download Frequency" field +- Settings -> **Scheduling**, the **Automatic downloads** schedule (a daily time, or a custom cron expression) - Example: `0 2 * * *` (runs at 2 AM daily) ---