Add Pinchflat-style media manager: index channels into playlist folders

Index an entire YouTube channel or playlist once, then download it into
organized <Channel>/<Playlist>/<NNN> - <Title> folders, with incremental
re-sync. Built in six rebuild-gated phases (F-K); see ROADMAP-PINCHFLAT.md.

- F: channel-walk indexer (/playlists + /videos) -> persisted Source +
  MediaItem JSON stores; pure classify/dedup logic in indexerCore.ts
- G: Windows-safe folder paths + dir sanitizer (collectionOutputTemplate)
- H: Library tab + source tree + "Download pending" into the existing queue
- I: state-preserving re-index merge + persist-downloaded-on-complete
- J: watched sources, RSS fast-check, Task Scheduler + --sync launch
  (the OS-level scheduling/RSS need a real-install smoke test)
- K: .info.json / thumbnail / .description sidecars for media servers

Also gitignore .gitea-token. 106 unit tests pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:50:13 -04:00
parent 81b97ea429
commit 47da3e6746
26 changed files with 2529 additions and 46 deletions
+54
View File
@@ -8,10 +8,12 @@
* resolving it itself; the caller in download.ts passes `getBinDir()`.
*/
import { join } from 'path'
import {
BEST_FORMAT_ID,
type StartDownloadOptions,
type DownloadOptions,
type CollectionContext,
type CookieBrowser
} from '@shared/ipc'
@@ -138,6 +140,52 @@ export function formatCommandLine(exe: string, args: string[]): string {
return [exe, ...args].map(quoteForDisplay).join(' ')
}
// --- Collection (media-manager) folder paths --------------------------------
/**
* Sanitize one path segment (a channel or playlist name) into a Windows-safe
* directory name. This matters because, unlike the filename yt-dlp itself writes
* (which `--restrict-filenames` can clean), these directory segments are built by
* AeroFetch from untrusted channel/playlist titles and joined onto the output
* dir — so they must be neutered for both illegal characters AND path traversal.
*
* - illegal chars (`< > : " / \ | ? *`) and control chars → space
* - leading/trailing dots and spaces stripped (illegal / invisible on Windows),
* which also turns a bare `..` traversal segment into nothing
* - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix
* - length-capped so the full path stays well under MAX_PATH
* - empty result falls back to 'Untitled'
*/
export function sanitizeDirSegment(name: string): string {
// C0 control chars (charCode < 0x20) and Windows-illegal chars both become a
// space. The control-char filter is done by char code so no literal control
// byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(s)) s = `_${s}`
s = s.slice(0, 80).trim()
return s || 'Untitled'
}
/**
* Build the `-o` output template for a collection download:
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
* where NNN is the 1-based playlist index zero-padded to three digits and
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
*/
export function collectionOutputTemplate(
outDir: string,
c: CollectionContext,
baseFilename: string
): string {
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
const nnn = String(n).padStart(3, '0')
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
return join(outDir, ...segments, `${nnn} - ${baseFilename}`)
}
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
@@ -179,6 +227,12 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
if (o.embedChapters) args.push('--embed-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
// Media-server sidecar files (Phase K) — written next to the output file so
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
if (o.writeInfoJson) args.push('--write-info-json')
if (o.writeThumbnailFile) args.push('--write-thumbnail')
if (o.writeDescription) args.push('--write-description')
return args
}