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
+1 -33
View File
@@ -4,6 +4,7 @@ import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
import {
BEST_FORMAT_ID,
type ProbeResult,
@@ -26,16 +27,6 @@ interface RawFormat {
filesize_approx?: number
}
interface RawEntry {
id?: string
title?: string
url?: string
webpage_url?: string
duration?: number
uploader?: string
channel?: string
}
interface RawInfo {
_type?: string
title?: string
@@ -92,29 +83,6 @@ function buildInfo(data: RawInfo): MediaInfo {
}
}
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat playlist entries give duration as a number. */
function fmtDuration(sec?: number): string | undefined {
if (sec == null || !Number.isFinite(sec)) return undefined
const s = Math.max(0, Math.round(sec))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
const mm = h ? String(m).padStart(2, '0') : String(m)
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
}
/**
* Resolve the URL we'll enqueue for a playlist entry. Prefer an explicit http(s)
* URL; fall back to a YouTube watch URL built from the id (the common case where
* flat entries carry only an id). Returns null when nothing usable is present.
*/
function entryUrl(e: RawEntry): string | null {
const cand = e.url || e.webpage_url
if (cand && /^https?:\/\//i.test(cand)) return cand
if (e.id) return `https://www.youtube.com/watch?v=${e.id}`
return null
}
function buildPlaylist(data: RawInfo): PlaylistInfo {
const entries: PlaylistEntry[] = []
;(data.entries ?? []).forEach((e, i) => {