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