Compare commits
17 commits
ad16cc6141
...
76e7611d8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76e7611d8d | ||
|
|
5d9cf148aa | ||
|
|
e1d5ef80b4 | ||
|
|
794181580f | ||
|
|
8cf998a697 | ||
|
|
f814e8d261 | ||
|
|
f8916d2cc3 | ||
|
|
d8d876e9b8 | ||
|
|
0f42a4b269 | ||
|
|
4cabcfbb4c | ||
|
|
8ebacba3e1 | ||
|
|
ed901c8240 | ||
|
|
29e8654b01 | ||
|
|
97905039ed | ||
|
|
872f092ec4 | ||
|
|
243960d249 | ||
|
|
76f0dd882a |
29 changed files with 2876 additions and 281 deletions
2
drizzle/0018_platform_settings_nfo_view.sql
Normal file
2
drizzle/0018_platform_settings_nfo_view.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `platform_settings` ADD `nfo_enabled` integer NOT NULL DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE `platform_settings` ADD `default_view` text NOT NULL DEFAULT 'list';
|
||||||
1147
drizzle/meta/0018_snapshot.json
Normal file
1147
drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -127,6 +127,13 @@
|
||||||
"when": 1775282773898,
|
"when": 1775282773898,
|
||||||
"tag": "0017_wild_havok",
|
"tag": "0017_wild_havok",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775520100000,
|
||||||
|
"tag": "0018_platform_settings_nfo_view",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -39,13 +39,13 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock getAppSetting for NFO feature flag
|
// Mock getPlatformSettings for per-platform NFO feature flag
|
||||||
const getAppSettingMock = vi.fn();
|
const getPlatformSettingsMock = vi.fn();
|
||||||
vi.mock('../db/repositories/system-config-repository', async (importOriginal) => {
|
vi.mock('../db/repositories/platform-settings-repository', async (importOriginal) => {
|
||||||
const actual = await importOriginal() as Record<string, unknown>;
|
const actual = await importOriginal() as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getAppSetting: (...args: unknown[]) => getAppSettingMock(...args),
|
getPlatformSettings: (...args: unknown[]) => getPlatformSettingsMock(...args),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -788,7 +788,7 @@ describe('DownloadService', () => {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('writes .nfo sidecar when app.nfo_enabled is "true"', async () => {
|
it('writes .nfo sidecar when platform nfoEnabled is true', async () => {
|
||||||
const deps = createMockDeps();
|
const deps = createMockDeps();
|
||||||
const service = new DownloadService(
|
const service = new DownloadService(
|
||||||
db, deps.rateLimiter, deps.fileOrganizer,
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
|
@ -796,7 +796,7 @@ describe('DownloadService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const outputPath = setupSuccessfulDownload(deps);
|
const outputPath = setupSuccessfulDownload(deps);
|
||||||
getAppSettingMock.mockResolvedValueOnce('true');
|
getPlatformSettingsMock.mockResolvedValueOnce({ nfoEnabled: true });
|
||||||
|
|
||||||
await service.downloadItem(testContentItem, testChannel);
|
await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
||||||
|
|
@ -812,7 +812,7 @@ describe('DownloadService', () => {
|
||||||
expect(nfoContent).toContain('<studio>Test Channel</studio>');
|
expect(nfoContent).toContain('<studio>Test Channel</studio>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not write .nfo when app.nfo_enabled is not "true"', async () => {
|
it('does not write .nfo when platform nfoEnabled is false', async () => {
|
||||||
const deps = createMockDeps();
|
const deps = createMockDeps();
|
||||||
const service = new DownloadService(
|
const service = new DownloadService(
|
||||||
db, deps.rateLimiter, deps.fileOrganizer,
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
|
@ -820,7 +820,7 @@ describe('DownloadService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const outputPath = setupSuccessfulDownload(deps);
|
const outputPath = setupSuccessfulDownload(deps);
|
||||||
getAppSettingMock.mockResolvedValueOnce(null); // Not set
|
getPlatformSettingsMock.mockResolvedValueOnce({ nfoEnabled: false });
|
||||||
|
|
||||||
await service.downloadItem(testContentItem, testChannel);
|
await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
||||||
|
|
@ -828,7 +828,7 @@ describe('DownloadService', () => {
|
||||||
expect(existsSync(nfoPath)).toBe(false);
|
expect(existsSync(nfoPath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not write .nfo when app.nfo_enabled is "false"', async () => {
|
it('does not write .nfo when no platform settings exist', async () => {
|
||||||
const deps = createMockDeps();
|
const deps = createMockDeps();
|
||||||
const service = new DownloadService(
|
const service = new DownloadService(
|
||||||
db, deps.rateLimiter, deps.fileOrganizer,
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
|
@ -836,7 +836,7 @@ describe('DownloadService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const outputPath = setupSuccessfulDownload(deps);
|
const outputPath = setupSuccessfulDownload(deps);
|
||||||
getAppSettingMock.mockResolvedValueOnce('false');
|
getPlatformSettingsMock.mockResolvedValueOnce(null); // No settings for platform
|
||||||
|
|
||||||
await service.downloadItem(testContentItem, testChannel);
|
await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
||||||
|
|
@ -852,7 +852,7 @@ describe('DownloadService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
setupSuccessfulDownload(deps);
|
setupSuccessfulDownload(deps);
|
||||||
getAppSettingMock.mockRejectedValueOnce(new Error('DB read failed'));
|
getPlatformSettingsMock.mockRejectedValueOnce(new Error('DB read failed'));
|
||||||
|
|
||||||
// Download should still succeed
|
// Download should still succeed
|
||||||
const result = await service.downloadItem(testContentItem, testChannel);
|
const result = await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
@ -867,11 +867,290 @@ describe('DownloadService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
setupSuccessfulDownload(deps);
|
setupSuccessfulDownload(deps);
|
||||||
getAppSettingMock.mockResolvedValueOnce('true');
|
getPlatformSettingsMock.mockResolvedValueOnce({ nfoEnabled: true });
|
||||||
|
|
||||||
const result = await service.downloadItem(testContentItem, testChannel);
|
const result = await service.downloadItem(testContentItem, testChannel);
|
||||||
expect(result.status).toBe('downloaded');
|
expect(result.status).toBe('downloaded');
|
||||||
expect(result.filePath).toBeDefined();
|
expect(result.filePath).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('downloadItem — SponsorBlock arg construction', () => {
|
||||||
|
function setupForArgs(deps: ReturnType<typeof createMockDeps>) {
|
||||||
|
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
|
||||||
|
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
|
||||||
|
writeFileSync(outputPath, 'data');
|
||||||
|
execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 });
|
||||||
|
statMock.mockResolvedValueOnce({ size: 1000 });
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('produces --sponsorblock-remove with comma-separated categories', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 20, name: 'SB Test', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
|
||||||
|
embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: 'sponsor,selfpromo',
|
||||||
|
outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
const sbIdx = args.indexOf('--sponsorblock-remove');
|
||||||
|
expect(sbIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(args[sbIdx + 1]).toBe('sponsor,selfpromo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include --sponsorblock-remove when sponsorBlockRemove is null', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 21, name: 'No SB', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
|
||||||
|
embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: null,
|
||||||
|
outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
expect(args).not.toContain('--sponsorblock-remove');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include --sponsorblock-remove when value is empty/whitespace', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 22, name: 'Empty SB', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
|
||||||
|
embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: ' ',
|
||||||
|
outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
expect(args).not.toContain('--sponsorblock-remove');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all SponsorBlock category types', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const allCategories = 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler';
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 23, name: 'All SB', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
|
||||||
|
embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: allCategories,
|
||||||
|
outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
const sbIdx = args.indexOf('--sponsorblock-remove');
|
||||||
|
expect(sbIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(args[sbIdx + 1]).toBe(allCategories);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadItem — subtitle arg construction', () => {
|
||||||
|
function setupForArgs(deps: ReturnType<typeof createMockDeps>) {
|
||||||
|
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
|
||||||
|
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
|
||||||
|
writeFileSync(outputPath, 'data');
|
||||||
|
execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 });
|
||||||
|
statMock.mockResolvedValueOnce({ size: 1000 });
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('produces --write-subs and --sub-langs when subtitleLanguages is set', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 30, name: 'Sub Test', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false,
|
||||||
|
subtitleLanguages: 'en,es',
|
||||||
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
expect(args).toContain('--write-subs');
|
||||||
|
const slIdx = args.indexOf('--sub-langs');
|
||||||
|
expect(slIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(args[slIdx + 1]).toBe('en,es');
|
||||||
|
// embedSubtitles is false, so no --embed-subs
|
||||||
|
expect(args).not.toContain('--embed-subs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces --embed-subs when embedSubtitles=true AND subtitleLanguages is set', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 31, name: 'Embed Sub', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false,
|
||||||
|
subtitleLanguages: 'en,es',
|
||||||
|
embedSubtitles: true,
|
||||||
|
embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
expect(args).toContain('--write-subs');
|
||||||
|
expect(args).toContain('--sub-langs');
|
||||||
|
expect(args).toContain('--embed-subs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT produce --embed-subs when embedSubtitles=true but subtitleLanguages is null', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 32, name: 'No Lang', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false,
|
||||||
|
subtitleLanguages: null,
|
||||||
|
embedSubtitles: true,
|
||||||
|
embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
expect(args).not.toContain('--write-subs');
|
||||||
|
expect(args).not.toContain('--sub-langs');
|
||||||
|
expect(args).not.toContain('--embed-subs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not produce subtitle args when no format profile', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
expect(args).not.toContain('--write-subs');
|
||||||
|
expect(args).not.toContain('--sub-langs');
|
||||||
|
expect(args).not.toContain('--embed-subs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single subtitle language', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
setupForArgs(deps);
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 33, name: 'Single Lang', videoResolution: null, audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: null, isDefault: false,
|
||||||
|
subtitleLanguages: 'en',
|
||||||
|
embedSubtitles: true,
|
||||||
|
embedChapters: false, embedThumbnail: false,
|
||||||
|
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
expect(args).toContain('--write-subs');
|
||||||
|
const slIdx = args.indexOf('--sub-langs');
|
||||||
|
expect(args[slIdx + 1]).toBe('en');
|
||||||
|
expect(args).toContain('--embed-subs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadItem — combined SponsorBlock + subtitle args', () => {
|
||||||
|
it('includes both SponsorBlock and subtitle args when both are configured', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
|
||||||
|
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
|
||||||
|
writeFileSync(outputPath, 'data');
|
||||||
|
execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 });
|
||||||
|
statMock.mockResolvedValueOnce({ size: 1000 });
|
||||||
|
|
||||||
|
const profile: FormatProfile = {
|
||||||
|
id: 40, name: 'Full Features', videoResolution: '1080p', audioCodec: null, audioBitrate: null,
|
||||||
|
containerFormat: 'mkv', isDefault: false,
|
||||||
|
subtitleLanguages: 'en,es,fr',
|
||||||
|
embedSubtitles: true,
|
||||||
|
embedChapters: true, embedThumbnail: true,
|
||||||
|
sponsorBlockRemove: 'sponsor,selfpromo,intro,outro',
|
||||||
|
outputTemplate: null, createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel, profile);
|
||||||
|
|
||||||
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
|
|
||||||
|
// Subtitle args
|
||||||
|
expect(args).toContain('--write-subs');
|
||||||
|
const slIdx = args.indexOf('--sub-langs');
|
||||||
|
expect(args[slIdx + 1]).toBe('en,es,fr');
|
||||||
|
expect(args).toContain('--embed-subs');
|
||||||
|
|
||||||
|
// SponsorBlock args
|
||||||
|
const sbIdx = args.indexOf('--sponsorblock-remove');
|
||||||
|
expect(sbIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(args[sbIdx + 1]).toBe('sponsor,selfpromo,intro,outro');
|
||||||
|
|
||||||
|
// Chapter + thumbnail embedding
|
||||||
|
expect(args).toContain('--embed-chapters');
|
||||||
|
expect(args).toContain('--embed-thumbnail');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,137 @@ describe('Platform Settings API', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Generic platform ──
|
||||||
|
|
||||||
|
describe('Generic platform', () => {
|
||||||
|
it('accepts generic as a valid platform', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
checkInterval: 480,
|
||||||
|
concurrencyLimit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.platform).toBe('generic');
|
||||||
|
expect(body.checkInterval).toBe(480);
|
||||||
|
expect(body.nfoEnabled).toBe(false);
|
||||||
|
expect(body.defaultView).toBe('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows generic in list of all platform settings', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/platform-settings',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const platforms = res.json().map((s: { platform: string }) => s.platform);
|
||||||
|
expect(platforms).toContain('generic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── nfoEnabled ──
|
||||||
|
|
||||||
|
describe('nfoEnabled', () => {
|
||||||
|
it('persists nfoEnabled through PUT → GET round-trip', async () => {
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
nfoEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().nfoEnabled).toBe(true);
|
||||||
|
|
||||||
|
const getRes = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
expect(getRes.statusCode).toBe(200);
|
||||||
|
expect(getRes.json().nfoEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults nfoEnabled to false when not specified', async () => {
|
||||||
|
await server.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { checkInterval: 360 },
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().nfoEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── defaultView ──
|
||||||
|
|
||||||
|
describe('defaultView', () => {
|
||||||
|
it('persists defaultView through PUT → GET round-trip', async () => {
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
defaultView: 'poster',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().defaultView).toBe('poster');
|
||||||
|
|
||||||
|
const getRes = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
expect(getRes.statusCode).toBe(200);
|
||||||
|
expect(getRes.json().defaultView).toBe('poster');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid defaultView value', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/youtube',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {
|
||||||
|
defaultView: 'grid',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults defaultView to list when not specified', async () => {
|
||||||
|
await server.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
const putRes = await server.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/platform-settings/generic',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { checkInterval: 360 },
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(putRes.json().defaultView).toBe('list');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Auth ──
|
// ── Auth ──
|
||||||
|
|
||||||
describe('Authentication', () => {
|
describe('Authentication', () => {
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,11 @@ import type {
|
||||||
Channel,
|
Channel,
|
||||||
PlatformSourceMetadata,
|
PlatformSourceMetadata,
|
||||||
PlatformContentMetadata,
|
PlatformContentMetadata,
|
||||||
|
PlaylistDiscoveryResult,
|
||||||
} from '../types/index';
|
} from '../types/index';
|
||||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
import type * as schema from '../db/schema/index';
|
import type * as schema from '../db/schema/index';
|
||||||
|
import { getPlaylistsByChannelId } from '../db/repositories/playlist-repository';
|
||||||
|
|
||||||
// ── Rate Limiter Tests ──
|
// ── Rate Limiter Tests ──
|
||||||
|
|
||||||
|
|
@ -891,6 +893,91 @@ describe('SchedulerService', () => {
|
||||||
|
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls fetchPlaylists after successful scan when source supports it', async () => {
|
||||||
|
const channel = await insertTestChannel();
|
||||||
|
|
||||||
|
// Build a registry with fetchPlaylists support
|
||||||
|
const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise<PlaylistDiscoveryResult[]>>()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
platformPlaylistId: 'PL_test_1',
|
||||||
|
title: 'Test Playlist',
|
||||||
|
videoIds: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const mockSourceWithPlaylists: PlatformSource = {
|
||||||
|
resolveChannel: mockResolveChannel,
|
||||||
|
fetchRecentContent: mockFetchRecentContent,
|
||||||
|
fetchPlaylists: fetchPlaylistsFn,
|
||||||
|
};
|
||||||
|
const reg = new PlatformRegistry();
|
||||||
|
reg.register(Platform.YouTube, mockSourceWithPlaylists);
|
||||||
|
|
||||||
|
const localLimiter = new RateLimiter({
|
||||||
|
[Platform.YouTube]: { minIntervalMs: 0 },
|
||||||
|
});
|
||||||
|
const scheduler = new SchedulerService(db, reg, localLimiter);
|
||||||
|
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `plsync_${channel.id}`));
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
// Allow background sidecar to complete
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
expect(fetchPlaylistsFn).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Verify playlist was persisted
|
||||||
|
const playlists = await getPlaylistsByChannelId(db, channel.id);
|
||||||
|
expect(playlists.length).toBe(1);
|
||||||
|
expect(playlists[0].title).toBe('Test Playlist');
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scan succeeds even when playlist sync throws', async () => {
|
||||||
|
const channel = await insertTestChannel();
|
||||||
|
|
||||||
|
const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise<PlaylistDiscoveryResult[]>>()
|
||||||
|
.mockRejectedValueOnce(new Error('Playlist fetch exploded'));
|
||||||
|
const mockSourceWithPlaylists: PlatformSource = {
|
||||||
|
resolveChannel: mockResolveChannel,
|
||||||
|
fetchRecentContent: mockFetchRecentContent,
|
||||||
|
fetchPlaylists: fetchPlaylistsFn,
|
||||||
|
};
|
||||||
|
const reg = new PlatformRegistry();
|
||||||
|
reg.register(Platform.YouTube, mockSourceWithPlaylists);
|
||||||
|
|
||||||
|
const localLimiter = new RateLimiter({
|
||||||
|
[Platform.YouTube]: { minIntervalMs: 0 },
|
||||||
|
});
|
||||||
|
const scheduler = new SchedulerService(db, reg, localLimiter);
|
||||||
|
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(2, `plfail_${channel.id}`));
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.newItems).toBe(2);
|
||||||
|
// Allow background sidecar to attempt and fail
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
expect(fetchPlaylistsFn).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips playlist sync when source does not support fetchPlaylists', async () => {
|
||||||
|
const channel = await insertTestChannel();
|
||||||
|
const scheduler = new SchedulerService(db, registry, rateLimiter);
|
||||||
|
|
||||||
|
// Default mock source has no fetchPlaylists
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `noplsync_${channel.id}`));
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.newItems).toBe(1);
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── addChannel() / removeChannel() ──
|
// ── addChannel() / removeChannel() ──
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export interface UpsertPlatformSettingsData {
|
||||||
scanLimit?: number;
|
scanLimit?: number;
|
||||||
rateLimitDelay?: number;
|
rateLimitDelay?: number;
|
||||||
defaultMonitoringMode?: MonitoringMode;
|
defaultMonitoringMode?: MonitoringMode;
|
||||||
|
nfoEnabled?: boolean;
|
||||||
|
defaultView?: 'list' | 'poster' | 'table';
|
||||||
}
|
}
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
@ -67,6 +69,8 @@ export async function upsertPlatformSettings(
|
||||||
scanLimit: data.scanLimit ?? 500,
|
scanLimit: data.scanLimit ?? 500,
|
||||||
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
||||||
|
nfoEnabled: data.nfoEnabled ?? false,
|
||||||
|
defaultView: data.defaultView ?? 'list',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
@ -82,6 +86,8 @@ export async function upsertPlatformSettings(
|
||||||
scanLimit: data.scanLimit ?? 500,
|
scanLimit: data.scanLimit ?? 500,
|
||||||
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
||||||
|
nfoEnabled: data.nfoEnabled ?? false,
|
||||||
|
defaultView: data.defaultView ?? 'list',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -117,6 +123,8 @@ function mapRow(row: typeof platformSettings.$inferSelect): PlatformSettings {
|
||||||
scanLimit: row.scanLimit ?? 500,
|
scanLimit: row.scanLimit ?? 500,
|
||||||
rateLimitDelay: row.rateLimitDelay ?? 1000,
|
rateLimitDelay: row.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: (row.defaultMonitoringMode ?? 'all') as MonitoringMode,
|
defaultMonitoringMode: (row.defaultMonitoringMode ?? 'all') as MonitoringMode,
|
||||||
|
nfoEnabled: row.nfoEnabled,
|
||||||
|
defaultView: (row.defaultView ?? 'list') as 'list' | 'poster' | 'table',
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export const platformSettings = sqliteTable('platform_settings', {
|
||||||
scanLimit: integer('scan_limit').default(100),
|
scanLimit: integer('scan_limit').default(100),
|
||||||
rateLimitDelay: integer('rate_limit_delay').default(1000),
|
rateLimitDelay: integer('rate_limit_delay').default(1000),
|
||||||
defaultMonitoringMode: text('default_monitoring_mode').notNull().default('all'),
|
defaultMonitoringMode: text('default_monitoring_mode').notNull().default('all'),
|
||||||
|
nfoEnabled: integer('nfo_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
defaultView: text('default_view').notNull().default('list'),
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(datetime('now'))`),
|
.default(sql`(datetime('now'))`),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tubearr</title>
|
<title>Tubearr</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export interface UpdatePlatformSettingsInput {
|
||||||
scanLimit?: number;
|
scanLimit?: number;
|
||||||
rateLimitDelay?: number;
|
rateLimitDelay?: number;
|
||||||
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
||||||
|
nfoEnabled?: boolean;
|
||||||
|
defaultView?: 'list' | 'poster' | 'table';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
|
|
|
||||||
257
src/frontend/src/components/ColumnConfig.tsx
Normal file
257
src/frontend/src/components/ColumnConfig.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { Settings2, GripVertical, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface ListColumnDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Columns that cannot be hidden */
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnConfig {
|
||||||
|
key: string;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
export const LIST_COLUMNS: ListColumnDef[] = [
|
||||||
|
{ key: 'thumbnail', label: 'Thumbnail' },
|
||||||
|
{ key: 'contentType', label: 'Type' },
|
||||||
|
{ key: 'contentRating', label: 'Rating' },
|
||||||
|
{ key: 'quality', label: 'Quality' },
|
||||||
|
{ key: 'publishedAt', label: 'Published' },
|
||||||
|
{ key: 'downloadedAt', label: 'Downloaded' },
|
||||||
|
{ key: 'duration', label: 'Duration' },
|
||||||
|
{ key: 'fileSize', label: 'Size' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_COLUMN_CONFIG: ColumnConfig[] = LIST_COLUMNS.map((col) => ({
|
||||||
|
key: col.key,
|
||||||
|
visible: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Merge stored config with current column defs — handles new columns added after storage was saved */
|
||||||
|
export function mergeColumnConfig(stored: ColumnConfig[]): ColumnConfig[] {
|
||||||
|
const storedMap = new Map(stored.map((c) => [c.key, c]));
|
||||||
|
const merged: ColumnConfig[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// Preserve stored order for known columns
|
||||||
|
for (const s of stored) {
|
||||||
|
if (LIST_COLUMNS.some((c) => c.key === s.key)) {
|
||||||
|
merged.push(s);
|
||||||
|
seen.add(s.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append any new columns not in stored config
|
||||||
|
for (const col of LIST_COLUMNS) {
|
||||||
|
if (!seen.has(col.key)) {
|
||||||
|
merged.push({ key: col.key, visible: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
interface ColumnConfigPopoverProps {
|
||||||
|
columns: ColumnConfig[];
|
||||||
|
onChange: (columns: ColumnConfig[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnConfigPopover({ columns, onChange }: ColumnConfigPopoverProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
popoverRef.current &&
|
||||||
|
!popoverRef.current.contains(e.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggleVisibility = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
onChange(
|
||||||
|
columns.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[columns, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveColumn = useCallback(
|
||||||
|
(fromIdx: number, toIdx: number) => {
|
||||||
|
if (fromIdx === toIdx) return;
|
||||||
|
const next = [...columns];
|
||||||
|
const [moved] = next.splice(fromIdx, 1);
|
||||||
|
next.splice(toIdx, 0, moved);
|
||||||
|
onChange(next);
|
||||||
|
},
|
||||||
|
[columns, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((idx: number) => {
|
||||||
|
setDragIndex(idx);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent, idx: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragIndex !== null && dragIndex !== idx) {
|
||||||
|
moveColumn(dragIndex, idx);
|
||||||
|
setDragIndex(idx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dragIndex, moveColumn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleCount = columns.filter((c) => c.visible).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
title="Configure columns"
|
||||||
|
aria-label="Configure list columns"
|
||||||
|
aria-expanded={open}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: open ? 'var(--accent)' : 'var(--bg-input)',
|
||||||
|
color: open ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings2 size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 4px)',
|
||||||
|
right: 0,
|
||||||
|
zIndex: 200,
|
||||||
|
minWidth: 220,
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
padding: 'var(--space-2) 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Columns ({visibleCount}/{columns.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{columns.map((col, idx) => {
|
||||||
|
const def = LIST_COLUMNS.find((c) => c.key === col.key);
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(idx)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, idx)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-1) var(--space-3)',
|
||||||
|
cursor: 'grab',
|
||||||
|
backgroundColor: dragIndex === idx ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical
|
||||||
|
size={14}
|
||||||
|
style={{ color: 'var(--text-muted)', flexShrink: 0, opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleVisibility(col.key)}
|
||||||
|
title={col.visible ? `Hide ${def.label}` : `Show ${def.label}`}
|
||||||
|
aria-label={col.visible ? `Hide ${def.label} column` : `Show ${def.label} column`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: col.visible ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'color var(--transition-fast)',
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col.visible ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: col.visible ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
|
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, HardDriveDownload, Music } from 'lucide-react';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { DownloadProgressBar } from './DownloadProgressBar';
|
import { DownloadProgressBar } from './DownloadProgressBar';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
|
|
@ -219,6 +219,27 @@ export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDow
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.status === 'downloaded' && item.filePath && (
|
||||||
|
<a
|
||||||
|
href={`/api/v1/content/${item.id}/download`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Save to device"
|
||||||
|
aria-label={`Save ${item.title} to device`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
transition: 'color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HardDriveDownload size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={item.url}
|
href={item.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
|
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, HardDriveDownload, Music } from 'lucide-react';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
|
import { RatingBadge } from './RatingBadge';
|
||||||
|
import { QualityLabel } from './QualityLabel';
|
||||||
import { DownloadProgressBar } from './DownloadProgressBar';
|
import { DownloadProgressBar } from './DownloadProgressBar';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
import { formatDuration, formatRelativeTime } from '../utils/format';
|
import { formatDuration, formatRelativeTime, formatFileSize } from '../utils/format';
|
||||||
import type { ContentItem } from '@shared/types/index';
|
import type { ContentItem } from '@shared/types/index';
|
||||||
|
import type { ColumnConfig } from './ColumnConfig';
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
|
|
@ -13,12 +16,59 @@ interface ContentListItemProps {
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
onToggleMonitored: (id: number, monitored: boolean) => void;
|
onToggleMonitored: (id: number, monitored: boolean) => void;
|
||||||
onDownload: (id: number) => void;
|
onDownload: (id: number) => void;
|
||||||
|
/** Optional column visibility/order config. If omitted, uses legacy hardcoded layout. */
|
||||||
|
columnConfig?: ColumnConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentListItemProps) {
|
/** Check if a column key is visible given the config */
|
||||||
|
function isVisible(config: ColumnConfig[] | undefined, key: string): boolean {
|
||||||
|
if (!config) return true; // legacy: show all default fields
|
||||||
|
return config.some((c) => c.key === key && c.visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get ordered visible column keys */
|
||||||
|
function visibleKeys(config: ColumnConfig[] | undefined): string[] {
|
||||||
|
if (!config) return ['thumbnail', 'publishedAt', 'duration', 'contentType'];
|
||||||
|
return config.filter((c) => c.visible).map((c) => c.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meta field renderers ──
|
||||||
|
|
||||||
|
function MetaSeparator() {
|
||||||
|
return <span style={{ opacity: 0.5 }}>·</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetaField(item: ContentItem, key: string): React.ReactNode {
|
||||||
|
switch (key) {
|
||||||
|
case 'contentType':
|
||||||
|
return <span style={{ textTransform: 'capitalize' }}>{item.contentType}</span>;
|
||||||
|
case 'contentRating':
|
||||||
|
return item.contentRating ? <RatingBadge rating={item.contentRating} /> : null;
|
||||||
|
case 'quality':
|
||||||
|
return item.qualityMetadata ? <QualityLabel quality={item.qualityMetadata} /> : null;
|
||||||
|
case 'publishedAt':
|
||||||
|
return formatRelativeTime(item.publishedAt) ? <span>{formatRelativeTime(item.publishedAt)}</span> : null;
|
||||||
|
case 'downloadedAt':
|
||||||
|
return formatRelativeTime(item.downloadedAt) ? <span>{formatRelativeTime(item.downloadedAt)}</span> : null;
|
||||||
|
case 'duration': {
|
||||||
|
const d = formatDuration(item.duration);
|
||||||
|
return d ? <span style={{ fontVariantNumeric: 'tabular-nums' }}>{d}</span> : null;
|
||||||
|
}
|
||||||
|
case 'fileSize': {
|
||||||
|
const s = formatFileSize(item.fileSize);
|
||||||
|
return s ? <span>{s}</span> : null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload, columnConfig }: ContentListItemProps) {
|
||||||
const progress = useDownloadProgress(item.id);
|
const progress = useDownloadProgress(item.id);
|
||||||
const duration = formatDuration(item.duration);
|
const duration = formatDuration(item.duration);
|
||||||
const published = formatRelativeTime(item.publishedAt);
|
|
||||||
|
const showThumbnail = isVisible(columnConfig, 'thumbnail');
|
||||||
|
const metaKeys = visibleKeys(columnConfig).filter((k) => k !== 'thumbnail');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -38,13 +88,11 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
onClick={() => onSelect(item.id)}
|
onClick={() => onSelect(item.id)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)';
|
if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)';
|
||||||
// Reveal checkbox on hover
|
|
||||||
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
||||||
if (cb) cb.style.opacity = '1';
|
if (cb) cb.style.opacity = '1';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border)';
|
if (!selected) e.currentTarget.style.borderColor = 'var(--border)';
|
||||||
// Hide checkbox if not selected
|
|
||||||
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
||||||
if (cb && !selected) cb.style.opacity = '0';
|
if (cb && !selected) cb.style.opacity = '0';
|
||||||
}}
|
}}
|
||||||
|
|
@ -77,6 +125,7 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
|
{showThumbnail && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
@ -143,6 +192,7 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Info section */}
|
{/* Info section */}
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
|
@ -167,7 +217,7 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
{item.title}
|
{item.title}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Meta row: published · duration · content type */}
|
{/* Meta row: dynamic columns */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -177,11 +227,16 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
fontSize: 'var(--font-size-xs)',
|
fontSize: 'var(--font-size-xs)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{published && <span>{published}</span>}
|
{metaKeys.reduce<React.ReactNode[]>((acc, key, i) => {
|
||||||
{published && duration && <span style={{ opacity: 0.5 }}>·</span>}
|
const node = renderMetaField(item, key);
|
||||||
{duration && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{duration}</span>}
|
if (node) {
|
||||||
{(published || duration) && <span style={{ opacity: 0.5 }}>·</span>}
|
if (acc.length > 0) {
|
||||||
<span style={{ textTransform: 'capitalize' }}>{item.contentType}</span>
|
acc.push(<MetaSeparator key={`sep-${key}`} />);
|
||||||
|
}
|
||||||
|
acc.push(<span key={key}>{node}</span>);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -241,6 +296,27 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.status === 'downloaded' && item.filePath && (
|
||||||
|
<a
|
||||||
|
href={`/api/v1/content/${item.id}/download`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Save to device"
|
||||||
|
aria-label={`Save ${item.title} to device`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
transition: 'color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HardDriveDownload size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={item.url}
|
href={item.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,17 @@ const CODEC_OPTIONS = ['Any', 'AAC', 'MP3', 'OPUS', 'FLAC'] as const;
|
||||||
const BITRATE_OPTIONS = ['Any', 'Best', '320k', '256k', '192k', '128k'] as const;
|
const BITRATE_OPTIONS = ['Any', 'Best', '320k', '256k', '192k', '128k'] as const;
|
||||||
const CONTAINER_OPTIONS = ['Any', 'MP4', 'MKV', 'WEBM', 'MP3'] as const;
|
const CONTAINER_OPTIONS = ['Any', 'MP4', 'MKV', 'WEBM', 'MP3'] as const;
|
||||||
|
|
||||||
|
const SPONSORBLOCK_CATEGORIES = [
|
||||||
|
{ value: 'sponsor', label: 'Sponsor' },
|
||||||
|
{ value: 'selfpromo', label: 'Self-Promotion' },
|
||||||
|
{ value: 'interaction', label: 'Interaction' },
|
||||||
|
{ value: 'intro', label: 'Intro' },
|
||||||
|
{ value: 'outro', label: 'Outro' },
|
||||||
|
{ value: 'preview', label: 'Preview' },
|
||||||
|
{ value: 'music_offtopic', label: 'Music (Off-Topic)' },
|
||||||
|
{ value: 'filler', label: 'Filler' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
export interface FormatProfileFormValues {
|
export interface FormatProfileFormValues {
|
||||||
|
|
@ -98,7 +109,11 @@ export function FormatProfileForm({
|
||||||
const [embedSubtitles, setEmbedSubtitles] = useState(profile?.embedSubtitles ?? false);
|
const [embedSubtitles, setEmbedSubtitles] = useState(profile?.embedSubtitles ?? false);
|
||||||
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
|
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
|
||||||
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
|
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
|
||||||
const [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? '');
|
const [sponsorBlockCategories, setSponsorBlockCategories] = useState<Set<string>>(() => {
|
||||||
|
const raw = profile?.sponsorBlockRemove ?? '';
|
||||||
|
if (!raw.trim()) return new Set<string>();
|
||||||
|
return new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
|
||||||
|
});
|
||||||
const [outputTemplate, setOutputTemplate] = useState(profile?.outputTemplate ?? '');
|
const [outputTemplate, setOutputTemplate] = useState(profile?.outputTemplate ?? '');
|
||||||
|
|
||||||
const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const;
|
const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const;
|
||||||
|
|
@ -147,11 +162,11 @@ export function FormatProfileForm({
|
||||||
embedSubtitles,
|
embedSubtitles,
|
||||||
embedChapters,
|
embedChapters,
|
||||||
embedThumbnail,
|
embedThumbnail,
|
||||||
sponsorBlockRemove: sponsorBlockRemove.trim() || null,
|
sponsorBlockRemove: sponsorBlockCategories.size > 0 ? [...sponsorBlockCategories].join(',') : null,
|
||||||
outputTemplate: outputTemplate.trim() || null,
|
outputTemplate: outputTemplate.trim() || null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, outputTemplate, onSubmit],
|
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockCategories, outputTemplate, onSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -328,21 +343,58 @@ export function FormatProfileForm({
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SponsorBlock Remove */}
|
{/* SponsorBlock Remove — checkbox group */}
|
||||||
<div style={fieldGroupStyle}>
|
<div style={fieldGroupStyle}>
|
||||||
<label htmlFor="fp-sponsorblock" style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
SponsorBlock — Remove Segments
|
SponsorBlock — Remove Segments
|
||||||
</label>
|
</label>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
backgroundColor: 'var(--bg-input)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
}}>
|
||||||
|
{SPONSORBLOCK_CATEGORIES.map(({ value, label }) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
htmlFor={`fp-sb-${value}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="fp-sponsorblock"
|
id={`fp-sb-${value}`}
|
||||||
type="text"
|
type="checkbox"
|
||||||
value={sponsorBlockRemove}
|
checked={sponsorBlockCategories.has(value)}
|
||||||
onChange={(e) => setSponsorBlockRemove(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder="e.g. sponsor,selfpromo,intro,outro"
|
setSponsorBlockCategories((prev) => {
|
||||||
style={inputStyle}
|
const next = new Set(prev);
|
||||||
|
if (e.target.checked) next.add(value);
|
||||||
|
else next.delete(value);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
Comma-separated categories: sponsor, selfpromo, interaction, intro, outro, preview, music_offtopic, filler
|
Selected segments will be removed from downloaded videos using SponsorBlock data.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export interface PlatformSettingsFormValues {
|
||||||
scanLimit: number;
|
scanLimit: number;
|
||||||
rateLimitDelay: number;
|
rateLimitDelay: number;
|
||||||
defaultMonitoringMode: string;
|
defaultMonitoringMode: string;
|
||||||
|
nfoEnabled: boolean;
|
||||||
|
defaultView: 'list' | 'poster' | 'table';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlatformSettingsFormProps {
|
interface PlatformSettingsFormProps {
|
||||||
|
|
@ -89,6 +91,8 @@ export function PlatformSettingsForm({
|
||||||
const [scanLimit, setScanLimit] = useState(settings?.scanLimit ?? 100);
|
const [scanLimit, setScanLimit] = useState(settings?.scanLimit ?? 100);
|
||||||
const [rateLimitDelay, setRateLimitDelay] = useState(settings?.rateLimitDelay ?? 1000);
|
const [rateLimitDelay, setRateLimitDelay] = useState(settings?.rateLimitDelay ?? 1000);
|
||||||
const [defaultMonitoringMode, setDefaultMonitoringMode] = useState(settings?.defaultMonitoringMode ?? 'all');
|
const [defaultMonitoringMode, setDefaultMonitoringMode] = useState(settings?.defaultMonitoringMode ?? 'all');
|
||||||
|
const [nfoEnabled, setNfoEnabled] = useState(settings?.nfoEnabled ?? false);
|
||||||
|
const [defaultView, setDefaultView] = useState<'list' | 'poster' | 'table'>(settings?.defaultView ?? 'list');
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
|
|
@ -103,12 +107,14 @@ export function PlatformSettingsForm({
|
||||||
scanLimit,
|
scanLimit,
|
||||||
rateLimitDelay,
|
rateLimitDelay,
|
||||||
defaultMonitoringMode,
|
defaultMonitoringMode,
|
||||||
|
nfoEnabled,
|
||||||
|
defaultView,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[defaultFormatProfileId, checkInterval, concurrencyLimit, subtitleLanguages, grabAllEnabled, grabAllOrder, scanLimit, rateLimitDelay, defaultMonitoringMode, onSubmit],
|
[defaultFormatProfileId, checkInterval, concurrencyLimit, subtitleLanguages, grabAllEnabled, grabAllOrder, scanLimit, rateLimitDelay, defaultMonitoringMode, nfoEnabled, defaultView, onSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const platformLabel = platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : platform;
|
const platformLabel = platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : platform === 'generic' ? 'Generic' : platform;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
|
@ -280,6 +286,41 @@ export function PlatformSettingsForm({
|
||||||
<span style={hintStyle}>Default monitoring mode for new channels from this platform.</span>
|
<span style={hintStyle}>Default monitoring mode for new channels from this platform.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Default View */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="ps-default-view" style={labelStyle}>Default View</label>
|
||||||
|
<select
|
||||||
|
id="ps-default-view"
|
||||||
|
value={defaultView}
|
||||||
|
onChange={(e) => setDefaultView(e.target.value as 'list' | 'poster' | 'table')}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="list">List</option>
|
||||||
|
<option value="poster">Poster</option>
|
||||||
|
<option value="table">Table</option>
|
||||||
|
</select>
|
||||||
|
<span style={hintStyle}>Default content view when browsing channels on this platform.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NFO Enabled */}
|
||||||
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<input
|
||||||
|
id="ps-nfo-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={nfoEnabled}
|
||||||
|
onChange={(e) => setNfoEnabled(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="ps-nfo-enabled" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||||
|
Generate NFO sidecar files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', marginTop: 'var(--space-5)' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', marginTop: 'var(--space-5)' }}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@ import {
|
||||||
Server,
|
Server,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Link2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { TubearrLogo } from './TubearrLogo';
|
import { TubearrLogo } from './TubearrLogo';
|
||||||
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
|
||||||
import { AddUrlModal } from './AddUrlModal';
|
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', icon: Radio, label: 'Channels' },
|
{ to: '/', icon: Radio, label: 'Channels' },
|
||||||
|
|
@ -26,7 +24,6 @@ const NAV_ITEMS = [
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const wsConnected = useDownloadProgressConnection();
|
const wsConnected = useDownloadProgressConnection();
|
||||||
const [showAddUrl, setShowAddUrl] = useState(false);
|
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
|
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
|
||||||
|
|
@ -132,38 +129,6 @@ export function Sidebar() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add URL button */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: collapsed ? 'var(--space-2) var(--space-2)' : 'var(--space-2) var(--space-3)',
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddUrl(true)}
|
|
||||||
title={collapsed ? 'Download URL' : undefined}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
width: '100%',
|
|
||||||
padding: `var(--space-2) ${collapsed ? 'var(--space-2)' : 'var(--space-3)'}`,
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
backgroundColor: 'var(--accent)',
|
|
||||||
color: 'var(--text-inverse)',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
fontWeight: 600,
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
transition: 'opacity var(--transition-fast)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link2 size={16} style={{ flexShrink: 0 }} />
|
|
||||||
{!collapsed && <span>Add URL</span>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* WebSocket connection status */}
|
{/* WebSocket connection status */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -199,7 +164,6 @@ export function Sidebar() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddUrlModal open={showAddUrl} onClose={() => setShowAddUrl(false)} />
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
boxShadow: 'var(--shadow-lg)',
|
boxShadow: 'var(--shadow-lg)',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
backgroundColor: t.variant === 'error' ? 'var(--danger-bg)' : t.variant === 'success' ? 'var(--success-bg)' : 'var(--bg-card)',
|
backgroundColor: t.variant === 'error' ? 'var(--toast-danger-bg)' : t.variant === 'success' ? 'var(--toast-success-bg)' : 'var(--toast-info-bg)',
|
||||||
border: `1px solid ${t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--border)'}`,
|
border: `1px solid ${t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--border)'}`,
|
||||||
color: t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--text-primary)',
|
color: t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--text-primary)',
|
||||||
animation: 'toast-slide-in 0.25s ease-out',
|
animation: 'toast-slide-in 0.25s ease-out',
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Filter,
|
Filter,
|
||||||
Film,
|
Film,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
|
HardDriveDownload,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
List,
|
List,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
|
|
@ -30,6 +31,7 @@ import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCanc
|
||||||
import { useChannelContentPaginated, useContentTypeCounts, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, useUpdateContentRating, type ChannelContentFilters } from '../api/hooks/useContent';
|
import { useChannelContentPaginated, useContentTypeCounts, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, useUpdateContentRating, type ChannelContentFilters } from '../api/hooks/useContent';
|
||||||
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
||||||
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
||||||
|
import { usePlatformSetting } from '../api/hooks/usePlatformSettings';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { PlatformBadge } from '../components/PlatformBadge';
|
import { PlatformBadge } from '../components/PlatformBadge';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
@ -39,6 +41,7 @@ import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
||||||
import { ContentCard } from '../components/ContentCard';
|
import { ContentCard } from '../components/ContentCard';
|
||||||
import { ContentListItem } from '../components/ContentListItem';
|
import { ContentListItem } from '../components/ContentListItem';
|
||||||
|
import { ColumnConfigPopover, DEFAULT_COLUMN_CONFIG, mergeColumnConfig, type ColumnConfig } from '../components/ColumnConfig';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
|
|
@ -82,6 +85,7 @@ export function ChannelDetail() {
|
||||||
const { data: formatProfiles } = useFormatProfiles();
|
const { data: formatProfiles } = useFormatProfiles();
|
||||||
const { data: playlistData } = useChannelPlaylists(channelId);
|
const { data: playlistData } = useChannelPlaylists(channelId);
|
||||||
const { data: contentTypeCounts } = useContentTypeCounts(channelId);
|
const { data: contentTypeCounts } = useContentTypeCounts(channelId);
|
||||||
|
const { data: platformSettings } = usePlatformSetting(channel?.platform ?? null);
|
||||||
|
|
||||||
// ── Content type tab (URL-driven) ──
|
// ── Content type tab (URL-driven) ──
|
||||||
const activeTab = searchParams.get('tab') ?? 'all';
|
const activeTab = searchParams.get('tab') ?? 'all';
|
||||||
|
|
@ -108,6 +112,20 @@ export function ChannelDetail() {
|
||||||
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
||||||
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
|
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
|
||||||
|
const [listColumnConfig, setListColumnConfig] = usePersistedState<ColumnConfig[]>(
|
||||||
|
'tubearr-list-columns',
|
||||||
|
DEFAULT_COLUMN_CONFIG,
|
||||||
|
);
|
||||||
|
const viewModeOverriddenRef = useRef(false);
|
||||||
|
|
||||||
|
// Merge stored column config with current column defs (handles new columns)
|
||||||
|
const mergedColumnConfig = mergeColumnConfig(listColumnConfig);
|
||||||
|
// Sync merged config back if it differs (new columns were added)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mergedColumnConfig.length !== listColumnConfig.length) {
|
||||||
|
setListColumnConfig(mergedColumnConfig);
|
||||||
|
}
|
||||||
|
}, [mergedColumnConfig.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Derive contentType filter from active tab
|
// Derive contentType filter from active tab
|
||||||
const contentTypeFilter = activeTab === 'all' ? '' : activeTab;
|
const contentTypeFilter = activeTab === 'all' ? '' : activeTab;
|
||||||
|
|
@ -176,6 +194,18 @@ export function ChannelDetail() {
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Reset view-mode override when navigating to a different channel
|
||||||
|
useEffect(() => {
|
||||||
|
viewModeOverriddenRef.current = false;
|
||||||
|
}, [channelId]);
|
||||||
|
|
||||||
|
// Apply platform default view when settings load (only if user hasn't manually switched)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!platformSettings?.defaultView || viewModeOverriddenRef.current) return;
|
||||||
|
const mapped = platformSettings.defaultView === 'poster' ? 'card' : platformSettings.defaultView;
|
||||||
|
setViewMode(mapped);
|
||||||
|
}, [platformSettings?.defaultView, setViewMode]);
|
||||||
|
|
||||||
// Sync local check interval from channel data
|
// Sync local check interval from channel data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channel?.checkInterval != null) {
|
if (channel?.checkInterval != null) {
|
||||||
|
|
@ -344,6 +374,7 @@ export function ChannelDetail() {
|
||||||
}, [groupBy]);
|
}, [groupBy]);
|
||||||
|
|
||||||
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
|
const handleSetViewMode = useCallback((mode: 'table' | 'card' | 'list') => {
|
||||||
|
viewModeOverriddenRef.current = true;
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
}, [setViewMode]);
|
}, [setViewMode]);
|
||||||
|
|
||||||
|
|
@ -706,6 +737,32 @@ export function ChannelDetail() {
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
label: '',
|
||||||
|
width: '36px',
|
||||||
|
render: (item) =>
|
||||||
|
item.status === 'downloaded' && item.filePath ? (
|
||||||
|
<a
|
||||||
|
href={`/api/v1/content/${item.id}/download`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Save to device"
|
||||||
|
aria-label={`Save ${item.title} to device`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
transition: 'color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HardDriveDownload size={14} />
|
||||||
|
</a>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
|
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
|
||||||
);
|
);
|
||||||
|
|
@ -781,12 +838,13 @@ export function ChannelDetail() {
|
||||||
onSelect={toggleSelect}
|
onSelect={toggleSelect}
|
||||||
onToggleMonitored={(id, monitored) => toggleMonitored.mutate({ contentId: id, monitored })}
|
onToggleMonitored={(id, monitored) => toggleMonitored.mutate({ contentId: id, monitored })}
|
||||||
onDownload={(id) => downloadContent.mutate(id)}
|
onDownload={(id) => downloadContent.mutate(id)}
|
||||||
|
columnConfig={mergedColumnConfig}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
|
[selectedIds, toggleSelect, toggleMonitored, downloadContent, mergedColumnConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderGroupedContent = useCallback(
|
const renderGroupedContent = useCallback(
|
||||||
|
|
@ -903,7 +961,7 @@ export function ChannelDetail() {
|
||||||
gap: 'var(--space-1)',
|
gap: 'var(--space-1)',
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
fontSize: 'var(--font-size-sm)',
|
fontSize: 'var(--font-size-sm)',
|
||||||
marginBottom: 'var(--space-5)',
|
marginBottom: 'var(--space-2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} /> Back to Channels
|
<ArrowLeft size={14} /> Back to Channels
|
||||||
|
|
@ -1326,22 +1384,7 @@ export function ChannelDetail() {
|
||||||
)}
|
)}
|
||||||
{collectMonitored.isPending ? 'Collecting…' : 'Collect'}
|
{collectMonitored.isPending ? 'Collecting…' : 'Collect'}
|
||||||
</button>
|
</button>
|
||||||
{isYouTube ? (
|
|
||||||
<button
|
|
||||||
onClick={handleRefreshPlaylists}
|
|
||||||
disabled={refreshPlaylists.isPending}
|
|
||||||
title="Refresh Playlists"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ opacity: refreshPlaylists.isPending ? 0.6 : 1 }}
|
|
||||||
>
|
|
||||||
{refreshPlaylists.isPending ? (
|
|
||||||
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
|
||||||
) : (
|
|
||||||
<ListMusic size={14} />
|
|
||||||
)}
|
|
||||||
{refreshPlaylists.isPending ? 'Refreshing…' : 'Playlists'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1810,6 +1853,12 @@ export function ChannelDetail() {
|
||||||
<LayoutList size={16} />
|
<LayoutList size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<ColumnConfigPopover
|
||||||
|
columns={mergedColumnConfig}
|
||||||
|
onChange={setListColumnConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Sort & Group controls */}
|
{/* Sort & Group controls */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
|
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Plus, Loader, RefreshCw, Download, Link2, Search } from 'lucide-react';
|
||||||
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
|
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
|
||||||
import { useCollectAllMonitored } from '../api/hooks/useContent';
|
import { useCollectAllMonitored } from '../api/hooks/useContent';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
|
|
@ -8,16 +8,45 @@ import { PlatformBadge } from '../components/PlatformBadge';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { ProgressBar } from '../components/ProgressBar';
|
import { ProgressBar } from '../components/ProgressBar';
|
||||||
import { AddChannelModal } from '../components/AddChannelModal';
|
import { AddChannelModal } from '../components/AddChannelModal';
|
||||||
|
import { AddUrlModal } from '../components/AddUrlModal';
|
||||||
import { SkeletonChannelsList } from '../components/Skeleton';
|
import { SkeletonChannelsList } from '../components/Skeleton';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { formatRelativeTime } from '../utils/format';
|
import { formatRelativeTime } from '../utils/format';
|
||||||
import type { ChannelWithCounts } from '@shared/types/api';
|
import type { ChannelWithCounts } from '@shared/types/api';
|
||||||
|
|
||||||
|
// ── Channel list sort/group types ──
|
||||||
|
|
||||||
|
type ChannelSortKey = 'name' | 'platform' | 'lastCheckedAt' | 'contentCount';
|
||||||
|
type ChannelGroupBy = 'none' | 'platform';
|
||||||
|
|
||||||
|
interface ChannelSortButton {
|
||||||
|
key: ChannelSortKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_SORT_BUTTONS: ChannelSortButton[] = [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'platform', label: 'Platform' },
|
||||||
|
{ key: 'lastCheckedAt', label: 'Last Checked' },
|
||||||
|
{ key: 'contentCount', label: 'Content Count' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHANNEL_GROUP_OPTIONS: { value: ChannelGroupBy; label: string }[] = [
|
||||||
|
{ value: 'none', label: 'No Grouping' },
|
||||||
|
{ value: 'platform', label: 'Platform' },
|
||||||
|
];
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Channels() {
|
export function Channels() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [showAddUrl, setShowAddUrl] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortKey, setSortKey] = useState<ChannelSortKey | null>(null);
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [groupBy, setGroupBy] = useState<ChannelGroupBy>('none');
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: channels, isLoading, error, refetch } = useChannels();
|
const { data: channels, isLoading, error, refetch } = useChannels();
|
||||||
|
|
@ -62,6 +91,97 @@ export function Channels() {
|
||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Sort handler ──
|
||||||
|
const handleSortClick = useCallback((key: ChannelSortKey) => {
|
||||||
|
setSortKey((prev) => {
|
||||||
|
if (prev === key) {
|
||||||
|
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
setSortDirection('asc');
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Toggle group expand/collapse ──
|
||||||
|
const toggleGroup = useCallback((id: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Filtered, sorted, grouped channels ──
|
||||||
|
const filteredChannels = useMemo(() => {
|
||||||
|
let result = channels ?? [];
|
||||||
|
|
||||||
|
// Text search — case-insensitive on channel name
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
result = result.filter((c) => c.name.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
if (sortKey) {
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'name':
|
||||||
|
cmp = a.name.localeCompare(b.name);
|
||||||
|
break;
|
||||||
|
case 'platform':
|
||||||
|
cmp = a.platform.localeCompare(b.platform);
|
||||||
|
break;
|
||||||
|
case 'lastCheckedAt': {
|
||||||
|
const aTime = a.lastCheckedAt ? new Date(a.lastCheckedAt).getTime() : 0;
|
||||||
|
const bTime = b.lastCheckedAt ? new Date(b.lastCheckedAt).getTime() : 0;
|
||||||
|
cmp = aTime - bTime;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'contentCount': {
|
||||||
|
const aCount = a.contentCounts?.total ?? 0;
|
||||||
|
const bCount = b.contentCounts?.total ?? 0;
|
||||||
|
cmp = aCount - bCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortDirection === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [channels, searchQuery, sortKey, sortDirection]);
|
||||||
|
|
||||||
|
const groupedChannels = useMemo<{ id: string; title: string; platform?: string; items: ChannelWithCounts[] }[] | null>(() => {
|
||||||
|
if (groupBy === 'none') return null;
|
||||||
|
|
||||||
|
// Group by platform
|
||||||
|
const platformMap = new Map<string, ChannelWithCounts[]>();
|
||||||
|
for (const ch of filteredChannels) {
|
||||||
|
const key = ch.platform;
|
||||||
|
const arr = platformMap.get(key);
|
||||||
|
if (arr) {
|
||||||
|
arr.push(ch);
|
||||||
|
} else {
|
||||||
|
platformMap.set(key, [ch]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(platformMap.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([platform, items]) => ({
|
||||||
|
id: platform,
|
||||||
|
title: platform.charAt(0).toUpperCase() + platform.slice(1),
|
||||||
|
platform,
|
||||||
|
items,
|
||||||
|
}));
|
||||||
|
}, [groupBy, filteredChannels]);
|
||||||
|
|
||||||
const columns = useMemo<Column<ChannelWithCounts>[]>(
|
const columns = useMemo<Column<ChannelWithCounts>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
|
@ -240,10 +360,19 @@ export function Channels() {
|
||||||
{collectAll.isPending ? (
|
{collectAll.isPending ? (
|
||||||
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
) : (
|
) : (
|
||||||
<Search size={16} />
|
<Download size={16} />
|
||||||
)}
|
)}
|
||||||
{collectAll.isPending ? 'Collecting...' : 'Collect All Monitored'}
|
{collectAll.isPending ? 'Collecting...' : 'Collect All Monitored'}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Add URL button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddUrl(true)}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title="Download URL"
|
||||||
|
>
|
||||||
|
<Link2 size={16} />
|
||||||
|
Add URL
|
||||||
|
</button>
|
||||||
{/* Add Channel button */}
|
{/* Add Channel button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(true)}
|
onClick={() => setShowAddModal(true)}
|
||||||
|
|
@ -257,7 +386,218 @@ export function Channels() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel table */}
|
{/* Filter bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
padding: 'var(--space-3) var(--space-5)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search channels..."
|
||||||
|
aria-label="Search channels by name"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 'var(--space-1) var(--space-2)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-input)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort + Group row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-3) var(--space-5)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sort label */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
marginRight: 'var(--space-1)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sort
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Sort buttons */}
|
||||||
|
{CHANNEL_SORT_BUTTONS.map((btn) => {
|
||||||
|
const isActive = sortKey === btn.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={btn.key}
|
||||||
|
onClick={() => handleSortClick(btn.key)}
|
||||||
|
aria-label={`Sort by ${btn.label}${isActive ? `, currently ${sortDirection === 'asc' ? 'ascending' : 'descending'}` : ''}`}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: 'var(--space-1) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
backgroundColor: isActive ? 'var(--accent)' : 'transparent',
|
||||||
|
color: isActive ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: isActive ? 'none' : '1px solid var(--border-light)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
{isActive && (
|
||||||
|
sortDirection === 'asc'
|
||||||
|
? <ArrowUp size={12} />
|
||||||
|
: <ArrowDown size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Group by */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
marginRight: 'var(--space-1)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Group
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={groupBy}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGroupBy(e.target.value as ChannelGroupBy);
|
||||||
|
setExpandedGroups(new Set());
|
||||||
|
}}
|
||||||
|
aria-label="Group channels by"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-1) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
minWidth: 110,
|
||||||
|
backgroundColor: 'var(--bg-input)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CHANNEL_GROUP_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel table / grouped view */}
|
||||||
|
{groupedChannels ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{groupedChannels.length === 0 ? (
|
||||||
|
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
No channels match your search.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupedChannels.map((group) => {
|
||||||
|
const isExpanded = expandedGroups.has(group.id);
|
||||||
|
return (
|
||||||
|
<div key={group.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(group.id)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--space-3) var(--space-5)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
{group.platform && <PlatformBadge platform={group.platform as ChannelWithCounts['platform']} />}
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: 'var(--font-size-sm)' }}>
|
||||||
|
{group.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '1px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.items.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={group.items}
|
||||||
|
keyExtractor={(c) => c.id}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
emptyMessage="No channels in this group."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
|
@ -268,15 +608,19 @@ export function Channels() {
|
||||||
>
|
>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={channels ?? []}
|
data={filteredChannels}
|
||||||
keyExtractor={(c) => c.id}
|
keyExtractor={(c) => c.id}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
emptyMessage="No channels added yet. Add a YouTube channel or SoundCloud artist to get started."
|
emptyMessage={searchQuery.trim() ? 'No channels match your search.' : 'No channels added yet. Add a YouTube channel or SoundCloud artist to get started.'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add Channel modal */}
|
{/* Add Channel modal */}
|
||||||
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
||||||
|
|
||||||
|
{/* Add URL modal */}
|
||||||
|
<AddUrlModal open={showAddUrl} onClose={() => setShowAddUrl(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { ListOrdered, RotateCcw, X, RefreshCw } from 'lucide-react';
|
import { ListOrdered, RotateCcw, X, RefreshCw, Pause, Play } from 'lucide-react';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { SkeletonQueueList } from '../components/Skeleton';
|
import { SkeletonQueueList } from '../components/Skeleton';
|
||||||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
|
import { useQueue, useRetryQueueItem, useCancelQueueItem, usePauseQueueItem, useResumeQueueItem } from '../api/hooks/useQueue';
|
||||||
import { formatShortDateTime } from '../utils/format';
|
import { formatShortDateTime } from '../utils/format';
|
||||||
import { useTimezone } from '../hooks/useTimezone';
|
import { useTimezone } from '../hooks/useTimezone';
|
||||||
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
||||||
|
|
@ -57,6 +57,8 @@ export function Queue() {
|
||||||
const { data: items, isLoading, error, refetch } = useQueue(statusFilter);
|
const { data: items, isLoading, error, refetch } = useQueue(statusFilter);
|
||||||
const retryMutation = useRetryQueueItem();
|
const retryMutation = useRetryQueueItem();
|
||||||
const cancelMutation = useCancelQueueItem();
|
const cancelMutation = useCancelQueueItem();
|
||||||
|
const pauseMutation = usePauseQueueItem();
|
||||||
|
const resumeMutation = useResumeQueueItem();
|
||||||
|
|
||||||
// Table columns
|
// Table columns
|
||||||
const columns: Column<QueueItem>[] = useMemo(
|
const columns: Column<QueueItem>[] = useMemo(
|
||||||
|
|
@ -232,7 +234,7 @@ export function Queue() {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[retryMutation, cancelMutation],
|
[retryMutation, cancelMutation, pauseMutation, resumeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabBase: React.CSSProperties = {
|
const tabBase: React.CSSProperties = {
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ export function SettingsPage() {
|
||||||
const [checkInterval, setCheckInterval] = useState<number | ''>('');
|
const [checkInterval, setCheckInterval] = useState<number | ''>('');
|
||||||
const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>('');
|
const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>('');
|
||||||
const [outputTemplate, setOutputTemplate] = useState('');
|
const [outputTemplate, setOutputTemplate] = useState('');
|
||||||
const [nfoEnabled, setNfoEnabled] = useState(false);
|
|
||||||
const [timezone, setTimezone] = useState('UTC');
|
const [timezone, setTimezone] = useState('UTC');
|
||||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||||
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
||||||
|
|
@ -134,7 +133,6 @@ export function SettingsPage() {
|
||||||
setCheckInterval(appSettings.checkInterval);
|
setCheckInterval(appSettings.checkInterval);
|
||||||
setConcurrentDownloads(appSettings.concurrentDownloads);
|
setConcurrentDownloads(appSettings.concurrentDownloads);
|
||||||
setOutputTemplate(appSettings.outputTemplate);
|
setOutputTemplate(appSettings.outputTemplate);
|
||||||
setNfoEnabled(appSettings.nfoEnabled);
|
|
||||||
setTimezone(appSettings.timezone);
|
setTimezone(appSettings.timezone);
|
||||||
setTheme(appSettings.theme);
|
setTheme(appSettings.theme);
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +152,6 @@ export function SettingsPage() {
|
||||||
(Number(checkInterval) !== appSettings.checkInterval ||
|
(Number(checkInterval) !== appSettings.checkInterval ||
|
||||||
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
||||||
outputTemplate !== appSettings.outputTemplate ||
|
outputTemplate !== appSettings.outputTemplate ||
|
||||||
nfoEnabled !== appSettings.nfoEnabled ||
|
|
||||||
timezone !== appSettings.timezone ||
|
timezone !== appSettings.timezone ||
|
||||||
theme !== appSettings.theme);
|
theme !== appSettings.theme);
|
||||||
|
|
||||||
|
|
@ -207,7 +204,6 @@ export function SettingsPage() {
|
||||||
checkInterval: Number(checkInterval),
|
checkInterval: Number(checkInterval),
|
||||||
concurrentDownloads: Number(concurrentDownloads),
|
concurrentDownloads: Number(concurrentDownloads),
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
nfoEnabled,
|
|
||||||
timezone,
|
timezone,
|
||||||
theme,
|
theme,
|
||||||
},
|
},
|
||||||
|
|
@ -312,7 +308,7 @@ export function SettingsPage() {
|
||||||
|
|
||||||
// ── Platform Settings handlers ──
|
// ── Platform Settings handlers ──
|
||||||
|
|
||||||
const KNOWN_PLATFORMS = ['youtube', 'soundcloud'] as const;
|
const KNOWN_PLATFORMS = ['youtube', 'soundcloud', 'generic'] as const;
|
||||||
|
|
||||||
const platformSettingsMap = useMemo(() => {
|
const platformSettingsMap = useMemo(() => {
|
||||||
const map = new Map<string, PlatformSettings>();
|
const map = new Map<string, PlatformSettings>();
|
||||||
|
|
@ -337,6 +333,8 @@ export function SettingsPage() {
|
||||||
subtitleLanguages: values.subtitleLanguages || null,
|
subtitleLanguages: values.subtitleLanguages || null,
|
||||||
grabAllEnabled: values.grabAllEnabled,
|
grabAllEnabled: values.grabAllEnabled,
|
||||||
grabAllOrder: values.grabAllOrder,
|
grabAllOrder: values.grabAllOrder,
|
||||||
|
nfoEnabled: values.nfoEnabled,
|
||||||
|
defaultView: values.defaultView,
|
||||||
};
|
};
|
||||||
updatePlatformSettingsMutation.mutate(input, {
|
updatePlatformSettingsMutation.mutate(input, {
|
||||||
onSuccess: () => setEditingPlatform(null),
|
onSuccess: () => setEditingPlatform(null),
|
||||||
|
|
@ -357,7 +355,7 @@ export function SettingsPage() {
|
||||||
() =>
|
() =>
|
||||||
KNOWN_PLATFORMS.map((platform) => ({
|
KNOWN_PLATFORMS.map((platform) => ({
|
||||||
platform,
|
platform,
|
||||||
label: platform === 'youtube' ? 'YouTube' : 'SoundCloud',
|
label: platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : 'Generic',
|
||||||
settings: platformSettingsMap.get(platform) ?? null,
|
settings: platformSettingsMap.get(platform) ?? null,
|
||||||
})),
|
})),
|
||||||
[platformSettingsMap],
|
[platformSettingsMap],
|
||||||
|
|
@ -369,17 +367,25 @@ export function SettingsPage() {
|
||||||
key: 'platform',
|
key: 'platform',
|
||||||
label: 'Platform',
|
label: 'Platform',
|
||||||
width: '130px',
|
width: '130px',
|
||||||
render: (row) => (
|
render: (row) => {
|
||||||
|
const colorMap: Record<string, { color: string; bg: string }> = {
|
||||||
|
youtube: { color: '#ff4444', bg: 'rgba(255, 68, 68, 0.1)' },
|
||||||
|
soundcloud: { color: '#ff7700', bg: 'rgba(255, 119, 0, 0.1)' },
|
||||||
|
generic: { color: '#8b8d97', bg: 'rgba(139, 141, 151, 0.1)' },
|
||||||
|
};
|
||||||
|
const c = colorMap[row.platform] ?? colorMap.generic;
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
...badgeBase,
|
...badgeBase,
|
||||||
color: row.platform === 'youtube' ? '#ff4444' : '#ff7700',
|
color: c.color,
|
||||||
backgroundColor: row.platform === 'youtube' ? 'rgba(255, 68, 68, 0.1)' : 'rgba(255, 119, 0, 0.1)',
|
backgroundColor: c.bg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.label}
|
{row.label}
|
||||||
</span>
|
</span>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'formatProfile',
|
key: 'formatProfile',
|
||||||
|
|
@ -433,6 +439,35 @@ export function SettingsPage() {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'nfo',
|
||||||
|
label: 'NFO',
|
||||||
|
width: '70px',
|
||||||
|
render: (row) => {
|
||||||
|
const enabled = row.settings?.nfoEnabled ?? false;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...badgeBase,
|
||||||
|
color: enabled ? 'var(--success)' : 'var(--text-muted)',
|
||||||
|
backgroundColor: enabled ? 'var(--success-bg)' : 'var(--bg-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{enabled ? 'On' : 'Off'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'defaultView',
|
||||||
|
label: 'View',
|
||||||
|
width: '80px',
|
||||||
|
render: (row) => (
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', textTransform: 'capitalize' }}>
|
||||||
|
{row.settings?.defaultView ?? 'list'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
|
|
@ -1339,84 +1374,6 @@ export function SettingsPage() {
|
||||||
{templatePreview || '—'}
|
{templatePreview || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NFO Sidecar toggle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 'var(--space-4)',
|
|
||||||
paddingTop: 'var(--space-4)',
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="nfo-enabled"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generate NFO Files
|
|
||||||
</label>
|
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
|
||||||
Write Kodi-compatible .nfo sidecar files alongside downloaded media
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 44,
|
|
||||||
height: 24,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="nfo-enabled"
|
|
||||||
type="checkbox"
|
|
||||||
checked={nfoEnabled}
|
|
||||||
onChange={(e) => setNfoEnabled(e.target.checked)}
|
|
||||||
style={{
|
|
||||||
opacity: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
cursor: 'pointer',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: nfoEnabled ? 'var(--accent)' : 'var(--bg-hover)',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${nfoEnabled ? 'var(--accent)' : 'var(--border)'}`,
|
|
||||||
transition: 'background-color 0.2s, border-color 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
content: '""',
|
|
||||||
height: 18,
|
|
||||||
width: 18,
|
|
||||||
left: nfoEnabled ? 22 : 2,
|
|
||||||
top: 2,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '50%',
|
|
||||||
transition: 'left 0.2s',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -1664,7 +1621,7 @@ export function SettingsPage() {
|
||||||
|
|
||||||
{/* ── Platform Settings: Edit modal ── */}
|
{/* ── Platform Settings: Edit modal ── */}
|
||||||
<Modal
|
<Modal
|
||||||
title={`${editingPlatform === 'youtube' ? 'YouTube' : editingPlatform === 'soundcloud' ? 'SoundCloud' : editingPlatform ?? ''} Settings`}
|
title={`${editingPlatform === 'youtube' ? 'YouTube' : editingPlatform === 'soundcloud' ? 'SoundCloud' : editingPlatform === 'generic' ? 'Generic' : editingPlatform ?? ''} Settings`}
|
||||||
open={!!editingPlatform}
|
open={!!editingPlatform}
|
||||||
onClose={() => setEditingPlatform(null)}
|
onClose={() => setEditingPlatform(null)}
|
||||||
width={520}
|
width={520}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@
|
||||||
--info: #e05d44;
|
--info: #e05d44;
|
||||||
--info-bg: rgba(224, 93, 68, 0.1);
|
--info-bg: rgba(224, 93, 68, 0.1);
|
||||||
|
|
||||||
|
/* ── Toast backgrounds (opaque — toasts float over arbitrary content) ── */
|
||||||
|
--toast-success-bg: #152b1a;
|
||||||
|
--toast-danger-bg: #2e1515;
|
||||||
|
--toast-info-bg: var(--bg-card-solid);
|
||||||
|
|
||||||
/* ── Borders ── */
|
/* ── Borders ── */
|
||||||
--border: rgba(255, 255, 255, 0.06);
|
--border: rgba(255, 255, 255, 0.06);
|
||||||
--border-light: rgba(255, 255, 255, 0.1);
|
--border-light: rgba(255, 255, 255, 0.1);
|
||||||
|
|
@ -136,6 +141,11 @@
|
||||||
--info: #d14836;
|
--info: #d14836;
|
||||||
--info-bg: rgba(209, 72, 54, 0.08);
|
--info-bg: rgba(209, 72, 54, 0.08);
|
||||||
|
|
||||||
|
/* ── Toast backgrounds (opaque) ── */
|
||||||
|
--toast-success-bg: #e8f5e9;
|
||||||
|
--toast-danger-bg: #fde8e8;
|
||||||
|
--toast-info-bg: var(--bg-card-solid);
|
||||||
|
|
||||||
/* ── Borders ── */
|
/* ── Borders ── */
|
||||||
--border: rgba(0, 0, 0, 0.08);
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
--border-light: rgba(0, 0, 0, 0.12);
|
--border-light: rgba(0, 0, 0, 0.12);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { createReadStream, statSync } from 'node:fs';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { parseIdParam } from './helpers';
|
import { parseIdParam } from './helpers';
|
||||||
import {
|
import {
|
||||||
getAllContentItems,
|
getAllContentItems,
|
||||||
|
|
@ -9,6 +12,8 @@ import {
|
||||||
bulkSetMonitored,
|
bulkSetMonitored,
|
||||||
updateContentItem,
|
updateContentItem,
|
||||||
} from '../../db/repositories/content-repository';
|
} from '../../db/repositories/content-repository';
|
||||||
|
import { contentItems } from '../../db/schema/index';
|
||||||
|
import { appConfig } from '../../config/index';
|
||||||
import type { PaginatedResponse, ApiResponse, ContentTypeCounts } from '../../types/api';
|
import type { PaginatedResponse, ApiResponse, ContentTypeCounts } from '../../types/api';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
|
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
|
||||||
|
|
||||||
|
|
@ -359,4 +364,93 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Download-to-Client Endpoint ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/content/:id/download — Stream a downloaded media file to the browser.
|
||||||
|
*
|
||||||
|
* Authenticated (same-origin bypass or API key) — unlike the public /api/v1/media/
|
||||||
|
* endpoint used by podcast apps, this triggers a browser download via
|
||||||
|
* Content-Disposition: attachment.
|
||||||
|
*/
|
||||||
|
fastify.get<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/content/:id/download',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply);
|
||||||
|
if (id === null) {
|
||||||
|
return; // reply already sent by parseIdParam
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = fastify.db;
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
filePath: contentItems.filePath,
|
||||||
|
fileSize: contentItems.fileSize,
|
||||||
|
format: contentItems.format,
|
||||||
|
status: contentItems.status,
|
||||||
|
})
|
||||||
|
.from(contentItems)
|
||||||
|
.where(eq(contentItems.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Content item ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = rows[0];
|
||||||
|
|
||||||
|
if (item.status !== 'downloaded' || !item.filePath) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `No downloaded file for content item ${id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve file path (may be relative to media path)
|
||||||
|
const filePath = item.filePath.startsWith('/')
|
||||||
|
? item.filePath
|
||||||
|
: `${appConfig.mediaPath}/${item.filePath}`;
|
||||||
|
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = statSync(filePath);
|
||||||
|
} catch {
|
||||||
|
request.log.warn({ contentId: id, filePath }, '[content] Download file not found on disk');
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Media file not found on disk',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type from extension or format field
|
||||||
|
const ext = basename(item.filePath).split('.').pop()?.toLowerCase();
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
opus: 'audio/opus',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
webm: 'video/webm',
|
||||||
|
};
|
||||||
|
const mimeType = (ext && mimeMap[ext]) || 'application/octet-stream';
|
||||||
|
const fileName = basename(item.filePath);
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
return reply
|
||||||
|
.header('Content-Length', stat.size)
|
||||||
|
.header('Content-Type', mimeType)
|
||||||
|
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`)
|
||||||
|
.send(stream);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ export async function healthRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// robots.txt — disallow all crawlers (private media manager)
|
||||||
|
fastify.get('/robots.txt', async (_request, reply) => {
|
||||||
|
return reply
|
||||||
|
.type('text/plain')
|
||||||
|
.send('User-agent: *\nDisallow: /\n');
|
||||||
|
});
|
||||||
|
|
||||||
// Authenticated component health
|
// Authenticated component health
|
||||||
fastify.get('/api/v1/health', async (_request, _reply) => {
|
fastify.get('/api/v1/health', async (_request, _reply) => {
|
||||||
const components: ComponentHealth[] = [];
|
const components: ComponentHealth[] = [];
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Platform } from '../../types/index';
|
||||||
|
|
||||||
// ── JSON Schemas for Fastify Validation ──
|
// ── JSON Schemas for Fastify Validation ──
|
||||||
|
|
||||||
const VALID_PLATFORMS = [Platform.YouTube, Platform.SoundCloud] as const;
|
const VALID_PLATFORMS = [Platform.YouTube, Platform.SoundCloud, Platform.Generic] as const;
|
||||||
|
|
||||||
const upsertPlatformSettingsBodySchema = {
|
const upsertPlatformSettingsBodySchema = {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
|
|
@ -23,6 +23,8 @@ const upsertPlatformSettingsBodySchema = {
|
||||||
scanLimit: { type: 'integer' as const, minimum: 10, maximum: 1000 },
|
scanLimit: { type: 'integer' as const, minimum: 10, maximum: 1000 },
|
||||||
rateLimitDelay: { type: 'integer' as const, minimum: 0, maximum: 10000 },
|
rateLimitDelay: { type: 'integer' as const, minimum: 0, maximum: 10000 },
|
||||||
defaultMonitoringMode: { type: 'string' as const, enum: ['all', 'future', 'existing', 'none'] },
|
defaultMonitoringMode: { type: 'string' as const, enum: ['all', 'future', 'existing', 'none'] },
|
||||||
|
nfoEnabled: { type: 'boolean' as const },
|
||||||
|
defaultView: { type: 'string' as const, enum: ['list', 'poster', 'table'] },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -79,6 +81,8 @@ export async function platformSettingsRoutes(fastify: FastifyInstance): Promise<
|
||||||
scanLimit?: number;
|
scanLimit?: number;
|
||||||
rateLimitDelay?: number;
|
rateLimitDelay?: number;
|
||||||
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
||||||
|
nfoEnabled?: boolean;
|
||||||
|
defaultView?: 'list' | 'poster' | 'table';
|
||||||
};
|
};
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/platform-settings/:platform',
|
'/api/v1/platform-settings/:platform',
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
import type * as schema from '../db/schema/index';
|
import type * as schema from '../db/schema/index';
|
||||||
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
|
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
|
||||||
import { updateContentItem } from '../db/repositories/content-repository';
|
import { updateContentItem } from '../db/repositories/content-repository';
|
||||||
import { getAppSetting, APP_NFO_ENABLED } from '../db/repositories/system-config-repository';
|
import { getPlatformSettings } from '../db/repositories/platform-settings-repository';
|
||||||
import { generateNfo, writeNfoFile } from './nfo-generator';
|
import { generateNfo, writeNfoFile } from './nfo-generator';
|
||||||
import { parseProgressLine } from './progress-parser';
|
import { parseProgressLine } from './progress-parser';
|
||||||
import type { DownloadEventBus } from './event-bus';
|
import type { DownloadEventBus } from './event-bus';
|
||||||
|
|
@ -140,7 +140,7 @@ export class DownloadService {
|
||||||
this.rateLimiter.reportSuccess(platform as Platform);
|
this.rateLimiter.reportSuccess(platform as Platform);
|
||||||
|
|
||||||
// Generate NFO sidecar if enabled
|
// Generate NFO sidecar if enabled
|
||||||
await this.maybeWriteNfo(contentItem, channel, finalPath, logPrefix);
|
await this.maybeWriteNfo(contentItem, channel, finalPath, logPrefix, platform);
|
||||||
|
|
||||||
// Emit download:complete event
|
// Emit download:complete event
|
||||||
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
||||||
|
|
@ -471,11 +471,12 @@ export class DownloadService {
|
||||||
contentItem: ContentItem,
|
contentItem: ContentItem,
|
||||||
channel: Channel | null,
|
channel: Channel | null,
|
||||||
mediaFilePath: string,
|
mediaFilePath: string,
|
||||||
logPrefix: string
|
logPrefix: string,
|
||||||
|
platform: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const nfoEnabled = await getAppSetting(this.db, APP_NFO_ENABLED);
|
const settings = await getPlatformSettings(this.db, platform);
|
||||||
if (nfoEnabled !== 'true') return;
|
if (!settings?.nfoEnabled) return;
|
||||||
|
|
||||||
const nfoXml = generateNfo(contentItem, channel);
|
const nfoXml = generateNfo(contentItem, channel);
|
||||||
const nfoPath = await writeNfoFile(nfoXml, mediaFilePath);
|
const nfoPath = await writeNfoFile(nfoXml, mediaFilePath);
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,16 @@ export class MissingFileScanner {
|
||||||
.from(systemConfig)
|
.from(systemConfig)
|
||||||
.where(eq(systemConfig.key, SCAN_LAST_RESULT_KEY));
|
.where(eq(systemConfig.key, SCAN_LAST_RESULT_KEY));
|
||||||
|
|
||||||
return {
|
let result: ScanResult = { checked: 0, missing: 0, duration: 0 };
|
||||||
lastRun: rows[0].value,
|
if (resultRows.length > 0) {
|
||||||
result: resultRows.length > 0 ? JSON.parse(resultRows[0].value) : { checked: 0, missing: 0, duration: 0 },
|
try {
|
||||||
};
|
result = JSON.parse(resultRows[0].value) as ScanResult;
|
||||||
|
} catch {
|
||||||
|
// Corrupt stored result — return default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lastRun: rows[0].value, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ──
|
// ── Private ──
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
updateContentItem,
|
updateContentItem,
|
||||||
} from '../db/repositories/content-repository';
|
} from '../db/repositories/content-repository';
|
||||||
import { getPlatformSettings } from '../db/repositories/platform-settings-repository';
|
import { getPlatformSettings } from '../db/repositories/platform-settings-repository';
|
||||||
|
import { upsertPlaylists } from '../db/repositories/playlist-repository';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
|
|
@ -330,6 +331,18 @@ export class SchedulerService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 10. Best-effort playlist sync (K011 sidecar pattern)
|
||||||
|
// Runs after content scan succeeds — failure never affects the scan result.
|
||||||
|
if (source.fetchPlaylists && !effectiveSignal.aborted) {
|
||||||
|
this.syncPlaylists(channel, source)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(
|
||||||
|
`[scheduler] Playlist sync failed for channel ${channel.id}:`,
|
||||||
|
err instanceof Error ? err.message : err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
channelName: channel.name,
|
channelName: channel.name,
|
||||||
|
|
@ -467,9 +480,11 @@ export class SchedulerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const videoUrl = `https://www.youtube.com/watch?v=${item.platformContentId}`;
|
// Use the item's original URL (platform-agnostic) instead of
|
||||||
|
// hardcoding YouTube — SoundCloud / generic items have their own URLs.
|
||||||
|
const contentUrl = item.url || `https://www.youtube.com/watch?v=${item.platformContentId}`;
|
||||||
const enrichResult = await execYtDlp(
|
const enrichResult = await execYtDlp(
|
||||||
['--dump-json', '--no-playlist', videoUrl],
|
['--dump-json', '--no-playlist', contentUrl],
|
||||||
{ timeout: 15_000 }
|
{ timeout: 15_000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -517,6 +532,33 @@ export class SchedulerService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort playlist sync for a channel.
|
||||||
|
* Fetches playlists from the platform source and upserts them into the DB.
|
||||||
|
* Failure is logged but never propagated (K011 sidecar pattern).
|
||||||
|
*/
|
||||||
|
private async syncPlaylists(channel: Channel, source: PlatformSource): Promise<void> {
|
||||||
|
if (!source.fetchPlaylists) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const discoveryResults = await source.fetchPlaylists(channel);
|
||||||
|
if (discoveryResults.length === 0) {
|
||||||
|
console.log(`[scheduler] Playlist sync: no playlists found for channel ${channel.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upserted = await upsertPlaylists(this.db, channel.id, discoveryResults);
|
||||||
|
console.log(
|
||||||
|
`[scheduler] Playlist sync complete for channel ${channel.id}: ${upserted.length} playlists synced`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[scheduler] Playlist sync error for channel ${channel.id}:`,
|
||||||
|
err instanceof Error ? err.message : err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ──
|
// ── Internal ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,8 @@ export interface PlatformSettings {
|
||||||
scanLimit: number;
|
scanLimit: number;
|
||||||
rateLimitDelay: number;
|
rateLimitDelay: number;
|
||||||
defaultMonitoringMode: MonitoringMode;
|
defaultMonitoringMode: MonitoringMode;
|
||||||
|
nfoEnabled: boolean;
|
||||||
|
defaultView: 'list' | 'poster' | 'table';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue