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:
+36
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'path'
|
||||
import type { HistoryEntry, ErrorLogEntry, CommandTemplate } from '@shared/ipc'
|
||||
import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem } from '@shared/ipc'
|
||||
|
||||
// --- Path-traversal sanitization (audit S4) ---------------------------------
|
||||
|
||||
@@ -75,3 +75,38 @@ export function isTemplateLike(o: unknown): o is CommandTemplate {
|
||||
const t = o as Record<string, unknown>
|
||||
return typeof t.id === 'string' || typeof t.id === 'number'
|
||||
}
|
||||
|
||||
/** A persisted sources.json row must have the right shape or it's dropped on read. */
|
||||
export function isValidSource(o: unknown): o is Source {
|
||||
if (!o || typeof o !== 'object') return false
|
||||
const s = o as Record<string, unknown>
|
||||
return (
|
||||
typeof s.id === 'string' &&
|
||||
typeof s.url === 'string' &&
|
||||
(s.kind === 'channel' || s.kind === 'playlist') &&
|
||||
typeof s.title === 'string' &&
|
||||
typeof s.addedAt === 'number' &&
|
||||
typeof s.itemCount === 'number' &&
|
||||
isOptionalString(s.channel) &&
|
||||
(s.lastIndexedAt === undefined || typeof s.lastIndexedAt === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
/** A persisted media-items.json row must have the right shape or it's dropped on read. */
|
||||
export function isValidMediaItem(o: unknown): o is MediaItem {
|
||||
if (!o || typeof o !== 'object') return false
|
||||
const m = o as Record<string, unknown>
|
||||
return (
|
||||
typeof m.id === 'string' &&
|
||||
typeof m.sourceId === 'string' &&
|
||||
typeof m.videoId === 'string' &&
|
||||
typeof m.title === 'string' &&
|
||||
typeof m.url === 'string' &&
|
||||
typeof m.playlistTitle === 'string' &&
|
||||
typeof m.playlistIndex === 'number' &&
|
||||
typeof m.downloaded === 'boolean' &&
|
||||
isOptionalString(m.durationLabel) &&
|
||||
isOptionalString(m.filePath) &&
|
||||
(m.downloadedAt === undefined || typeof m.downloadedAt === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user