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:
+147
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user