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:
@@ -3,6 +3,8 @@ import {
|
||||
buildArgs,
|
||||
parseExtraArgs,
|
||||
formatCommandLine,
|
||||
sanitizeDirSegment,
|
||||
collectionOutputTemplate,
|
||||
CROP_SQUARE_PPA,
|
||||
ARIA2C_ARGS,
|
||||
type AccessOptions
|
||||
@@ -360,3 +362,91 @@ describe('buildArgs extraArgs', () => {
|
||||
expect(withEmpty).toEqual(withoutParam)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Media-server sidecar files (Phase K) -----------------------------------
|
||||
|
||||
describe('sidecar files', () => {
|
||||
it('emits --write-info-json / --write-thumbnail / --write-description when enabled', () => {
|
||||
const argv = build(
|
||||
opts(),
|
||||
dlo({ writeInfoJson: true, writeThumbnailFile: true, writeDescription: true })
|
||||
)
|
||||
expect(argv).toContain('--write-info-json')
|
||||
expect(argv).toContain('--write-thumbnail')
|
||||
expect(argv).toContain('--write-description')
|
||||
})
|
||||
|
||||
it('emits none of them by default', () => {
|
||||
const argv = build(opts(), dlo())
|
||||
expect(argv).not.toContain('--write-info-json')
|
||||
expect(argv).not.toContain('--write-description')
|
||||
// (--write-thumbnail is sidecar-only here; embedThumbnail uses --embed-thumbnail)
|
||||
expect(argv).not.toContain('--write-thumbnail')
|
||||
})
|
||||
})
|
||||
|
||||
// --- Collection folder paths (Phase G, media-manager) -----------------------
|
||||
|
||||
describe('sanitizeDirSegment', () => {
|
||||
it('keeps an ordinary name (incl. spaces and hyphens) intact', () => {
|
||||
expect(sanitizeDirSegment('Linus Tech Tips')).toBe('Linus Tech Tips')
|
||||
expect(sanitizeDirSegment('Two-Minute Papers')).toBe('Two-Minute Papers')
|
||||
})
|
||||
|
||||
it('replaces Windows-illegal characters with a space and collapses runs', () => {
|
||||
expect(sanitizeDirSegment('A/B\\C:D*E?F')).toBe('A B C D E F')
|
||||
expect(sanitizeDirSegment('Best of 2024 <Live>')).toBe('Best of 2024 Live')
|
||||
})
|
||||
|
||||
it('strips leading/trailing dots and spaces, neutralising `..` traversal', () => {
|
||||
expect(sanitizeDirSegment('..')).toBe('Untitled')
|
||||
expect(sanitizeDirSegment('../../etc')).toBe('etc')
|
||||
expect(sanitizeDirSegment(' trailing. ')).toBe('trailing')
|
||||
expect(sanitizeDirSegment('My Playlist.')).toBe('My Playlist')
|
||||
})
|
||||
|
||||
it('prefixes reserved Windows device names', () => {
|
||||
expect(sanitizeDirSegment('CON')).toBe('_CON')
|
||||
expect(sanitizeDirSegment('com1')).toBe('_com1')
|
||||
})
|
||||
|
||||
it('falls back to Untitled for empty / all-illegal input', () => {
|
||||
expect(sanitizeDirSegment('')).toBe('Untitled')
|
||||
expect(sanitizeDirSegment('???')).toBe('Untitled')
|
||||
})
|
||||
|
||||
it('caps length to keep the overall path bounded', () => {
|
||||
expect(sanitizeDirSegment('x'.repeat(200)).length).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectionOutputTemplate', () => {
|
||||
const ctx = { channel: 'DevChannel', playlist: 'Full Course', index: 3 }
|
||||
|
||||
it('files into <out>/<channel>/<playlist>/<NNN> - <filename>', () => {
|
||||
const t = collectionOutputTemplate('C:/out', ctx, '%(title)s.%(ext)s')
|
||||
// normalise separators so the assertion is OS-independent
|
||||
expect(t.replace(/\\/g, '/')).toBe('C:/out/DevChannel/Full Course/003 - %(title)s.%(ext)s')
|
||||
})
|
||||
|
||||
it('zero-pads the index to three digits and clamps bad values to 001', () => {
|
||||
expect(collectionOutputTemplate('C:/o', { ...ctx, index: 42 }, 'f').replace(/\\/g, '/')).toContain(
|
||||
'/042 - f'
|
||||
)
|
||||
expect(collectionOutputTemplate('C:/o', { ...ctx, index: 0 }, 'f').replace(/\\/g, '/')).toContain(
|
||||
'/001 - f'
|
||||
)
|
||||
expect(
|
||||
collectionOutputTemplate('C:/o', { ...ctx, index: NaN }, 'f').replace(/\\/g, '/')
|
||||
).toContain('/001 - f')
|
||||
})
|
||||
|
||||
it('sanitizes the channel and playlist folder names', () => {
|
||||
const t = collectionOutputTemplate(
|
||||
'C:/out',
|
||||
{ channel: 'A/B', playlist: '..', index: 1 },
|
||||
'%(title)s.%(ext)s'
|
||||
)
|
||||
expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user