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
+147 -2
View File
@@ -40,7 +40,24 @@ export const IpcChannels = {
openHighContrastSettings: 'shell:open-high-contrast-settings',
/** main → renderer push channel: a URL handed to AeroFetch from outside the app
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
externalUrl: 'external-url'
externalUrl: 'external-url',
// --- Sources & media-manager indexing (Pinchflat-style, see ROADMAP-PINCHFLAT.md) ---
sourcesList: 'sources:list',
sourceIndex: 'sources:index',
sourceReindex: 'sources:reindex',
sourceRemove: 'sources:remove',
sourceItems: 'sources:items',
/** mark a media item downloaded once its collection download completes */
sourceItemDownloaded: 'sources:item-downloaded',
/** toggle whether a source is watched for new uploads (Phase J) */
sourceSetWatched: 'sources:set-watched',
/** re-index all watched sources and return the videos that are new */
sourcesSync: 'sources:sync',
/** get / set the Windows scheduled-sync task (Task Scheduler) */
scheduledSyncGet: 'sources:scheduled-sync-get',
scheduledSyncSet: 'sources:scheduled-sync-set',
/** main → renderer push channel for live indexing progress */
indexProgress: 'sources:index-progress'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -134,6 +151,12 @@ export interface DownloadOptions {
embedThumbnail: boolean
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
cropThumbnail: boolean
/** write a .info.json metadata sidecar (Jellyfin/Plex/Kodi ingest — Phase K) */
writeInfoJson: boolean
/** write the thumbnail as a separate image file alongside the video */
writeThumbnailFile: boolean
/** write the video description to a .description sidecar file */
writeDescription: boolean
}
export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
@@ -149,7 +172,10 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
embedChapters: false,
embedMetadata: true,
embedThumbnail: true,
cropThumbnail: false
cropThumbnail: false,
writeInfoJson: false,
writeThumbnailFile: false,
writeDescription: false
}
export interface YtdlpVersionResult {
@@ -225,6 +251,24 @@ export interface ProbeResult {
error?: string
}
/**
* Media-manager folder context for a collection download (see ROADMAP-PINCHFLAT.md
* Phase G). When present on a StartDownloadOptions, the file is filed into
* `<outputDir>/<channel>/<playlist>/<NNN> - <title>.<ext>` instead of the flat
* filenameTemplate. The directory segments are sanitized for Windows at argv-build
* time, and `index` (the 1-based playlist position from the persisted MediaItem)
* drives the NNN prefix — not yt-dlp's %(playlist_index)s, which is empty under
* the per-video `--no-playlist` path these downloads still take.
*/
export interface CollectionContext {
/** channel / uploader name — the top folder level */
channel: string
/** playlist title — the second folder level ('Uploads' for the catch-all) */
playlist: string
/** 1-based position within the playlist, for the NNN filename prefix */
index: number
}
/** Sent renderer → main to kick off a download. The renderer owns the id. */
export interface StartDownloadOptions {
id: string
@@ -255,6 +299,12 @@ export interface StartDownloadOptions {
* race where the late probe overwrites a good title the renderer supplied.
*/
meta?: DownloadMeta
/**
* When set, this is a media-manager (collection) download — file it into
* `<channel>/<playlist>/<NNN> - <title>` folders instead of the flat
* filenameTemplate. See CollectionContext.
*/
collection?: CollectionContext
}
export interface StartDownloadResult {
@@ -327,6 +377,8 @@ export interface Settings {
defaultTemplateId: string | null
/** show a native OS notification when a download finishes or fails */
notifyOnComplete: boolean
/** auto-enqueue newly-found videos from watched sources during a sync (Phase J) */
autoDownloadNew: boolean
/** false until the first-run welcome screen has been dismissed */
hasCompletedOnboarding: boolean
}
@@ -410,3 +462,96 @@ export interface BackupImportResult {
ok: boolean
error?: string
}
// --- Sources & media-manager indexing (Pinchflat-style) ---------------------
// See ROADMAP-PINCHFLAT.md. A Source (channel/playlist) is indexed once into a
// persisted list of MediaItem records; the live download queue then pulls from
// that list a batch at a time, so "download an entire channel" never has to hold
// the whole collection in the queue at once.
/** Whether a Source is a whole channel or a single playlist. */
export type SourceKind = 'channel' | 'playlist'
/**
* A monitored channel or playlist that AeroFetch has indexed. Its full video
* list lives as MediaItem records (one JSON store), keyed back by `id`.
*/
export interface Source {
id: string
/** the URL the user added (used for re-indexing) */
url: string
kind: SourceKind
title: string
/** channel / uploader name, when known */
channel?: string
/** epoch ms the source was first added */
addedAt: number
/** epoch ms of the last successful (re)index; undefined if never indexed */
lastIndexedAt?: number
/** number of MediaItems at the last index (cached for list display) */
itemCount: number
/** watched for new uploads — included in scheduled / startup sync (Phase J) */
watched?: boolean
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
feedUrl?: string
}
/**
* One video discovered while indexing a Source. `playlistTitle`/`playlistIndex`
* drive the on-disk folder layout (<Channel>/<Playlist>/<NNN> - <Title>); the
* `downloaded` flag + `filePath` let the library view show per-item state and
* let re-syncs skip what's already on disk.
*/
export interface MediaItem {
/** globally unique, formed as `${sourceId}:${videoId}` */
id: string
sourceId: string
/** the yt-dlp video id — the dedup key within a source */
videoId: string
title: string
url: string
/** the playlist this item is filed under; 'Uploads' for videos in no playlist */
playlistTitle: string
/** 1-based position within its playlist, for NNN numbering */
playlistIndex: number
durationLabel?: string
downloaded: boolean
downloadedAt?: number
filePath?: string
}
/** Live progress pushed while a Source is being indexed (main → renderer). */
export interface IndexProgress {
/** the Source URL being indexed (correlates events back to the request) */
url: string
phase: 'start' | 'playlists' | 'playlist' | 'uploads' | 'done' | 'error'
/** human-readable status line */
message: string
/** for the per-playlist phase: playlists processed so far / total */
current?: number
total?: number
}
/** Result of indexing (or re-indexing) a Source. */
export interface IndexSourceResult {
ok: boolean
source?: Source
itemCount?: number
/** how many videos were new since the previous index (all of them on first index) */
newCount?: number
error?: string
}
/** Result of syncing all watched sources: the videos found new across them. */
export interface SyncResult {
ok: boolean
/** newly-discovered media items across all watched sources (empty if nothing new) */
newItems: MediaItem[]
error?: string
}
/** Whether the Windows scheduled-sync task is currently registered. */
export interface ScheduledSyncStatus {
enabled: boolean
error?: string
}