Compare commits
31 commits
9e5033026e
...
f37cfde0a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f37cfde0a0 | ||
|
|
54e9041058 | ||
|
|
c012425ceb | ||
|
|
87cbfe87ee | ||
|
|
98c3d73c69 | ||
|
|
daf892edad | ||
|
|
bd9e07f878 | ||
|
|
69ec5841e7 | ||
|
|
e6711e91a5 | ||
|
|
a11c4c56c5 | ||
|
|
61da729fa4 | ||
|
|
c0ac8cadd5 | ||
|
|
b4d730d42f | ||
|
|
7f6f3dcccf | ||
|
|
e0b6424932 | ||
|
|
01f4a2d38a | ||
|
|
9ef0323480 | ||
|
|
73c232a845 | ||
|
|
6aa7e21b90 | ||
|
|
9e7d98c7c7 | ||
|
|
05045828d8 | ||
|
|
cc031a78a9 | ||
|
|
8d133024a5 | ||
|
|
3bfdb7b634 | ||
|
|
fb731377bd | ||
|
|
71175198bd | ||
|
|
e6371ba196 | ||
|
|
61105a74b0 | ||
|
|
22077e0eb1 | ||
|
|
373a2ee649 | ||
|
|
8150b1f6cf |
89 changed files with 14083 additions and 164 deletions
18
.mcp.json
Normal file
18
.mcp.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"forgejo": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run", "--rm", "-i",
|
||||||
|
"-e", "FORGEJOMCP_TOKEN",
|
||||||
|
"-e", "FORGEJOMCP_SERVER",
|
||||||
|
"ronmi/forgejo-mcp",
|
||||||
|
"stdio"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"FORGEJOMCP_SERVER": "https://git.xpltd.co",
|
||||||
|
"FORGEJOMCP_TOKEN": "d1c855d501446f8b0a97fc7e8c283cad8c94b76c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
drizzle/0012_adhoc_nullable_channel.sql
Normal file
43
drizzle/0012_adhoc_nullable_channel.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Make content_items.channel_id nullable to support ad-hoc URL downloads without a channel.
|
||||||
|
-- SQLite cannot ALTER COLUMN to remove NOT NULL, so we recreate the table.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE TABLE `content_items_new` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`channel_id` integer,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`platform_content_id` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`content_type` text NOT NULL,
|
||||||
|
`duration` integer,
|
||||||
|
`file_path` text,
|
||||||
|
`file_size` integer,
|
||||||
|
`format` text,
|
||||||
|
`quality_metadata` text,
|
||||||
|
`status` text DEFAULT 'monitored' NOT NULL,
|
||||||
|
`thumbnail_url` text,
|
||||||
|
`published_at` text,
|
||||||
|
`downloaded_at` text,
|
||||||
|
`monitored` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);--> statement-breakpoint
|
||||||
|
|
||||||
|
INSERT INTO `content_items_new`
|
||||||
|
SELECT `id`, `channel_id`, `title`, `platform_content_id`, `url`, `content_type`,
|
||||||
|
`duration`, `file_path`, `file_size`, `format`, `quality_metadata`, `status`,
|
||||||
|
`thumbnail_url`, `published_at`, `downloaded_at`, `monitored`,
|
||||||
|
`created_at`, `updated_at`
|
||||||
|
FROM `content_items`;--> statement-breakpoint
|
||||||
|
|
||||||
|
DROP TABLE `content_items`;--> statement-breakpoint
|
||||||
|
|
||||||
|
ALTER TABLE `content_items_new` RENAME TO `content_items`;--> statement-breakpoint
|
||||||
|
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Seed ad-hoc download setting (enabled by default)
|
||||||
|
INSERT OR IGNORE INTO `system_config` (`key`, `value`, `created_at`, `updated_at`)
|
||||||
|
VALUES ('adhoc.enabled', 'true', datetime('now'), datetime('now'));
|
||||||
29
drizzle/0013_flat_lady_deathstrike.sql
Normal file
29
drizzle/0013_flat_lady_deathstrike.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_content_items` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`channel_id` integer,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`platform_content_id` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`content_type` text NOT NULL,
|
||||||
|
`duration` integer,
|
||||||
|
`file_path` text,
|
||||||
|
`file_size` integer,
|
||||||
|
`format` text,
|
||||||
|
`quality_metadata` text,
|
||||||
|
`status` text DEFAULT 'monitored' NOT NULL,
|
||||||
|
`thumbnail_url` text,
|
||||||
|
`published_at` text,
|
||||||
|
`downloaded_at` text,
|
||||||
|
`monitored` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_content_items`("id", "channel_id", "title", "platform_content_id", "url", "content_type", "duration", "file_path", "file_size", "format", "quality_metadata", "status", "thumbnail_url", "published_at", "downloaded_at", "monitored", "created_at", "updated_at") SELECT "id", "channel_id", "title", "platform_content_id", "url", "content_type", "duration", "file_path", "file_size", "format", "quality_metadata", "status", "thumbnail_url", "published_at", "downloaded_at", "monitored", "created_at", "updated_at" FROM `content_items`;--> statement-breakpoint
|
||||||
|
DROP TABLE `content_items`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_content_items` RENAME TO `content_items`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
ALTER TABLE `format_profiles` ADD `embed_thumbnail` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `format_profiles` ADD `sponsor_block_remove` text;
|
||||||
1
drizzle/0014_adorable_miek.sql
Normal file
1
drizzle/0014_adorable_miek.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `format_profiles` ADD `output_template` text;
|
||||||
2
drizzle/0015_perfect_lethal_legion.sql
Normal file
2
drizzle/0015_perfect_lethal_legion.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `channels` ADD `include_keywords` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `channels` ADD `exclude_keywords` text;
|
||||||
11
drizzle/0016_right_galactus.sql
Normal file
11
drizzle/0016_right_galactus.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
CREATE TABLE `media_servers` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`library_section` text,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||||
|
);
|
||||||
2
drizzle/0017_wild_havok.sql
Normal file
2
drizzle/0017_wild_havok.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `channels` ADD `content_rating` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `content_items` ADD `content_rating` text;
|
||||||
1020
drizzle/meta/0013_snapshot.json
Normal file
1020
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1027
drizzle/meta/0014_snapshot.json
Normal file
1027
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1041
drizzle/meta/0015_snapshot.json
Normal file
1041
drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1117
drizzle/meta/0016_snapshot.json
Normal file
1117
drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1131
drizzle/meta/0017_snapshot.json
Normal file
1131
drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,48 @@
|
||||||
"when": 1775253600000,
|
"when": 1775253600000,
|
||||||
"tag": "0011_add_youtube_enhancements",
|
"tag": "0011_add_youtube_enhancements",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775520000000,
|
||||||
|
"tag": "0012_adhoc_nullable_channel",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775279021003,
|
||||||
|
"tag": "0013_flat_lady_deathstrike",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775279888856,
|
||||||
|
"tag": "0014_adorable_miek",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775280800944,
|
||||||
|
"tag": "0015_perfect_lethal_legion",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775281783887,
|
||||||
|
"tag": "0016_right_galactus",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775282773898,
|
||||||
|
"tag": "0017_wild_havok",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
497
src/__tests__/adhoc-download-api.test.ts
Normal file
497
src/__tests__/adhoc-download-api.test.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||||
|
import { runMigrations } from '../db/migrate';
|
||||||
|
import { buildServer } from '../server/index';
|
||||||
|
import { systemConfig } from '../db/schema/index';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
|
import type * as schema from '../db/schema/index';
|
||||||
|
|
||||||
|
// Mock yt-dlp module before imports
|
||||||
|
vi.mock('../sources/yt-dlp', () => {
|
||||||
|
const YtDlpError = class YtDlpError extends Error {
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
isRateLimit: boolean;
|
||||||
|
category: string;
|
||||||
|
constructor(message: string, stderr: string, exitCode: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'YtDlpError';
|
||||||
|
this.stderr = stderr;
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
this.isRateLimit = stderr.toLowerCase().includes('429') || stderr.toLowerCase().includes('too many requests');
|
||||||
|
// Minimal classify
|
||||||
|
const lower = stderr.toLowerCase();
|
||||||
|
if (this.isRateLimit) this.category = 'rate_limit';
|
||||||
|
else if (lower.includes('private video') || lower.includes('video unavailable')) this.category = 'private';
|
||||||
|
else if (lower.includes('not available in your country')) this.category = 'geo_blocked';
|
||||||
|
else if (lower.includes('connection') || lower.includes('timed out')) this.category = 'network';
|
||||||
|
else this.category = 'unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
execYtDlp: vi.fn(),
|
||||||
|
parseSingleJson: vi.fn((stdout: string) => JSON.parse(stdout.trim())),
|
||||||
|
YtDlpError,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { execYtDlp, YtDlpError } from '../sources/yt-dlp';
|
||||||
|
|
||||||
|
const mockExecYtDlp = vi.mocked(execYtDlp);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for the ad-hoc URL preview endpoint.
|
||||||
|
*/
|
||||||
|
describe('Adhoc Download API - URL Preview', () => {
|
||||||
|
let server: FastifyInstance;
|
||||||
|
let db: LibSQLDatabase<typeof schema>;
|
||||||
|
let apiKey: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-adhoc-'));
|
||||||
|
const dbPath = join(tmpDir, 'test.db');
|
||||||
|
db = await initDatabaseAsync(dbPath);
|
||||||
|
await runMigrations(dbPath);
|
||||||
|
server = await buildServer({ db });
|
||||||
|
|
||||||
|
// Fetch API key
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(systemConfig)
|
||||||
|
.where(eq(systemConfig.key, 'api_key'));
|
||||||
|
apiKey = rows[0]?.value ?? 'test-key';
|
||||||
|
|
||||||
|
await server.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
closeDatabase();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Happy Path ──
|
||||||
|
|
||||||
|
it('should return metadata for a valid YouTube URL', async () => {
|
||||||
|
const ytMetadata = {
|
||||||
|
id: 'dQw4w9WgXcQ',
|
||||||
|
title: 'Rick Astley - Never Gonna Give You Up',
|
||||||
|
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||||
|
duration: 212,
|
||||||
|
extractor_key: 'Youtube',
|
||||||
|
webpage_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
channel: 'Rick Astley',
|
||||||
|
vcodec: 'avc1.640028',
|
||||||
|
is_live: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecYtDlp.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify(ytMetadata),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.title).toBe('Rick Astley - Never Gonna Give You Up');
|
||||||
|
expect(body.platform).toBe('youtube');
|
||||||
|
expect(body.contentType).toBe('video');
|
||||||
|
expect(body.platformContentId).toBe('dQw4w9WgXcQ');
|
||||||
|
expect(body.duration).toBe(212);
|
||||||
|
expect(body.thumbnail).toBe('https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg');
|
||||||
|
expect(body.channelName).toBe('Rick Astley');
|
||||||
|
expect(body.url).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return metadata for a SoundCloud URL', async () => {
|
||||||
|
const scMetadata = {
|
||||||
|
id: '12345',
|
||||||
|
title: 'Test Track',
|
||||||
|
thumbnail: 'https://i1.sndcdn.com/artworks-test.jpg',
|
||||||
|
duration: 180,
|
||||||
|
extractor_key: 'Soundcloud',
|
||||||
|
webpage_url: 'https://soundcloud.com/artist/test-track',
|
||||||
|
uploader: 'Test Artist',
|
||||||
|
vcodec: 'none',
|
||||||
|
is_live: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecYtDlp.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify(scMetadata),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://soundcloud.com/artist/test-track' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.platform).toBe('soundcloud');
|
||||||
|
expect(body.contentType).toBe('audio');
|
||||||
|
expect(body.channelName).toBe('Test Artist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect livestream content type', async () => {
|
||||||
|
const liveMetadata = {
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Live Stream',
|
||||||
|
thumbnail: null,
|
||||||
|
duration: null,
|
||||||
|
extractor_key: 'Youtube',
|
||||||
|
webpage_url: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
channel: 'Streamer',
|
||||||
|
is_live: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecYtDlp.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify(liveMetadata),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=abc123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().contentType).toBe('livestream');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation Errors ──
|
||||||
|
|
||||||
|
it('should reject missing URL', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-HTTP URL', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'ftp://example.com/file.mp4' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().message).toContain('valid HTTP or HTTPS URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty string URL', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Error Handling ──
|
||||||
|
|
||||||
|
it('should return 429 on rate limit', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'HTTP Error 429: Too Many Requests', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(429);
|
||||||
|
expect(res.json().message).toContain('Rate limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 422 for private/unavailable content', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'ERROR: Private video', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=private123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(422);
|
||||||
|
expect(res.json().message).toContain('not accessible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 502 for network errors', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'ERROR: Unable to download - connection timed out', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(502);
|
||||||
|
expect(res.json().message).toContain('Failed to reach');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 422 for unsupported URLs', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'ERROR: Unsupported URL', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://example.com/not-a-video' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(422);
|
||||||
|
expect(res.json().message).toContain('Could not resolve metadata');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Confirm Endpoint Tests ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for the ad-hoc URL confirm endpoint.
|
||||||
|
*/
|
||||||
|
describe('Adhoc Download API - URL Confirm', () => {
|
||||||
|
let server: FastifyInstance;
|
||||||
|
let db: LibSQLDatabase<typeof schema>;
|
||||||
|
let apiKey: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
let queueService: import('../services/queue').QueueService;
|
||||||
|
let mockDownloadService: { downloadItem: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const validPayload = {
|
||||||
|
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
title: 'Rick Astley - Never Gonna Give You Up',
|
||||||
|
platform: 'youtube',
|
||||||
|
platformContentId: 'dQw4w9WgXcQ',
|
||||||
|
contentType: 'video',
|
||||||
|
channelName: 'Rick Astley',
|
||||||
|
duration: 212,
|
||||||
|
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-adhoc-confirm-'));
|
||||||
|
const dbPath = join(tmpDir, 'test.db');
|
||||||
|
db = await initDatabaseAsync(dbPath);
|
||||||
|
await runMigrations(dbPath);
|
||||||
|
server = await buildServer({ db });
|
||||||
|
|
||||||
|
// Create mock download service and queue service
|
||||||
|
mockDownloadService = {
|
||||||
|
downloadItem: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const { QueueService } = await import('../services/queue');
|
||||||
|
queueService = new QueueService(
|
||||||
|
db,
|
||||||
|
mockDownloadService as any,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
// Stop auto-processing so tests stay deterministic
|
||||||
|
queueService.stop();
|
||||||
|
|
||||||
|
(server as any).queueService = queueService;
|
||||||
|
|
||||||
|
// Fetch API key
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(systemConfig)
|
||||||
|
.where(eq(systemConfig.key, 'api_key'));
|
||||||
|
apiKey = rows[0]?.value ?? 'test-key';
|
||||||
|
|
||||||
|
await server.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
queueService?.stop();
|
||||||
|
await server.close();
|
||||||
|
closeDatabase();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Happy Path ──
|
||||||
|
|
||||||
|
it('should create content item and enqueue download', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: validPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.contentItemId).toBeTypeOf('number');
|
||||||
|
expect(body.queueItemId).toBeTypeOf('number');
|
||||||
|
expect(body.status).toBe('queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 409 when same content is already queued', async () => {
|
||||||
|
// Use a unique platformContentId
|
||||||
|
const payload = { ...validPayload, platformContentId: 'unique-dedup-test' };
|
||||||
|
|
||||||
|
// First call succeeds
|
||||||
|
const first = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(first.statusCode).toBe(201);
|
||||||
|
|
||||||
|
// Second call should conflict
|
||||||
|
const second = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(second.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept download without optional fields', async () => {
|
||||||
|
const minimal = {
|
||||||
|
url: 'https://www.youtube.com/watch?v=minimal123',
|
||||||
|
title: 'Minimal Test',
|
||||||
|
platform: 'youtube',
|
||||||
|
platformContentId: 'minimal123',
|
||||||
|
contentType: 'video',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: minimal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().status).toBe('queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept SoundCloud audio download', async () => {
|
||||||
|
const payload = {
|
||||||
|
url: 'https://soundcloud.com/artist/track',
|
||||||
|
title: 'SC Track',
|
||||||
|
platform: 'soundcloud',
|
||||||
|
platformContentId: 'sc-track-123',
|
||||||
|
contentType: 'audio',
|
||||||
|
channelName: 'Artist',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation Errors ──
|
||||||
|
|
||||||
|
it('should reject invalid URL', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { ...validPayload, url: 'not-a-url' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().message).toContain('valid HTTP or HTTPS URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid platform', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { ...validPayload, platform: 'vimeo', platformContentId: 'plat-err-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().message).toContain('Invalid platform');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid contentType', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { ...validPayload, contentType: 'podcast', platformContentId: 'ct-err-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().message).toContain('Invalid contentType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject missing required fields', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://example.com/video' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Service Unavailable ──
|
||||||
|
|
||||||
|
it('should return 503 when queue service is not available', async () => {
|
||||||
|
// Temporarily remove queue service
|
||||||
|
const saved = (server as any).queueService;
|
||||||
|
(server as any).queueService = null;
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/confirm',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { ...validPayload, platformContentId: 'svc-unavail-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
expect(res.json().message).toContain('not available');
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
(server as any).queueService = saved;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -193,7 +193,7 @@ describe('Channel API', () => {
|
||||||
expect(body.name).toBe('Beat Artist');
|
expect(body.name).toBe('Beat Artist');
|
||||||
expect(body.platform).toBe('soundcloud');
|
expect(body.platform).toBe('soundcloud');
|
||||||
expect(body.platformId).toBe('beat-artist');
|
expect(body.platformId).toBe('beat-artist');
|
||||||
expect(body.monitoringEnabled).toBe(true); // default
|
expect(body.monitoringEnabled).toBe(false); // default (monitoringMode defaults to 'none')
|
||||||
expect(body.checkInterval).toBe(360); // default
|
expect(body.checkInterval).toBe(360); // default
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -202,7 +202,7 @@ describe('Channel API', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/v1/channel',
|
url: '/api/v1/channel',
|
||||||
headers: { 'x-api-key': apiKey },
|
headers: { 'x-api-key': apiKey },
|
||||||
payload: { url: 'https://www.example.com/not-a-platform' },
|
payload: { url: 'ftp://files.example.com/not-a-platform' },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(422);
|
expect(res.statusCode).toBe(422);
|
||||||
|
|
@ -319,7 +319,7 @@ describe('Channel API', () => {
|
||||||
expect(body.monitoringEnabled).toBe(false);
|
expect(body.monitoringEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults monitoringMode to all when not specified', async () => {
|
it('defaults monitoringMode to none when not specified', async () => {
|
||||||
execYtDlpMock.mockResolvedValueOnce({
|
execYtDlpMock.mockResolvedValueOnce({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
channel: 'Default Mode Channel',
|
channel: 'Default Mode Channel',
|
||||||
|
|
@ -343,8 +343,8 @@ describe('Channel API', () => {
|
||||||
|
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
const body = res.json();
|
const body = res.json();
|
||||||
expect(body.monitoringMode).toBe('all');
|
expect(body.monitoringMode).toBe('none');
|
||||||
expect(body.monitoringEnabled).toBe(true);
|
expect(body.monitoringEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,20 @@ describe('content-api', () => {
|
||||||
expect(body.data.every((item: { channelId: number }) => item.channelId === channelA.id)).toBe(true);
|
expect(body.data.every((item: { channelId: number }) => item.channelId === channelA.id)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters by contentType when paginated', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/channel/${channelA.id}/content?contentType=video&page=1`,
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(2);
|
||||||
|
expect(body.data.every((item: { contentType: string }) => item.contentType === 'video')).toBe(true);
|
||||||
|
expect(body.pagination.totalItems).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns empty array for channel with no content', async () => {
|
it('returns empty array for channel with no content', async () => {
|
||||||
const noContentChannel = await createChannel(db, {
|
const noContentChannel = await createChannel(db, {
|
||||||
name: 'Empty Channel',
|
name: 'Empty Channel',
|
||||||
|
|
@ -306,4 +320,89 @@ describe('content-api', () => {
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── GET /api/v1/channel/:id/content-counts ──
|
||||||
|
|
||||||
|
describe('GET /api/v1/channel/:id/content-counts', () => {
|
||||||
|
it('returns per-type counts for a channel with mixed content', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/channel/${channelA.id}/content-counts`,
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data).toEqual({
|
||||||
|
video: 2,
|
||||||
|
audio: 0,
|
||||||
|
livestream: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns per-type counts for audio-only channel', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/channel/${channelB.id}/content-counts`,
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toEqual({
|
||||||
|
video: 0,
|
||||||
|
audio: 2,
|
||||||
|
livestream: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all zeros for channel with no content', async () => {
|
||||||
|
// Create a fresh empty channel
|
||||||
|
const emptyChannel = await createChannel(db, {
|
||||||
|
name: 'Counts Empty',
|
||||||
|
platform: 'youtube',
|
||||||
|
platformId: 'UC_counts_empty',
|
||||||
|
url: 'https://www.youtube.com/channel/UC_counts_empty',
|
||||||
|
monitoringEnabled: true,
|
||||||
|
checkInterval: 360,
|
||||||
|
imageUrl: null,
|
||||||
|
metadata: null,
|
||||||
|
formatProfileId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/channel/${emptyChannel.id}/content-counts`,
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toEqual({
|
||||||
|
video: 0,
|
||||||
|
audio: 0,
|
||||||
|
livestream: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid channel ID', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/channel/abc/content-counts',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 without API key', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/channel/${channelA.id}/content-counts`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||||
|
|
@ -39,6 +39,16 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock getAppSetting for NFO feature flag
|
||||||
|
const getAppSettingMock = vi.fn();
|
||||||
|
vi.mock('../db/repositories/system-config-repository', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal() as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getAppSetting: (...args: unknown[]) => getAppSettingMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// ── Test Helpers ──
|
// ── Test Helpers ──
|
||||||
|
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
@ -334,7 +344,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: 'mkv',
|
containerFormat: 'mkv',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -388,7 +398,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -642,7 +652,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -686,7 +696,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: 'mkv',
|
containerFormat: 'mkv',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -738,7 +748,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -761,4 +771,107 @@ describe('DownloadService', () => {
|
||||||
expect(args).not.toContain('--audio-quality');
|
expect(args).not.toContain('--audio-quality');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('downloadItem — NFO sidecar generation', () => {
|
||||||
|
function setupSuccessfulDownload(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, 'fake video data');
|
||||||
|
|
||||||
|
execYtDlpMock.mockResolvedValueOnce({
|
||||||
|
stdout: outputPath,
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
statMock.mockResolvedValueOnce({ size: 10_000_000 });
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('writes .nfo sidecar when app.nfo_enabled is "true"', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputPath = setupSuccessfulDownload(deps);
|
||||||
|
getAppSettingMock.mockResolvedValueOnce('true');
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
||||||
|
// NFO file should exist alongside the media file
|
||||||
|
const nfoPath = outputPath.replace(/\.mp4$/, '.nfo');
|
||||||
|
expect(existsSync(nfoPath)).toBe(true);
|
||||||
|
|
||||||
|
// Validate NFO content
|
||||||
|
const nfoContent = readFileSync(nfoPath, 'utf-8');
|
||||||
|
expect(nfoContent).toContain('<?xml version="1.0"');
|
||||||
|
expect(nfoContent).toContain('<episodedetails>');
|
||||||
|
expect(nfoContent).toContain('<title>Test Video Title</title>');
|
||||||
|
expect(nfoContent).toContain('<studio>Test Channel</studio>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write .nfo when app.nfo_enabled is not "true"', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputPath = setupSuccessfulDownload(deps);
|
||||||
|
getAppSettingMock.mockResolvedValueOnce(null); // Not set
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
||||||
|
const nfoPath = outputPath.replace(/\.mp4$/, '.nfo');
|
||||||
|
expect(existsSync(nfoPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write .nfo when app.nfo_enabled is "false"', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputPath = setupSuccessfulDownload(deps);
|
||||||
|
getAppSettingMock.mockResolvedValueOnce('false');
|
||||||
|
|
||||||
|
await service.downloadItem(testContentItem, testChannel);
|
||||||
|
|
||||||
|
const nfoPath = outputPath.replace(/\.mp4$/, '.nfo');
|
||||||
|
expect(existsSync(nfoPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fail the download when NFO generation throws', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
|
||||||
|
setupSuccessfulDownload(deps);
|
||||||
|
getAppSettingMock.mockRejectedValueOnce(new Error('DB read failed'));
|
||||||
|
|
||||||
|
// Download should still succeed
|
||||||
|
const result = await service.downloadItem(testContentItem, testChannel);
|
||||||
|
expect(result.status).toBe('downloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still completes download successfully even with NFO enabled', async () => {
|
||||||
|
const deps = createMockDeps();
|
||||||
|
const service = new DownloadService(
|
||||||
|
db, deps.rateLimiter, deps.fileOrganizer,
|
||||||
|
deps.qualityAnalyzer, deps.cookieManager
|
||||||
|
);
|
||||||
|
|
||||||
|
setupSuccessfulDownload(deps);
|
||||||
|
getAppSettingMock.mockResolvedValueOnce('true');
|
||||||
|
|
||||||
|
const result = await service.downloadItem(testContentItem, testChannel);
|
||||||
|
expect(result.status).toBe('downloaded');
|
||||||
|
expect(result.filePath).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
368
src/__tests__/feed-api.test.ts
Normal file
368
src/__tests__/feed-api.test.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||||
|
import { runMigrations } from '../db/migrate';
|
||||||
|
import { buildServer } from '../server/index';
|
||||||
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
|
import type * as schema from '../db/schema/index';
|
||||||
|
import { createChannel } from '../db/repositories/channel-repository';
|
||||||
|
import { createContentItem, updateContentItem } from '../db/repositories/content-repository';
|
||||||
|
import type { Channel, ContentItem } from '../types/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for the RSS feed and media serving endpoints.
|
||||||
|
*
|
||||||
|
* GET /api/v1/feed/rss — RSS 2.0 podcast feed of downloaded audio
|
||||||
|
* GET /api/v1/media/:id/:filename — serve downloaded media files
|
||||||
|
*
|
||||||
|
* Both endpoints are public (no API key required).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Feed API', () => {
|
||||||
|
let server: FastifyInstance;
|
||||||
|
let db: LibSQLDatabase<typeof schema>;
|
||||||
|
let tmpDir: string;
|
||||||
|
let mediaDir: string;
|
||||||
|
let testChannel: Channel;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-feed-api-'));
|
||||||
|
mediaDir = join(tmpDir, 'media');
|
||||||
|
mkdirSync(mediaDir, { recursive: true });
|
||||||
|
|
||||||
|
// Set media path before importing config
|
||||||
|
process.env.TUBEARR_MEDIA_PATH = mediaDir;
|
||||||
|
|
||||||
|
const dbPath = join(tmpDir, 'test.db');
|
||||||
|
db = await initDatabaseAsync(dbPath);
|
||||||
|
await runMigrations(dbPath);
|
||||||
|
server = await buildServer({ db });
|
||||||
|
await server.ready();
|
||||||
|
|
||||||
|
// Create a test channel
|
||||||
|
testChannel = await createChannel(db, {
|
||||||
|
name: 'Feed Test Channel',
|
||||||
|
platform: 'youtube',
|
||||||
|
platformId: 'UC_feed_test_1',
|
||||||
|
url: 'https://www.youtube.com/channel/UC_feed_test_1',
|
||||||
|
monitoringEnabled: true,
|
||||||
|
checkInterval: 360,
|
||||||
|
imageUrl: null,
|
||||||
|
metadata: null,
|
||||||
|
formatProfileId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
closeDatabase();
|
||||||
|
try {
|
||||||
|
if (tmpDir && existsSync(tmpDir)) {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
let contentCounter = 0;
|
||||||
|
async function createTestContent(
|
||||||
|
overrides: Partial<{
|
||||||
|
title: string;
|
||||||
|
contentType: string;
|
||||||
|
format: string;
|
||||||
|
status: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize: number;
|
||||||
|
duration: number;
|
||||||
|
publishedAt: string;
|
||||||
|
}> = {}
|
||||||
|
): Promise<ContentItem> {
|
||||||
|
contentCounter++;
|
||||||
|
const item = await createContentItem(db, {
|
||||||
|
channelId: testChannel.id,
|
||||||
|
title: overrides.title ?? `Test Audio ${contentCounter}`,
|
||||||
|
platformContentId: `feed_test_${contentCounter}`,
|
||||||
|
url: `https://youtube.com/watch?v=feed_test_${contentCounter}`,
|
||||||
|
contentType: (overrides.contentType ?? 'audio') as 'audio' | 'video' | 'livestream',
|
||||||
|
duration: overrides.duration ?? 300,
|
||||||
|
publishedAt: overrides.publishedAt ?? '2025-01-15T12:00:00Z',
|
||||||
|
});
|
||||||
|
expect(item).not.toBeNull();
|
||||||
|
|
||||||
|
// Apply post-creation fields
|
||||||
|
if (overrides.status === 'downloaded' || overrides.filePath) {
|
||||||
|
await updateContentItem(db, item!.id, {
|
||||||
|
status: 'downloaded',
|
||||||
|
filePath: overrides.filePath ?? `test-channel/test-audio-${contentCounter}.mp3`,
|
||||||
|
fileSize: overrides.fileSize ?? 5000000,
|
||||||
|
format: overrides.format ?? 'mp3',
|
||||||
|
downloadedAt: '2025-01-16T10:00:00Z',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return item!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RSS Feed Tests ──
|
||||||
|
|
||||||
|
describe('GET /api/v1/feed/rss', () => {
|
||||||
|
it('returns valid RSS XML with correct content type', async () => {
|
||||||
|
// Create a downloaded audio item
|
||||||
|
await createTestContent({ status: 'downloaded', format: 'mp3' });
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/feed/rss',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('application/rss+xml');
|
||||||
|
expect(res.body).toContain('<?xml version="1.0"');
|
||||||
|
expect(res.body).toContain('<rss version="2.0"');
|
||||||
|
expect(res.body).toContain('xmlns:itunes');
|
||||||
|
expect(res.body).toContain('<title>Tubearr Audio Feed</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require authentication', async () => {
|
||||||
|
// No API key, no Origin header — should still work
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/feed/rss',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes downloaded audio items as episodes', async () => {
|
||||||
|
const item = await createTestContent({
|
||||||
|
title: 'My Podcast Episode',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'mp3',
|
||||||
|
duration: 3661, // 1:01:01
|
||||||
|
fileSize: 12345678,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/feed/rss',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toContain('My Podcast Episode');
|
||||||
|
expect(res.body).toContain('<enclosure');
|
||||||
|
expect(res.body).toContain(`/api/v1/media/${item.id}/`);
|
||||||
|
expect(res.body).toContain('type="audio/mpeg"');
|
||||||
|
expect(res.body).toContain('length="12345678"');
|
||||||
|
expect(res.body).toContain('<itunes:duration>1:01:01</itunes:duration>');
|
||||||
|
expect(res.body).toContain(`<guid isPermaLink="false">tubearr-${item.id}-`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes video items with audio formats (e.g. m4a)', async () => {
|
||||||
|
await createTestContent({
|
||||||
|
title: 'Video With M4A Audio',
|
||||||
|
contentType: 'video',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'm4a',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/feed/rss',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toContain('Video With M4A Audio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes non-downloaded items', async () => {
|
||||||
|
await createTestContent({
|
||||||
|
title: 'Still Monitored Audio',
|
||||||
|
contentType: 'audio',
|
||||||
|
// status defaults to 'monitored' — not downloaded
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/feed/rss',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).not.toContain('Still Monitored Audio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes downloaded video items with non-audio formats', async () => {
|
||||||
|
await createTestContent({
|
||||||
|
title: 'Downloaded MP4 Video',
|
||||||
|
contentType: 'video',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'mp4',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/feed/rss',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).not.toContain('Downloaded MP4 Video');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes XML special characters in titles', async () => {
|
||||||
|
await createTestContent({
|
||||||
|
title: 'Episode with <tags> & "quotes"',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'mp3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/feed/rss',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toContain('<tags>');
|
||||||
|
expect(res.body).toContain('&');
|
||||||
|
expect(res.body).toContain('"quotes"');
|
||||||
|
expect(res.body).not.toContain('<tags>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Media Serving Tests ──
|
||||||
|
|
||||||
|
describe('GET /api/v1/media/:id/:filename', () => {
|
||||||
|
it('serves a downloaded media file', async () => {
|
||||||
|
// Create a real file on disk
|
||||||
|
const subDir = join(mediaDir, 'test-channel');
|
||||||
|
mkdirSync(subDir, { recursive: true });
|
||||||
|
const filePath = join(subDir, 'served-file.mp3');
|
||||||
|
const fileContent = Buffer.alloc(1024, 0xff); // 1KB dummy audio
|
||||||
|
writeFileSync(filePath, fileContent);
|
||||||
|
|
||||||
|
const item = await createTestContent({
|
||||||
|
title: 'Served Audio',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'mp3',
|
||||||
|
fileSize: 1024,
|
||||||
|
});
|
||||||
|
// Update filePath to the absolute path
|
||||||
|
await updateContentItem(db, item.id, { filePath });
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/media/${item.id}/served-file.mp3`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toBe('audio/mpeg');
|
||||||
|
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||||
|
expect(res.rawPayload.length).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require authentication', async () => {
|
||||||
|
// Create a file
|
||||||
|
const subDir = join(mediaDir, 'noauth-test');
|
||||||
|
mkdirSync(subDir, { recursive: true });
|
||||||
|
const filePath = join(subDir, 'noauth.mp3');
|
||||||
|
writeFileSync(filePath, Buffer.alloc(512));
|
||||||
|
|
||||||
|
const item = await createTestContent({
|
||||||
|
title: 'No Auth Test',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'mp3',
|
||||||
|
});
|
||||||
|
await updateContentItem(db, item.id, { filePath });
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/media/${item.id}/noauth.mp3`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for non-existent content item', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/media/99999/nothing.mp3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid ID', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/media/abc/nothing.mp3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for content item without downloaded file', async () => {
|
||||||
|
const item = await createTestContent({
|
||||||
|
title: 'Not Downloaded',
|
||||||
|
contentType: 'audio',
|
||||||
|
// no status: 'downloaded' update
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/media/${item.id}/something.mp3`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when file is missing from disk', async () => {
|
||||||
|
const item = await createTestContent({
|
||||||
|
title: 'File Missing From Disk',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'mp3',
|
||||||
|
});
|
||||||
|
// Set filePath to a non-existent file
|
||||||
|
await updateContentItem(db, item.id, {
|
||||||
|
filePath: '/tmp/nonexistent-file-abc123.mp3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/media/${item.id}/missing.mp3`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json().message).toContain('not found on disk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports range requests for seeking', async () => {
|
||||||
|
const subDir = join(mediaDir, 'range-test');
|
||||||
|
mkdirSync(subDir, { recursive: true });
|
||||||
|
const filePath = join(subDir, 'range.mp3');
|
||||||
|
const content = Buffer.alloc(2048, 0xab);
|
||||||
|
writeFileSync(filePath, content);
|
||||||
|
|
||||||
|
const item = await createTestContent({
|
||||||
|
title: 'Range Test Audio',
|
||||||
|
status: 'downloaded',
|
||||||
|
format: 'mp3',
|
||||||
|
fileSize: 2048,
|
||||||
|
});
|
||||||
|
await updateContentItem(db, item.id, { filePath });
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/media/${item.id}/range.mp3`,
|
||||||
|
headers: { range: 'bytes=0-511' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes 0-511/2048');
|
||||||
|
expect(res.rawPayload.length).toBe(512);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { FileOrganizer } from '../services/file-organizer';
|
import { FileOrganizer, DEFAULT_OUTPUT_TEMPLATE, TEMPLATE_VARIABLES } from '../services/file-organizer';
|
||||||
|
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
||||||
|
|
@ -213,4 +213,230 @@ describe('FileOrganizer', () => {
|
||||||
expect(result).not.toMatch(/\\{2,}/);
|
expect(result).not.toMatch(/\\{2,}/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveTemplate', () => {
|
||||||
|
it('replaces all known variables', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{platform}/{channel}/{title}.{ext}', {
|
||||||
|
platform: 'youtube',
|
||||||
|
channel: 'TechChannel',
|
||||||
|
title: 'My Video',
|
||||||
|
ext: 'mp4',
|
||||||
|
});
|
||||||
|
expect(result).toBe('youtube/TechChannel/My Video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles date/year/month variables', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{platform}/{year}/{month}/{title}.{ext}', {
|
||||||
|
platform: 'youtube',
|
||||||
|
year: '2026',
|
||||||
|
month: '04',
|
||||||
|
title: 'April Video',
|
||||||
|
ext: 'mkv',
|
||||||
|
});
|
||||||
|
expect(result).toBe('youtube/2026/04/April Video.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles contentType and id variables', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{contentType}/{id}.{ext}', {
|
||||||
|
contentType: 'video',
|
||||||
|
id: 'abc-123',
|
||||||
|
ext: 'mp4',
|
||||||
|
});
|
||||||
|
expect(result).toBe('video/abc-123.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes variable values (strips forbidden chars)', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{channel}/{title}.{ext}', {
|
||||||
|
channel: 'Bad:Channel*Name',
|
||||||
|
title: 'Title "With" <Special>',
|
||||||
|
ext: 'mp4',
|
||||||
|
});
|
||||||
|
expect(result).toBe('BadChannelName/Title With Special.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves missing known variables to empty string', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{platform}/{channel}/{title}.{ext}', {
|
||||||
|
platform: 'youtube',
|
||||||
|
ext: 'mp4',
|
||||||
|
// channel and title missing
|
||||||
|
});
|
||||||
|
// Missing vars resolve to empty → sanitizeFilename('') → '_unnamed'
|
||||||
|
expect(result).toBe('youtube/_unnamed/_unnamed.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unknown variables untouched', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{platform}/{unknown}/{title}.{ext}', {
|
||||||
|
platform: 'youtube',
|
||||||
|
title: 'Video',
|
||||||
|
ext: 'mp4',
|
||||||
|
});
|
||||||
|
expect(result).toBe('youtube/{unknown}/Video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles template with no variables', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('static/path/file.mp4', {});
|
||||||
|
expect(result).toBe('static/path/file.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters in variable values', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{channel}/{title}.{ext}', {
|
||||||
|
channel: '🎵 Music Channel 🎶',
|
||||||
|
title: 'Ünîcödé Söng',
|
||||||
|
ext: 'flac',
|
||||||
|
});
|
||||||
|
expect(result).toBe('🎵 Music Channel 🎶/Ünîcödé Söng.flac');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not sanitize the ext variable', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.resolveTemplate('{title}.{ext}', {
|
||||||
|
title: 'Video',
|
||||||
|
ext: 'mp4',
|
||||||
|
});
|
||||||
|
// ext should be raw, not run through sanitizeFilename
|
||||||
|
expect(result).toBe('Video.mp4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateTemplate', () => {
|
||||||
|
it('accepts the default template', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate(DEFAULT_OUTPUT_TEMPLATE);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a complex valid template', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate('{platform}/{channel}/{year}/{month}/{title}.{ext}');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects template without {ext}', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate('{platform}/{channel}/{title}');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Template must contain {ext} for the file extension');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty template', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate('');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Template must not be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-only template', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate(' ');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags unknown variable names', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate('{platform}/{bogus}/{title}.{ext}');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Unknown template variable: {bogus}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags illegal filesystem characters', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate('{platform}/<bad>/{title}.{ext}');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Template contains illegal filesystem characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates multiple errors', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
// Missing {ext} AND unknown variable
|
||||||
|
const result = fo.validateTemplate('{platform}/{bogus}/{title}');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts template with only {ext}', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.validateTemplate('{title}.{ext}');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildOutputPath with template', () => {
|
||||||
|
it('default template produces identical paths to legacy behavior', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
// Legacy (no template)
|
||||||
|
const legacy = fo.buildOutputPath('youtube', 'TechChannel', 'My Video', 'mp4');
|
||||||
|
// Template (explicit default)
|
||||||
|
const templated = fo.buildOutputPath('youtube', 'TechChannel', 'My Video', 'mp4', DEFAULT_OUTPUT_TEMPLATE);
|
||||||
|
|
||||||
|
expect(templated).toBe(legacy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custom template changes directory structure', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.buildOutputPath(
|
||||||
|
'youtube',
|
||||||
|
'TechChannel',
|
||||||
|
'My Video',
|
||||||
|
'mp4',
|
||||||
|
'{platform}/{title}.{ext}'
|
||||||
|
);
|
||||||
|
// Should be /media/youtube/My Video.mp4 — no channel directory
|
||||||
|
expect(result).toContain('youtube');
|
||||||
|
expect(result).toContain('My Video.mp4');
|
||||||
|
expect(result).not.toContain('TechChannel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('template with year/month creates date-based directories', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.buildOutputPath(
|
||||||
|
'youtube',
|
||||||
|
'TechChannel',
|
||||||
|
'My Video',
|
||||||
|
'mp4',
|
||||||
|
'{platform}/{channel}/{year}/{title}.{ext}'
|
||||||
|
);
|
||||||
|
const year = String(new Date().getFullYear());
|
||||||
|
expect(result).toContain(year);
|
||||||
|
expect(result).toContain('TechChannel');
|
||||||
|
expect(result).toContain('My Video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still sanitizes values when using custom template', () => {
|
||||||
|
const fo = new FileOrganizer('/media');
|
||||||
|
const result = fo.buildOutputPath(
|
||||||
|
'youtube',
|
||||||
|
'Bad:Channel*Name',
|
||||||
|
'Title "With" <Special>',
|
||||||
|
'mkv',
|
||||||
|
'{platform}/{channel}/{title}.{ext}'
|
||||||
|
);
|
||||||
|
expect(result).not.toContain(':');
|
||||||
|
expect(result).not.toContain('*');
|
||||||
|
expect(result).not.toContain('"');
|
||||||
|
expect(result).toContain('BadChannelName');
|
||||||
|
expect(result).toContain('Title With Special.mkv');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constants and types', () => {
|
||||||
|
it('DEFAULT_OUTPUT_TEMPLATE matches legacy layout', () => {
|
||||||
|
expect(DEFAULT_OUTPUT_TEMPLATE).toBe('{platform}/{channel}/{title}.{ext}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TEMPLATE_VARIABLES includes all expected variables', () => {
|
||||||
|
const expected = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'];
|
||||||
|
for (const v of expected) {
|
||||||
|
expect(TEMPLATE_VARIABLES).toContain(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
252
src/__tests__/keyword-filter.test.ts
Normal file
252
src/__tests__/keyword-filter.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
matchesKeywordFilter,
|
||||||
|
parsePatterns,
|
||||||
|
patternMatches,
|
||||||
|
} from '../services/keyword-filter';
|
||||||
|
|
||||||
|
// ── parsePatterns ──
|
||||||
|
|
||||||
|
describe('parsePatterns', () => {
|
||||||
|
it('returns empty array for null', () => {
|
||||||
|
expect(parsePatterns(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for undefined', () => {
|
||||||
|
expect(parsePatterns(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty string', () => {
|
||||||
|
expect(parsePatterns('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits pipe-separated patterns', () => {
|
||||||
|
expect(parsePatterns('shorts|live')).toEqual(['shorts', 'live']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from patterns', () => {
|
||||||
|
expect(parsePatterns(' shorts | live ')).toEqual(['shorts', 'live']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops empty segments', () => {
|
||||||
|
expect(parsePatterns('shorts||live|')).toEqual(['shorts', 'live']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single pattern', () => {
|
||||||
|
expect(parsePatterns('tutorial')).toEqual(['tutorial']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── patternMatches ──
|
||||||
|
|
||||||
|
describe('patternMatches', () => {
|
||||||
|
describe('plain text (case-insensitive substring)', () => {
|
||||||
|
it('matches substring', () => {
|
||||||
|
expect(patternMatches('shorts', 'My Shorts Video')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches case-insensitively', () => {
|
||||||
|
expect(patternMatches('SHORTS', 'my shorts video')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match when absent', () => {
|
||||||
|
expect(patternMatches('podcast', 'My Shorts Video')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches exact title', () => {
|
||||||
|
expect(patternMatches('hello world', 'Hello World')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('glob patterns (with *)', () => {
|
||||||
|
it('matches * as wildcard at start', () => {
|
||||||
|
expect(patternMatches('*shorts', 'My #shorts')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches * as wildcard at end', () => {
|
||||||
|
expect(patternMatches('Episode*', 'Episode 42: The Return')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches * in the middle', () => {
|
||||||
|
expect(patternMatches('EP*Review', 'EP42 Review')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches double wildcards', () => {
|
||||||
|
expect(patternMatches('*shorts*', 'My #shorts Video')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-matching glob', () => {
|
||||||
|
expect(patternMatches('Episode*', 'My Shorts Video')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('glob is case-insensitive', () => {
|
||||||
|
expect(patternMatches('*SHORTS*', 'my shorts video')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('glob anchors to full title (no partial)', () => {
|
||||||
|
// Without wildcards around it, glob requires full match
|
||||||
|
expect(patternMatches('shorts', 'My Shorts Video')).toBe(true); // plain text, not glob
|
||||||
|
expect(patternMatches('short*', 'My Shorts Video')).toBe(false); // anchored: must start with "short"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('regex patterns (/regex/)', () => {
|
||||||
|
it('matches regex pattern', () => {
|
||||||
|
expect(patternMatches('/^EP\\d+/', 'EP42 Review')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regex is case-insensitive', () => {
|
||||||
|
expect(patternMatches('/episode/', 'Episode 5')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-matching regex', () => {
|
||||||
|
expect(patternMatches('/^EP\\d+/', 'My Shorts Video')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles complex regex', () => {
|
||||||
|
expect(patternMatches('/shorts|#shorts/', 'Watch #shorts now')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text on invalid regex', () => {
|
||||||
|
// Invalid regex with unbalanced bracket — should fall back to substring match
|
||||||
|
// The full pattern "/[invalid/" is matched as plain text (including slashes)
|
||||||
|
expect(patternMatches('/[invalid/', 'contains /[invalid/ text')).toBe(true);
|
||||||
|
expect(patternMatches('/[invalid/', 'no match here')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects single slashes as plain text, not regex', () => {
|
||||||
|
// "//" is length 2, not > 2, so treated as plain text
|
||||||
|
expect(patternMatches('//', '// comment')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── matchesKeywordFilter ──
|
||||||
|
|
||||||
|
describe('keyword filter matching engine', () => {
|
||||||
|
describe('no filters', () => {
|
||||||
|
it('passes when both null', () => {
|
||||||
|
expect(matchesKeywordFilter('Any Title', null, null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when both undefined', () => {
|
||||||
|
expect(matchesKeywordFilter('Any Title', undefined, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when both empty string', () => {
|
||||||
|
expect(matchesKeywordFilter('Any Title', '', '')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exclude only', () => {
|
||||||
|
it('excludes matching title', () => {
|
||||||
|
expect(matchesKeywordFilter('My #shorts Video', null, '#shorts')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes non-matching title', () => {
|
||||||
|
expect(matchesKeywordFilter('Full Episode 1', null, '#shorts')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes on any matching pattern', () => {
|
||||||
|
expect(matchesKeywordFilter('Live Stream Now', null, 'shorts|live')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when no exclude patterns match', () => {
|
||||||
|
expect(matchesKeywordFilter('Full Episode 1', null, 'shorts|live')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('include only', () => {
|
||||||
|
it('passes when title matches include', () => {
|
||||||
|
expect(matchesKeywordFilter('Episode 42', 'episode', null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when title matches none of includes', () => {
|
||||||
|
expect(matchesKeywordFilter('Random Video', 'episode|tutorial', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when title matches at least one include', () => {
|
||||||
|
expect(matchesKeywordFilter('Tutorial: React', 'episode|tutorial', null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('include + exclude combined', () => {
|
||||||
|
it('exclude takes priority over include', () => {
|
||||||
|
// Title matches include "episode" but also matches exclude "shorts"
|
||||||
|
expect(matchesKeywordFilter(
|
||||||
|
'Episode 1 #shorts',
|
||||||
|
'episode',
|
||||||
|
'#shorts',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when matches include and not exclude', () => {
|
||||||
|
expect(matchesKeywordFilter(
|
||||||
|
'Episode 42: Deep Dive',
|
||||||
|
'episode',
|
||||||
|
'#shorts|live',
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when matches neither include nor exclude', () => {
|
||||||
|
expect(matchesKeywordFilter(
|
||||||
|
'Random Video',
|
||||||
|
'episode|tutorial',
|
||||||
|
'#shorts',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mixed pattern types', () => {
|
||||||
|
it('works with regex exclude and plain include', () => {
|
||||||
|
expect(matchesKeywordFilter(
|
||||||
|
'EP42 Shorts Compilation',
|
||||||
|
'EP*',
|
||||||
|
'/shorts/',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with glob include', () => {
|
||||||
|
expect(matchesKeywordFilter(
|
||||||
|
'Episode 42: The Return',
|
||||||
|
'Episode*',
|
||||||
|
null,
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with regex include', () => {
|
||||||
|
expect(matchesKeywordFilter(
|
||||||
|
'EP42 Review',
|
||||||
|
'/^EP\\d+/',
|
||||||
|
null,
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles empty title', () => {
|
||||||
|
expect(matchesKeywordFilter('', 'episode', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty title with no filters', () => {
|
||||||
|
expect(matchesKeywordFilter('', null, null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special regex chars in plain text pattern', () => {
|
||||||
|
// The "." in plain text should match literally as substring
|
||||||
|
expect(matchesKeywordFilter('version 2.0 release', '2.0', null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles pipe char in regex pattern', () => {
|
||||||
|
expect(matchesKeywordFilter(
|
||||||
|
'Watch shorts now',
|
||||||
|
'/shorts|clips/',
|
||||||
|
null,
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitespace-only patterns are dropped', () => {
|
||||||
|
expect(matchesKeywordFilter('Any Title', ' | ', null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
332
src/__tests__/media-server-api.test.ts
Normal file
332
src/__tests__/media-server-api.test.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||||
|
import { runMigrations } from '../db/migrate';
|
||||||
|
import { buildServer } from '../server/index';
|
||||||
|
import { systemConfig } from '../db/schema/index';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
|
import type * as schema from '../db/schema/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for media-server CRUD + action API endpoints.
|
||||||
|
* Uses Fastify inject — no real HTTP ports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Media Server API', () => {
|
||||||
|
let server: FastifyInstance;
|
||||||
|
let db: LibSQLDatabase<typeof schema>;
|
||||||
|
let apiKey: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-media-server-api-'));
|
||||||
|
const dbPath = join(tmpDir, 'test.db');
|
||||||
|
db = await initDatabaseAsync(dbPath);
|
||||||
|
await runMigrations(dbPath);
|
||||||
|
server = await buildServer({ db });
|
||||||
|
await server.ready();
|
||||||
|
|
||||||
|
// Read API key from database (generated by auth plugin)
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(systemConfig)
|
||||||
|
.where(eq(systemConfig.key, 'api_key'))
|
||||||
|
.limit(1);
|
||||||
|
apiKey = rows[0]?.value ?? '';
|
||||||
|
expect(apiKey).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
closeDatabase();
|
||||||
|
try {
|
||||||
|
if (tmpDir && existsSync(tmpDir)) {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function authed(opts: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
...opts,
|
||||||
|
headers: { 'x-api-key': apiKey, ...(opts.headers as Record<string, string> | undefined) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plexBody = {
|
||||||
|
name: 'My Plex',
|
||||||
|
type: 'plex' as const,
|
||||||
|
url: 'http://plex.local:32400',
|
||||||
|
token: 'abc123secret',
|
||||||
|
librarySection: '1',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jellyfinBody = {
|
||||||
|
name: 'My Jellyfin',
|
||||||
|
type: 'jellyfin' as const,
|
||||||
|
url: 'http://jellyfin.local:8096',
|
||||||
|
token: 'jf-token-secret',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── CRUD ──
|
||||||
|
|
||||||
|
describe('CRUD', () => {
|
||||||
|
it('POST /api/v1/media-servers creates a server and redacts token', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: plexBody,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.name).toBe('My Plex');
|
||||||
|
expect(body.type).toBe('plex');
|
||||||
|
expect(body.url).toBe('http://plex.local:32400');
|
||||||
|
// Token should be redacted
|
||||||
|
expect(body.token).not.toBe('abc123secret');
|
||||||
|
expect(body.token).toContain('****');
|
||||||
|
expect(body.id).toBeTypeOf('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/media-servers lists all servers', async () => {
|
||||||
|
// Create a second server
|
||||||
|
await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: jellyfinBody,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'GET', url: '/api/v1/media-servers' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.length).toBeGreaterThanOrEqual(2);
|
||||||
|
// All tokens should be redacted
|
||||||
|
for (const s of body) {
|
||||||
|
expect(s.token).toContain('****');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/media-servers/:id returns a single server', async () => {
|
||||||
|
const createRes = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: { ...plexBody, name: 'Get-By-Id Test' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const created = createRes.json();
|
||||||
|
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'GET', url: `/api/v1/media-servers/${created.id}` })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Get-By-Id Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/media-servers/:id returns 404 for missing', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'GET', url: '/api/v1/media-servers/99999' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/v1/media-servers/:id updates fields', async () => {
|
||||||
|
const createRes = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: plexBody,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const id = createRes.json().id;
|
||||||
|
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/media-servers/${id}`,
|
||||||
|
payload: { name: 'Updated Plex', enabled: false },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.name).toBe('Updated Plex');
|
||||||
|
expect(body.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/v1/media-servers/:id returns 404 for missing', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/media-servers/99999',
|
||||||
|
payload: { name: 'Nope' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/v1/media-servers/:id deletes and returns 204', async () => {
|
||||||
|
const createRes = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: jellyfinBody,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const id = createRes.json().id;
|
||||||
|
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'DELETE', url: `/api/v1/media-servers/${id}` })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
|
||||||
|
// Confirm gone
|
||||||
|
const getRes = await server.inject(
|
||||||
|
authed({ method: 'GET', url: `/api/v1/media-servers/${id}` })
|
||||||
|
);
|
||||||
|
expect(getRes.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/v1/media-servers/:id returns 404 for missing', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'DELETE', url: '/api/v1/media-servers/99999' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation ──
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('rejects POST with missing required fields', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: { name: 'Missing fields' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects POST with invalid type', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: { ...plexBody, type: 'emby' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-numeric ID param', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'GET', url: '/api/v1/media-servers/abc' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Action endpoints ──
|
||||||
|
|
||||||
|
describe('actions', () => {
|
||||||
|
it('POST /api/v1/media-servers/:id/test returns 404 for missing server', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'POST', url: '/api/v1/media-servers/99999/test' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/media-servers/:id/sections returns 404 for missing server', async () => {
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'GET', url: '/api/v1/media-servers/99999/sections' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/v1/media-servers/:id/test calls testConnection on a real server record', async () => {
|
||||||
|
// Create a server (the test will fail to actually connect, but verifies the route works)
|
||||||
|
const createRes = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: { ...plexBody, name: 'Test-Connection Target', url: 'http://127.0.0.1:1' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const id = createRes.json().id;
|
||||||
|
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'POST', url: `/api/v1/media-servers/${id}/test` })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
// Should return a structured result (will fail since no server is running)
|
||||||
|
expect(body).toHaveProperty('success');
|
||||||
|
expect(body).toHaveProperty('message');
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/media-servers/:id/sections returns sections array', async () => {
|
||||||
|
// Create a jellyfin server (listLibrarySections returns [] for jellyfin)
|
||||||
|
const createRes = await server.inject(
|
||||||
|
authed({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
payload: { ...jellyfinBody, name: 'Sections-Test' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const id = createRes.json().id;
|
||||||
|
|
||||||
|
const res = await server.inject(
|
||||||
|
authed({ method: 'GET', url: `/api/v1/media-servers/${id}/sections` })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auth ──
|
||||||
|
|
||||||
|
describe('auth', () => {
|
||||||
|
it('rejects requests without API key', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/media-servers',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be 401 or 403 depending on auth plugin behavior
|
||||||
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
337
src/__tests__/media-server.test.ts
Normal file
337
src/__tests__/media-server.test.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { MediaServerService } from '../services/media-server';
|
||||||
|
import type { MediaServer } from '../types/index';
|
||||||
|
|
||||||
|
// ── Fixtures ──
|
||||||
|
|
||||||
|
function makePlexServer(overrides: Partial<MediaServer> = {}): MediaServer {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
name: 'My Plex',
|
||||||
|
type: 'plex',
|
||||||
|
url: 'http://plex.local:32400',
|
||||||
|
token: 'abc123',
|
||||||
|
librarySection: '1',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeJellyfinServer(overrides: Partial<MediaServer> = {}): MediaServer {
|
||||||
|
return {
|
||||||
|
id: 2,
|
||||||
|
name: 'My Jellyfin',
|
||||||
|
type: 'jellyfin',
|
||||||
|
url: 'http://jellyfin.local:8096',
|
||||||
|
token: 'jf-token-456',
|
||||||
|
librarySection: null,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──
|
||||||
|
|
||||||
|
describe('MediaServerService', () => {
|
||||||
|
let service: MediaServerService;
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new MediaServerService();
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Plex scan ──
|
||||||
|
|
||||||
|
describe('plexScan', () => {
|
||||||
|
it('sends GET to /library/sections/{id}/refresh with token', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
|
||||||
|
const result = await service.triggerScan(makePlexServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('section 1');
|
||||||
|
expect(mockFetch).toHaveBeenCalledOnce();
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe(
|
||||||
|
'http://plex.local:32400/library/sections/1/refresh?X-Plex-Token=abc123'
|
||||||
|
);
|
||||||
|
expect(opts.method).toBe('GET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure when librarySection is missing', async () => {
|
||||||
|
const result = await service.triggerScan(
|
||||||
|
makePlexServer({ librarySection: null })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('requires a library section');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on HTTP error', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
const result = await service.triggerScan(makePlexServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('401');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on network error', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
const result = await service.triggerScan(makePlexServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing slashes from URL', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
|
||||||
|
await service.triggerScan(makePlexServer({ url: 'http://plex.local:32400///' }));
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toMatch(/^http:\/\/plex\.local:32400\/library\/sections\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes special characters in section ID and token', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
|
||||||
|
await service.triggerScan(
|
||||||
|
makePlexServer({ librarySection: 'a/b', token: 'tok&en=1' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toContain('a%2Fb');
|
||||||
|
expect(url).toContain('tok%26en%3D1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Jellyfin scan ──
|
||||||
|
|
||||||
|
describe('jellyfinScan', () => {
|
||||||
|
it('sends POST to /Library/Refresh with X-Emby-Token header', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
||||||
|
|
||||||
|
const result = await service.triggerScan(makeJellyfinServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('Jellyfin');
|
||||||
|
expect(mockFetch).toHaveBeenCalledOnce();
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('http://jellyfin.local:8096/Library/Refresh');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(opts.headers['X-Emby-Token']).toBe('jf-token-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on HTTP error', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden' });
|
||||||
|
|
||||||
|
const result = await service.triggerScan(makeJellyfinServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('403');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on network error', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('timeout'));
|
||||||
|
|
||||||
|
const result = await service.triggerScan(makeJellyfinServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('timeout');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Plex testConnection ──
|
||||||
|
|
||||||
|
describe('testConnection (Plex)', () => {
|
||||||
|
it('validates via /identity and returns server name', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
MediaContainer: { friendlyName: 'Living Room Plex' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testConnection(makePlexServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.serverName).toBe('Living Room Plex');
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toContain('/identity');
|
||||||
|
expect(url).toContain('X-Plex-Token=abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on 401', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
const result = await service.testConnection(makePlexServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('401');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on network error', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
const result = await service.testConnection(makePlexServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('succeeds without friendlyName', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ MediaContainer: {} }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testConnection(makePlexServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.serverName).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Jellyfin testConnection ──
|
||||||
|
|
||||||
|
describe('testConnection (Jellyfin)', () => {
|
||||||
|
it('validates via /System/Info and returns server name', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ ServerName: 'Bedroom Jellyfin' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testConnection(makeJellyfinServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.serverName).toBe('Bedroom Jellyfin');
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toContain('/System/Info');
|
||||||
|
expect(opts.headers['X-Emby-Token']).toBe('jf-token-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on 401', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
const result = await service.testConnection(makeJellyfinServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('401');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns failure on network error', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('ETIMEDOUT'));
|
||||||
|
|
||||||
|
const result = await service.testConnection(makeJellyfinServer());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('ETIMEDOUT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── listLibrarySections ──
|
||||||
|
|
||||||
|
describe('listLibrarySections', () => {
|
||||||
|
it('returns Plex sections from /library/sections', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
MediaContainer: {
|
||||||
|
Directory: [
|
||||||
|
{ key: '1', title: 'Movies', type: 'movie' },
|
||||||
|
{ key: '2', title: 'TV Shows', type: 'show' },
|
||||||
|
{ key: '3', title: 'Music', type: 'artist' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections = await service.listLibrarySections(makePlexServer());
|
||||||
|
|
||||||
|
expect(sections).toHaveLength(3);
|
||||||
|
expect(sections[0]).toEqual({ key: '1', title: 'Movies', type: 'movie' });
|
||||||
|
expect(sections[1]).toEqual({ key: '2', title: 'TV Shows', type: 'show' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for Jellyfin', async () => {
|
||||||
|
const sections = await service.listLibrarySections(makeJellyfinServer());
|
||||||
|
|
||||||
|
expect(sections).toEqual([]);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array on HTTP error', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal' });
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const sections = await service.listLibrarySections(makePlexServer());
|
||||||
|
|
||||||
|
expect(sections).toEqual([]);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[media-server]')
|
||||||
|
);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array on network error', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('DNS lookup failed'));
|
||||||
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const sections = await service.listLibrarySections(makePlexServer());
|
||||||
|
|
||||||
|
expect(sections).toEqual([]);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when Directory is missing', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ MediaContainer: {} }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections = await service.listLibrarySections(makePlexServer());
|
||||||
|
|
||||||
|
expect(sections).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── triggerScan dispatch ──
|
||||||
|
|
||||||
|
describe('triggerScan dispatch', () => {
|
||||||
|
it('routes to plexScan for plex type', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
|
||||||
|
await service.triggerScan(makePlexServer());
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toContain('/library/sections/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to jellyfinScan for jellyfin type', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
||||||
|
|
||||||
|
await service.triggerScan(makeJellyfinServer());
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toContain('/Library/Refresh');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
207
src/__tests__/missing-file-scanner.test.ts
Normal file
207
src/__tests__/missing-file-scanner.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||||
|
import { runMigrations } from '../db/migrate';
|
||||||
|
import { contentItems, systemConfig } from '../db/schema/index';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// ── Mock fs/promises.access to control which files "exist" ──
|
||||||
|
const existingFiles = new Set<string>();
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
access: vi.fn(async (filePath: string) => {
|
||||||
|
if (!existingFiles.has(filePath)) {
|
||||||
|
const err = new Error(`ENOENT: no such file or directory, access '${filePath}'`) as NodeJS.ErrnoException;
|
||||||
|
err.code = 'ENOENT';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { MissingFileScanner } from '../services/missing-file-scanner';
|
||||||
|
|
||||||
|
// ── Test Helpers ──
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let db: Awaited<ReturnType<typeof initDatabaseAsync>>;
|
||||||
|
|
||||||
|
async function setupDb() {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-missing-scan-'));
|
||||||
|
const dbPath = join(tmpDir, 'test.db');
|
||||||
|
db = await initDatabaseAsync(dbPath);
|
||||||
|
await runMigrations(dbPath);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
closeDatabase();
|
||||||
|
existingFiles.clear();
|
||||||
|
try {
|
||||||
|
if (tmpDir && existsSync(tmpDir)) {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a content item with the given status and filePath. */
|
||||||
|
async function insertContent(
|
||||||
|
overrides: { status?: string; filePath?: string | null; title?: string } = {}
|
||||||
|
) {
|
||||||
|
const result = await db
|
||||||
|
.insert(contentItems)
|
||||||
|
.values({
|
||||||
|
title: overrides.title ?? 'Test Video',
|
||||||
|
platformContentId: `plat-${Date.now()}-${Math.random()}`,
|
||||||
|
url: 'https://example.com/video',
|
||||||
|
contentType: 'video',
|
||||||
|
status: overrides.status ?? 'downloaded',
|
||||||
|
filePath: 'filePath' in overrides ? overrides.filePath : '/media/test-video.mp4',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──
|
||||||
|
|
||||||
|
describe('MissingFileScanner', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await setupDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('returns zero counts when no downloaded items exist', async () => {
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const result = await scanner.scanAll();
|
||||||
|
|
||||||
|
expect(result.checked).toBe(0);
|
||||||
|
expect(result.missing).toBe(0);
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag items whose files exist on disk', async () => {
|
||||||
|
const item = await insertContent({ filePath: '/media/exists.mp4' });
|
||||||
|
existingFiles.add('/media/exists.mp4');
|
||||||
|
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const result = await scanner.scanAll();
|
||||||
|
|
||||||
|
expect(result.checked).toBe(1);
|
||||||
|
expect(result.missing).toBe(0);
|
||||||
|
|
||||||
|
// Status should remain 'downloaded'
|
||||||
|
const rows = await db.select().from(contentItems).where(eq(contentItems.id, item.id));
|
||||||
|
expect(rows[0].status).toBe('downloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks items as missing when file does not exist', async () => {
|
||||||
|
const item = await insertContent({ filePath: '/media/gone.mp4' });
|
||||||
|
// Don't add to existingFiles — file is "missing"
|
||||||
|
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const result = await scanner.scanAll();
|
||||||
|
|
||||||
|
expect(result.checked).toBe(1);
|
||||||
|
expect(result.missing).toBe(1);
|
||||||
|
|
||||||
|
const rows = await db.select().from(contentItems).where(eq(contentItems.id, item.id));
|
||||||
|
expect(rows[0].status).toBe('missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips items with non-downloaded status', async () => {
|
||||||
|
await insertContent({ status: 'monitored', filePath: '/media/monitored.mp4' });
|
||||||
|
await insertContent({ status: 'queued', filePath: '/media/queued.mp4' });
|
||||||
|
await insertContent({ status: 'failed', filePath: '/media/failed.mp4' });
|
||||||
|
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const result = await scanner.scanAll();
|
||||||
|
|
||||||
|
expect(result.checked).toBe(0);
|
||||||
|
expect(result.missing).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips downloaded items with null filePath', async () => {
|
||||||
|
await insertContent({ status: 'downloaded', filePath: null });
|
||||||
|
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const result = await scanner.scanAll();
|
||||||
|
|
||||||
|
expect(result.checked).toBe(0);
|
||||||
|
expect(result.missing).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed batch of existing and missing files', async () => {
|
||||||
|
const items = await Promise.all([
|
||||||
|
insertContent({ filePath: '/media/a.mp4', title: 'A' }),
|
||||||
|
insertContent({ filePath: '/media/b.mp4', title: 'B' }),
|
||||||
|
insertContent({ filePath: '/media/c.mp4', title: 'C' }),
|
||||||
|
]);
|
||||||
|
// Only 'a' exists
|
||||||
|
existingFiles.add('/media/a.mp4');
|
||||||
|
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const result = await scanner.scanAll();
|
||||||
|
|
||||||
|
expect(result.checked).toBe(3);
|
||||||
|
expect(result.missing).toBe(2);
|
||||||
|
|
||||||
|
// Verify individual statuses
|
||||||
|
for (const item of items) {
|
||||||
|
const rows = await db.select().from(contentItems).where(eq(contentItems.id, item.id));
|
||||||
|
if (item.filePath === '/media/a.mp4') {
|
||||||
|
expect(rows[0].status).toBe('downloaded');
|
||||||
|
} else {
|
||||||
|
expect(rows[0].status).toBe('missing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists scan results to system_config', async () => {
|
||||||
|
await insertContent({ filePath: '/media/gone.mp4' });
|
||||||
|
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
await scanner.scanAll();
|
||||||
|
|
||||||
|
const lastScan = await scanner.getLastScanResult();
|
||||||
|
expect(lastScan).not.toBeNull();
|
||||||
|
expect(lastScan!.lastRun).toBeTruthy();
|
||||||
|
expect(lastScan!.result.checked).toBe(1);
|
||||||
|
expect(lastScan!.result.missing).toBe(1);
|
||||||
|
expect(lastScan!.result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for getLastScanResult when no scan has run', async () => {
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const lastScan = await scanner.getLastScanResult();
|
||||||
|
expect(lastScan).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles batching correctly with > BATCH_SIZE items', async () => {
|
||||||
|
// Insert 150 downloaded items, all missing from disk
|
||||||
|
const inserts = Array.from({ length: 150 }, (_, i) =>
|
||||||
|
insertContent({ filePath: `/media/file-${i}.mp4`, title: `Video ${i}` })
|
||||||
|
);
|
||||||
|
await Promise.all(inserts);
|
||||||
|
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
const result = await scanner.scanAll();
|
||||||
|
|
||||||
|
expect(result.checked).toBe(150);
|
||||||
|
expect(result.missing).toBe(150);
|
||||||
|
|
||||||
|
// All should be marked missing
|
||||||
|
const rows = await db
|
||||||
|
.select({ status: contentItems.status })
|
||||||
|
.from(contentItems)
|
||||||
|
.where(eq(contentItems.status, 'missing'));
|
||||||
|
expect(rows.length).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
220
src/__tests__/missing-scan-api.test.ts
Normal file
220
src/__tests__/missing-scan-api.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||||
|
import { runMigrations } from '../db/migrate';
|
||||||
|
import { buildServer } from '../server/index';
|
||||||
|
import { systemConfig, contentItems } from '../db/schema/index';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
|
import type * as schema from '../db/schema/index';
|
||||||
|
import { MissingFileScanner } from '../services/missing-file-scanner';
|
||||||
|
|
||||||
|
describe('Missing Scan API', () => {
|
||||||
|
let server: FastifyInstance;
|
||||||
|
let db: LibSQLDatabase<typeof schema>;
|
||||||
|
let apiKey: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-missing-scan-'));
|
||||||
|
const dbPath = join(tmpDir, 'test.db');
|
||||||
|
db = await initDatabaseAsync(dbPath);
|
||||||
|
await runMigrations(dbPath);
|
||||||
|
server = await buildServer({ db });
|
||||||
|
|
||||||
|
// Attach missing file scanner
|
||||||
|
const scanner = new MissingFileScanner(db);
|
||||||
|
(server as { missingFileScanner: MissingFileScanner | null }).missingFileScanner = scanner;
|
||||||
|
|
||||||
|
await server.ready();
|
||||||
|
|
||||||
|
// Read API key from database
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(systemConfig)
|
||||||
|
.where(eq(systemConfig.key, 'api_key'))
|
||||||
|
.limit(1);
|
||||||
|
apiKey = rows[0]?.value ?? '';
|
||||||
|
expect(apiKey).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
closeDatabase();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helper to insert a content item ──
|
||||||
|
|
||||||
|
async function insertContentItem(overrides: {
|
||||||
|
status?: string;
|
||||||
|
filePath?: string | null;
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
} = {}) {
|
||||||
|
const uid = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const result = await db
|
||||||
|
.insert(contentItems)
|
||||||
|
.values({
|
||||||
|
title: overrides.title ?? 'Test Video',
|
||||||
|
url: overrides.url ?? `https://youtube.com/watch?v=${uid}`,
|
||||||
|
platformContentId: uid,
|
||||||
|
contentType: 'video',
|
||||||
|
status: overrides.status ?? 'downloaded',
|
||||||
|
monitored: true,
|
||||||
|
filePath: overrides.filePath ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/system/missing-scan ──
|
||||||
|
|
||||||
|
describe('POST /api/v1/system/missing-scan', () => {
|
||||||
|
it('should trigger a scan and return results', async () => {
|
||||||
|
const response = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/system/missing-scan',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data).toHaveProperty('checked');
|
||||||
|
expect(body.data).toHaveProperty('missing');
|
||||||
|
expect(body.data).toHaveProperty('duration');
|
||||||
|
expect(typeof body.data.checked).toBe('number');
|
||||||
|
expect(typeof body.data.missing).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect a missing file', async () => {
|
||||||
|
// Insert a content item with a filePath that does not exist on disk
|
||||||
|
const fakePath = join(tmpDir, 'nonexistent-file.mp4');
|
||||||
|
await insertContentItem({
|
||||||
|
status: 'downloaded',
|
||||||
|
filePath: fakePath,
|
||||||
|
url: `https://youtube.com/watch?v=missing-${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/system/missing-scan',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.missing).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not flag files that exist on disk', async () => {
|
||||||
|
// Create a real file
|
||||||
|
const realPath = join(tmpDir, 'existing-file.mp4');
|
||||||
|
writeFileSync(realPath, 'fake content');
|
||||||
|
|
||||||
|
await insertContentItem({
|
||||||
|
status: 'downloaded',
|
||||||
|
filePath: realPath,
|
||||||
|
url: `https://youtube.com/watch?v=exists-${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/system/missing-scan',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
// The existing file should not be counted as missing
|
||||||
|
// (but previously inserted missing files may still be counted)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/v1/system/missing-scan/status ──
|
||||||
|
|
||||||
|
describe('GET /api/v1/system/missing-scan/status', () => {
|
||||||
|
it('should return null when no scan has been run', async () => {
|
||||||
|
// Use a fresh scanner with a fresh DB to test no-prior-scan state
|
||||||
|
// Since we already ran scans above, we check that status returns data
|
||||||
|
const response = await server.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/system/missing-scan/status',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
// After previous tests, data should have lastRun and result
|
||||||
|
if (body.data !== null) {
|
||||||
|
expect(body.data).toHaveProperty('lastRun');
|
||||||
|
expect(body.data).toHaveProperty('result');
|
||||||
|
expect(body.data.result).toHaveProperty('checked');
|
||||||
|
expect(body.data.result).toHaveProperty('missing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/v1/content/:id/requeue ──
|
||||||
|
|
||||||
|
describe('POST /api/v1/content/:id/requeue', () => {
|
||||||
|
it('should return 404 for a non-existent content item', async () => {
|
||||||
|
const response = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/content/99999/requeue',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if content item is not in missing status', async () => {
|
||||||
|
const item = await insertContentItem({
|
||||||
|
status: 'monitored',
|
||||||
|
url: `https://youtube.com/watch?v=monitored-${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/content/${item.id}/requeue`,
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.message).toContain('monitored');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should requeue a missing content item', async () => {
|
||||||
|
const item = await insertContentItem({
|
||||||
|
status: 'missing',
|
||||||
|
filePath: join(tmpDir, 'deleted.mp4'),
|
||||||
|
url: `https://youtube.com/watch?v=requeue-${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Need queueService for this to work — check if it returns 503
|
||||||
|
const response = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/content/${item.id}/requeue`,
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without a queue service attached, we get 503
|
||||||
|
// With one, we'd get 201
|
||||||
|
if (response.statusCode === 503) {
|
||||||
|
expect(response.json().message).toContain('Queue service');
|
||||||
|
} else {
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
267
src/__tests__/nfo-generator.test.ts
Normal file
267
src/__tests__/nfo-generator.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
generateNfo,
|
||||||
|
writeNfoFile,
|
||||||
|
nfoPathForMedia,
|
||||||
|
resolveContentRating,
|
||||||
|
} from '../services/nfo-generator';
|
||||||
|
import type { ContentItem, Channel } from '../types/index';
|
||||||
|
|
||||||
|
// ── Test Fixtures ──
|
||||||
|
|
||||||
|
function makeContentItem(overrides: Partial<ContentItem> = {}): ContentItem {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
channelId: 1,
|
||||||
|
title: 'Test Video Title',
|
||||||
|
platformContentId: 'abc123',
|
||||||
|
url: 'https://youtube.com/watch?v=abc123',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 600,
|
||||||
|
filePath: '/media/youtube/TestChannel/Test Video Title.mp4',
|
||||||
|
fileSize: 50_000_000,
|
||||||
|
format: 'mp4',
|
||||||
|
qualityMetadata: null,
|
||||||
|
status: 'downloaded',
|
||||||
|
thumbnailUrl: 'https://i.ytimg.com/vi/abc123/maxresdefault.jpg',
|
||||||
|
publishedAt: '2025-06-15T12:00:00Z',
|
||||||
|
downloadedAt: '2025-06-16T08:30:00Z',
|
||||||
|
monitored: true,
|
||||||
|
contentRating: null,
|
||||||
|
createdAt: '2025-06-15T12:00:00Z',
|
||||||
|
updatedAt: '2025-06-16T08:30:00Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChannel(overrides: Partial<Channel> = {}): Channel {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
name: 'TestChannel',
|
||||||
|
platform: 'youtube',
|
||||||
|
platformId: 'UC1234',
|
||||||
|
url: 'https://youtube.com/@TestChannel',
|
||||||
|
monitoringEnabled: true,
|
||||||
|
checkInterval: 60,
|
||||||
|
imageUrl: 'https://example.com/avatar.jpg',
|
||||||
|
metadata: null,
|
||||||
|
formatProfileId: null,
|
||||||
|
monitoringMode: 'all',
|
||||||
|
bannerUrl: null,
|
||||||
|
description: null,
|
||||||
|
subscriberCount: null,
|
||||||
|
contentRating: null,
|
||||||
|
includeKeywords: null,
|
||||||
|
excludeKeywords: null,
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2025-01-01T00:00:00Z',
|
||||||
|
lastCheckedAt: null,
|
||||||
|
lastCheckStatus: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File writing cleanup ──
|
||||||
|
|
||||||
|
let tmpDir: string | undefined;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (tmpDir && existsSync(tmpDir)) {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tests ──
|
||||||
|
|
||||||
|
describe('NfoGenerator', () => {
|
||||||
|
describe('resolveContentRating', () => {
|
||||||
|
it('uses item-level rating when present', () => {
|
||||||
|
const result = resolveContentRating(
|
||||||
|
{ contentRating: 'TV-MA' },
|
||||||
|
{ contentRating: 'TV-PG' }
|
||||||
|
);
|
||||||
|
expect(result).toBe('TV-MA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to channel rating when item rating is null', () => {
|
||||||
|
const result = resolveContentRating(
|
||||||
|
{ contentRating: null },
|
||||||
|
{ contentRating: 'TV-PG' }
|
||||||
|
);
|
||||||
|
expect(result).toBe('TV-PG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to NR when both are null', () => {
|
||||||
|
const result = resolveContentRating(
|
||||||
|
{ contentRating: null },
|
||||||
|
{ contentRating: null }
|
||||||
|
);
|
||||||
|
expect(result).toBe('NR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to NR when channel is null', () => {
|
||||||
|
const result = resolveContentRating({ contentRating: null }, null);
|
||||||
|
expect(result).toBe('NR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateNfo', () => {
|
||||||
|
it('produces valid Kodi XML with all fields populated', () => {
|
||||||
|
const item = makeContentItem();
|
||||||
|
const channel = makeChannel();
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>');
|
||||||
|
expect(nfo).toContain('<episodedetails>');
|
||||||
|
expect(nfo).toContain('</episodedetails>');
|
||||||
|
expect(nfo).toContain('<title>Test Video Title</title>');
|
||||||
|
expect(nfo).toContain('<aired>2025-06-15</aired>');
|
||||||
|
expect(nfo).toContain('<studio>TestChannel</studio>');
|
||||||
|
expect(nfo).toContain('<genre>YouTube</genre>');
|
||||||
|
expect(nfo).toContain('<mpaa>NR</mpaa>');
|
||||||
|
expect(nfo).toContain('<thumb>https://i.ytimg.com/vi/abc123/maxresdefault.jpg</thumb>');
|
||||||
|
expect(nfo).toContain('<uniqueid type="youtube" default="true">abc123</uniqueid>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses item contentRating over channel rating', () => {
|
||||||
|
const item = makeContentItem({ contentRating: 'TV-MA' });
|
||||||
|
const channel = makeChannel({ contentRating: 'TV-PG' });
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<mpaa>TV-MA</mpaa>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses channel contentRating when item has none', () => {
|
||||||
|
const item = makeContentItem({ contentRating: null });
|
||||||
|
const channel = makeChannel({ contentRating: 'TV-14' });
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<mpaa>TV-14</mpaa>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to NR when neither has rating', () => {
|
||||||
|
const item = makeContentItem({ contentRating: null });
|
||||||
|
const channel = makeChannel({ contentRating: null });
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<mpaa>NR</mpaa>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null channel gracefully', () => {
|
||||||
|
const item = makeContentItem();
|
||||||
|
const nfo = generateNfo(item, null);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<episodedetails>');
|
||||||
|
expect(nfo).toContain('<title>Test Video Title</title>');
|
||||||
|
expect(nfo).not.toContain('<studio>');
|
||||||
|
expect(nfo).toContain('<genre>Online Media</genre>');
|
||||||
|
expect(nfo).toContain('<mpaa>NR</mpaa>');
|
||||||
|
expect(nfo).toContain('<uniqueid type="generic"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits aired when publishedAt is null', () => {
|
||||||
|
const item = makeContentItem({ publishedAt: null });
|
||||||
|
const channel = makeChannel();
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).not.toContain('<aired>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits thumb when thumbnailUrl is null', () => {
|
||||||
|
const item = makeContentItem({ thumbnailUrl: null });
|
||||||
|
const channel = makeChannel();
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).not.toContain('<thumb>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes XML special characters in title', () => {
|
||||||
|
const item = makeContentItem({ title: 'Tom & Jerry <"Special">' });
|
||||||
|
const channel = makeChannel();
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<title>Tom & Jerry <"Special"></title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Music genre for SoundCloud', () => {
|
||||||
|
const item = makeContentItem();
|
||||||
|
const channel = makeChannel({ platform: 'soundcloud' });
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<genre>Music</genre>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Online Media genre for generic platform', () => {
|
||||||
|
const item = makeContentItem();
|
||||||
|
const channel = makeChannel({ platform: 'generic' });
|
||||||
|
const nfo = generateNfo(item, channel);
|
||||||
|
|
||||||
|
expect(nfo).toContain('<genre>Online Media</genre>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nfoPathForMedia', () => {
|
||||||
|
it('replaces .mp4 with .nfo', () => {
|
||||||
|
expect(nfoPathForMedia('/media/youtube/chan/video.mp4')).toBe(
|
||||||
|
'/media/youtube/chan/video.nfo'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces .webm with .nfo', () => {
|
||||||
|
expect(nfoPathForMedia('/media/youtube/chan/video.webm')).toBe(
|
||||||
|
'/media/youtube/chan/video.nfo'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces .opus with .nfo', () => {
|
||||||
|
expect(nfoPathForMedia('/media/soundcloud/artist/track.opus')).toBe(
|
||||||
|
'/media/soundcloud/artist/track.nfo'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles files with dots in the name', () => {
|
||||||
|
expect(nfoPathForMedia('/media/youtube/chan/video.v2.final.mp4')).toBe(
|
||||||
|
'/media/youtube/chan/video.v2.final.nfo'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeNfoFile', () => {
|
||||||
|
it('writes .nfo file alongside media file', async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-'));
|
||||||
|
const mediaPath = join(tmpDir, 'youtube', 'TestChannel', 'video.mp4');
|
||||||
|
const nfoContent = '<episodedetails><title>Test</title></episodedetails>';
|
||||||
|
|
||||||
|
const writtenPath = await writeNfoFile(nfoContent, mediaPath);
|
||||||
|
|
||||||
|
expect(writtenPath).toBe(join(tmpDir, 'youtube', 'TestChannel', 'video.nfo'));
|
||||||
|
expect(existsSync(writtenPath)).toBe(true);
|
||||||
|
expect(readFileSync(writtenPath, 'utf-8')).toBe(nfoContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parent directories if needed', async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-'));
|
||||||
|
const mediaPath = join(tmpDir, 'deep', 'nested', 'dir', 'video.mp4');
|
||||||
|
const nfoContent = '<episodedetails><title>Test</title></episodedetails>';
|
||||||
|
|
||||||
|
const writtenPath = await writeNfoFile(nfoContent, mediaPath);
|
||||||
|
|
||||||
|
expect(existsSync(writtenPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites existing .nfo file', async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-'));
|
||||||
|
const mediaPath = join(tmpDir, 'video.mp4');
|
||||||
|
|
||||||
|
await writeNfoFile('old content', mediaPath);
|
||||||
|
await writeNfoFile('new content', mediaPath);
|
||||||
|
|
||||||
|
const nfoPath = join(tmpDir, 'video.nfo');
|
||||||
|
expect(readFileSync(nfoPath, 'utf-8')).toBe('new content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -145,7 +145,7 @@ describe('Platform Settings API', () => {
|
||||||
// Defaults
|
// Defaults
|
||||||
expect(body.grabAllEnabled).toBe(false);
|
expect(body.grabAllEnabled).toBe(false);
|
||||||
expect(body.grabAllOrder).toBe('newest');
|
expect(body.grabAllOrder).toBe('newest');
|
||||||
expect(body.scanLimit).toBe(100);
|
expect(body.scanLimit).toBe(500);
|
||||||
expect(body.rateLimitDelay).toBe(1000);
|
expect(body.rateLimitDelay).toBe(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,129 @@ describe('QueueService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Pause ──
|
||||||
|
|
||||||
|
describe('pauseItem', () => {
|
||||||
|
it('pauses a pending item', async () => {
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 0);
|
||||||
|
qs.stop();
|
||||||
|
|
||||||
|
await qs.enqueue(contentItems[0].id);
|
||||||
|
|
||||||
|
const paused = await qs.pauseItem(1);
|
||||||
|
expect(paused.status).toBe('paused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses a downloading item by aborting the active download', async () => {
|
||||||
|
// Use a deferred so we can control when the download completes
|
||||||
|
let rejectFn: (err: Error) => void;
|
||||||
|
mockDownloadService.downloadItem.mockImplementationOnce(() => {
|
||||||
|
return new Promise<void>((_resolve, reject) => {
|
||||||
|
rejectFn = reject;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 1);
|
||||||
|
|
||||||
|
await qs.enqueue(contentItems[0].id);
|
||||||
|
await tick(50); // Let it transition to downloading
|
||||||
|
|
||||||
|
// Item should be downloading
|
||||||
|
let item = await getQueueItemById(db, 1);
|
||||||
|
expect(item!.status).toBe('downloading');
|
||||||
|
|
||||||
|
// Pause it — this should abort the download
|
||||||
|
const paused = await qs.pauseItem(1);
|
||||||
|
expect(paused.status).toBe('paused');
|
||||||
|
|
||||||
|
// Simulate the abort rejection (in real code, the AbortController signal kills yt-dlp)
|
||||||
|
rejectFn!(new Error('aborted'));
|
||||||
|
await tick(50);
|
||||||
|
|
||||||
|
// Item should remain paused (not retried as failed)
|
||||||
|
item = await getQueueItemById(db, 1);
|
||||||
|
expect(item!.status).toBe('paused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for completed item', async () => {
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 0);
|
||||||
|
qs.stop();
|
||||||
|
|
||||||
|
await qs.enqueue(contentItems[0].id);
|
||||||
|
await updateQueueItemStatus(db, 1, 'completed');
|
||||||
|
|
||||||
|
await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for cancelled item', async () => {
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 0);
|
||||||
|
qs.stop();
|
||||||
|
|
||||||
|
await qs.enqueue(contentItems[0].id);
|
||||||
|
await updateQueueItemStatus(db, 1, 'cancelled');
|
||||||
|
|
||||||
|
await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for non-existent item', async () => {
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 0);
|
||||||
|
await expect(qs.pauseItem(99999)).rejects.toThrow(/not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Resume ──
|
||||||
|
|
||||||
|
describe('resumeItem', () => {
|
||||||
|
it('resumes a paused item back to pending', async () => {
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 0);
|
||||||
|
qs.stop();
|
||||||
|
|
||||||
|
await qs.enqueue(contentItems[0].id);
|
||||||
|
await qs.pauseItem(1);
|
||||||
|
|
||||||
|
const resumed = await qs.resumeItem(1);
|
||||||
|
expect(resumed.status).toBe('pending');
|
||||||
|
expect(resumed.error).toBeNull();
|
||||||
|
|
||||||
|
// Content status should be reset to queued
|
||||||
|
const contentItem = await getContentItemById(db, contentItems[0].id);
|
||||||
|
expect(contentItem!.status).toBe('queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for non-paused item', async () => {
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 0);
|
||||||
|
qs.stop();
|
||||||
|
|
||||||
|
await qs.enqueue(contentItems[0].id);
|
||||||
|
|
||||||
|
await expect(qs.resumeItem(1)).rejects.toThrow(/expected 'paused'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for non-existent item', async () => {
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 0);
|
||||||
|
await expect(qs.resumeItem(99999)).rejects.toThrow(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers processNext after resume', async () => {
|
||||||
|
// After resuming, the item should get picked up and processed
|
||||||
|
const qs = new QueueService(db, mockDownloadService as any, 1);
|
||||||
|
|
||||||
|
// Enqueue and pause
|
||||||
|
qs.stop();
|
||||||
|
await qs.enqueue(contentItems[0].id);
|
||||||
|
await qs.pauseItem(1);
|
||||||
|
|
||||||
|
// Resume — processNext should fire and download
|
||||||
|
qs.start();
|
||||||
|
await qs.resumeItem(1);
|
||||||
|
await tick(100);
|
||||||
|
|
||||||
|
const item = await getQueueItemById(db, 1);
|
||||||
|
expect(item!.status).toBe('completed');
|
||||||
|
expect(mockDownloadService.downloadItem).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── getState ──
|
// ── getState ──
|
||||||
|
|
||||||
describe('getState', () => {
|
describe('getState', () => {
|
||||||
|
|
@ -539,6 +662,7 @@ describe('QueueService', () => {
|
||||||
expect(state.failed).toBe(1);
|
expect(state.failed).toBe(1);
|
||||||
expect(state.downloading).toBe(0);
|
expect(state.downloading).toBe(0);
|
||||||
expect(state.cancelled).toBe(0);
|
expect(state.cancelled).toBe(0);
|
||||||
|
expect(state.paused).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all zeros when queue is empty', async () => {
|
it('returns all zeros when queue is empty', async () => {
|
||||||
|
|
@ -551,6 +675,7 @@ describe('QueueService', () => {
|
||||||
completed: 0,
|
completed: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
cancelled: 0,
|
cancelled: 0,
|
||||||
|
paused: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -179,14 +179,12 @@ describe('Scan API', () => {
|
||||||
headers: { 'x-api-key': apiKey },
|
headers: { 'x-api-key': apiKey },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(202);
|
||||||
const body = res.json();
|
const body = res.json();
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
channelName: channel.name,
|
channelName: channel.name,
|
||||||
status: 'success',
|
status: 'started',
|
||||||
newItems: 3,
|
|
||||||
totalFetched: 3,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -293,7 +291,7 @@ describe('Scan API', () => {
|
||||||
errors: expect.any(Number),
|
errors: expect.any(Number),
|
||||||
});
|
});
|
||||||
// At least our two channels' new items should be counted
|
// At least our two channels' new items should be counted
|
||||||
expect(body.summary.newItems).toBeGreaterThanOrEqual(3);
|
expect(body.summary.newItems).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 503 when scheduler is null', async () => {
|
it('returns 503 when scheduler is null', async () => {
|
||||||
|
|
|
||||||
|
|
@ -610,6 +610,7 @@ describe('SchedulerService', () => {
|
||||||
qualityMetadata: null,
|
qualityMetadata: null,
|
||||||
status: 'monitored',
|
status: 'monitored',
|
||||||
monitored: false,
|
monitored: false,
|
||||||
|
contentRating: null,
|
||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
downloadedAt: null,
|
downloadedAt: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|
@ -659,6 +660,180 @@ describe('SchedulerService', () => {
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Keyword filter tests ──
|
||||||
|
|
||||||
|
it('excludes items matching excludeKeywords pattern', async () => {
|
||||||
|
const channel = await insertTestChannel({ excludeKeywords: 'shorts|#shorts' });
|
||||||
|
const scheduler = new SchedulerService(db, registry, rateLimiter);
|
||||||
|
|
||||||
|
const items: PlatformContentMetadata[] = [
|
||||||
|
{
|
||||||
|
platformContentId: `kf_exc_${channel.id}_1`,
|
||||||
|
title: 'Great Video About Coding',
|
||||||
|
url: 'https://www.youtube.com/watch?v=1',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 600,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformContentId: `kf_exc_${channel.id}_2`,
|
||||||
|
title: 'Quick shorts compilation',
|
||||||
|
url: 'https://www.youtube.com/watch?v=2',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 30,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformContentId: `kf_exc_${channel.id}_3`,
|
||||||
|
title: 'My Day #shorts vlog',
|
||||||
|
url: 'https://www.youtube.com/watch?v=3',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 15,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(items);
|
||||||
|
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
// Only the first item should pass the filter
|
||||||
|
expect(result.newItems).toBe(1);
|
||||||
|
expect(result.totalFetched).toBe(3);
|
||||||
|
|
||||||
|
const content = await getContentByChannelId(db, channel.id);
|
||||||
|
const inserted = content.filter(c =>
|
||||||
|
c.platformContentId.startsWith(`kf_exc_${channel.id}`)
|
||||||
|
);
|
||||||
|
expect(inserted.length).toBe(1);
|
||||||
|
expect(inserted[0].title).toBe('Great Video About Coding');
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes only items matching includeKeywords pattern', async () => {
|
||||||
|
const channel = await insertTestChannel({ includeKeywords: 'tutorial|guide' });
|
||||||
|
const scheduler = new SchedulerService(db, registry, rateLimiter);
|
||||||
|
|
||||||
|
const items: PlatformContentMetadata[] = [
|
||||||
|
{
|
||||||
|
platformContentId: `kf_inc_${channel.id}_1`,
|
||||||
|
title: 'Python Tutorial for Beginners',
|
||||||
|
url: 'https://www.youtube.com/watch?v=1',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 1800,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformContentId: `kf_inc_${channel.id}_2`,
|
||||||
|
title: 'Random Vlog Day 5',
|
||||||
|
url: 'https://www.youtube.com/watch?v=2',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 300,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformContentId: `kf_inc_${channel.id}_3`,
|
||||||
|
title: 'Ultimate Guide to Docker',
|
||||||
|
url: 'https://www.youtube.com/watch?v=3',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 2400,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(items);
|
||||||
|
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
expect(result.newItems).toBe(2);
|
||||||
|
const content = await getContentByChannelId(db, channel.id);
|
||||||
|
const inserted = content.filter(c =>
|
||||||
|
c.platformContentId.startsWith(`kf_inc_${channel.id}`)
|
||||||
|
);
|
||||||
|
expect(inserted.length).toBe(2);
|
||||||
|
const titles = inserted.map(c => c.title);
|
||||||
|
expect(titles).toContain('Python Tutorial for Beginners');
|
||||||
|
expect(titles).toContain('Ultimate Guide to Docker');
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies both include and exclude patterns together', async () => {
|
||||||
|
const channel = await insertTestChannel({
|
||||||
|
includeKeywords: 'tutorial',
|
||||||
|
excludeKeywords: 'shorts',
|
||||||
|
});
|
||||||
|
const scheduler = new SchedulerService(db, registry, rateLimiter);
|
||||||
|
|
||||||
|
const items: PlatformContentMetadata[] = [
|
||||||
|
{
|
||||||
|
platformContentId: `kf_both_${channel.id}_1`,
|
||||||
|
title: 'Tutorial: Getting Started',
|
||||||
|
url: 'https://www.youtube.com/watch?v=1',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 1800,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformContentId: `kf_both_${channel.id}_2`,
|
||||||
|
title: 'Tutorial shorts recap',
|
||||||
|
url: 'https://www.youtube.com/watch?v=2',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 30,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformContentId: `kf_both_${channel.id}_3`,
|
||||||
|
title: 'Random Gaming Stream',
|
||||||
|
url: 'https://www.youtube.com/watch?v=3',
|
||||||
|
contentType: 'video',
|
||||||
|
duration: 7200,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(items);
|
||||||
|
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
|
||||||
|
// Item 1: matches include, no exclude match → pass
|
||||||
|
// Item 2: matches include AND exclude → excluded (exclude wins)
|
||||||
|
// Item 3: doesn't match include → excluded
|
||||||
|
expect(result.newItems).toBe(1);
|
||||||
|
const content = await getContentByChannelId(db, channel.id);
|
||||||
|
const inserted = content.filter(c =>
|
||||||
|
c.platformContentId.startsWith(`kf_both_${channel.id}`)
|
||||||
|
);
|
||||||
|
expect(inserted.length).toBe(1);
|
||||||
|
expect(inserted[0].title).toBe('Tutorial: Getting Started');
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not filter when no keywords are set', async () => {
|
||||||
|
const channel = await insertTestChannel({
|
||||||
|
includeKeywords: null,
|
||||||
|
excludeKeywords: null,
|
||||||
|
});
|
||||||
|
const scheduler = new SchedulerService(db, registry, rateLimiter);
|
||||||
|
|
||||||
|
mockFetchRecentContent.mockResolvedValueOnce(
|
||||||
|
makeCannedContent(4, `kf_none_${channel.id}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scheduler.checkChannel(channel);
|
||||||
|
expect(result.newItems).toBe(4);
|
||||||
|
|
||||||
|
scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
// ── monitoringMode-aware item creation tests ──
|
// ── monitoringMode-aware item creation tests ──
|
||||||
|
|
||||||
it("creates items with monitored=false when channel monitoringMode is 'none'", async () => {
|
it("creates items with monitored=false when channel monitoringMode is 'none'", async () => {
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,9 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
|
||||||
bannerUrl: null,
|
bannerUrl: null,
|
||||||
description: null,
|
description: null,
|
||||||
subscriberCount: null,
|
subscriberCount: null,
|
||||||
|
includeKeywords: null,
|
||||||
|
excludeKeywords: null,
|
||||||
|
contentRating: null,
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
lastCheckedAt: null,
|
lastCheckedAt: null,
|
||||||
|
|
@ -334,7 +337,7 @@ describe('YouTubeSource', () => {
|
||||||
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
|
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
|
||||||
{ timeout: 60_000 }
|
{ timeout: 90_000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify Phase 2 calls use --dump-json --no-playlist per video
|
// Verify Phase 2 calls use --dump-json --no-playlist per video
|
||||||
|
|
@ -443,7 +446,7 @@ describe('YouTubeSource', () => {
|
||||||
|
|
||||||
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
||||||
['--flat-playlist', '--dump-json', '--playlist-items', '1:10', channel.url],
|
['--flat-playlist', '--dump-json', '--playlist-items', '1:10', channel.url],
|
||||||
{ timeout: 60_000 }
|
{ timeout: 90_000 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -456,7 +459,7 @@ describe('YouTubeSource', () => {
|
||||||
|
|
||||||
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
||||||
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
|
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
|
||||||
{ timeout: 60_000 }
|
{ timeout: 90_000 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ function makeProfile(overrides: Partial<FormatProfile> = {}): FormatProfile {
|
||||||
containerFormat: 'mp4',
|
containerFormat: 'mp4',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,20 @@ import type { Channel, Platform, MonitoringMode } from '../../types/index';
|
||||||
/** Fields needed to create a new channel (auto-generated fields excluded). */
|
/** Fields needed to create a new channel (auto-generated fields excluded). */
|
||||||
export type CreateChannelData = Omit<
|
export type CreateChannelData = Omit<
|
||||||
Channel,
|
Channel,
|
||||||
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount'
|
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords' | 'contentRating'
|
||||||
> & {
|
> & {
|
||||||
monitoringMode?: Channel['monitoringMode'];
|
monitoringMode?: Channel['monitoringMode'];
|
||||||
bannerUrl?: string | null;
|
bannerUrl?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
subscriberCount?: number | null;
|
subscriberCount?: number | null;
|
||||||
|
includeKeywords?: string | null;
|
||||||
|
excludeKeywords?: string | null;
|
||||||
|
contentRating?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fields that can be updated on an existing channel. */
|
/** Fields that can be updated on an existing channel. */
|
||||||
export type UpdateChannelData = Partial<
|
export type UpdateChannelData = Partial<
|
||||||
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount'>
|
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords'>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
@ -47,6 +50,8 @@ export async function createChannel(
|
||||||
bannerUrl: data.bannerUrl ?? null,
|
bannerUrl: data.bannerUrl ?? null,
|
||||||
description: data.description ?? null,
|
description: data.description ?? null,
|
||||||
subscriberCount: data.subscriberCount ?? null,
|
subscriberCount: data.subscriberCount ?? null,
|
||||||
|
includeKeywords: data.includeKeywords ?? null,
|
||||||
|
excludeKeywords: data.excludeKeywords ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -200,5 +205,8 @@ function mapRow(row: typeof channels.$inferSelect): Channel {
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
lastCheckedAt: row.lastCheckedAt,
|
lastCheckedAt: row.lastCheckedAt,
|
||||||
lastCheckStatus: row.lastCheckStatus as Channel['lastCheckStatus'],
|
lastCheckStatus: row.lastCheckStatus as Channel['lastCheckStatus'],
|
||||||
|
contentRating: row.contentRating ?? null,
|
||||||
|
includeKeywords: row.includeKeywords ?? null,
|
||||||
|
excludeKeywords: row.excludeKeywords ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
import type * as schema from '../schema/index';
|
import type * as schema from '../schema/index';
|
||||||
import { contentItems } from '../schema/index';
|
import { contentItems } from '../schema/index';
|
||||||
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
|
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
|
||||||
import type { ContentCounts } from '../../types/api';
|
import type { ContentCounts, ContentTypeCounts } from '../../types/api';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
/** Fields needed to create a new content item. */
|
/** Fields needed to create a new content item. */
|
||||||
export interface CreateContentItemData {
|
export interface CreateContentItemData {
|
||||||
channelId: number;
|
channelId: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
platformContentId: string;
|
platformContentId: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -29,6 +29,7 @@ export interface UpdateContentItemData {
|
||||||
qualityMetadata?: QualityInfo | null;
|
qualityMetadata?: QualityInfo | null;
|
||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
downloadedAt?: string | null;
|
downloadedAt?: string | null;
|
||||||
|
contentRating?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
@ -43,16 +44,22 @@ export async function createContentItem(
|
||||||
db: Db,
|
db: Db,
|
||||||
data: CreateContentItemData
|
data: CreateContentItemData
|
||||||
): Promise<ContentItem | null> {
|
): Promise<ContentItem | null> {
|
||||||
// Check for existing item first — dedup by (channelId, platformContentId)
|
// Check for existing item — dedup by (channelId, platformContentId) for channel items,
|
||||||
const existing = await db
|
// or by platformContentId alone for ad-hoc items (channelId=null)
|
||||||
.select({ id: contentItems.id })
|
const dedupConditions = data.channelId !== null
|
||||||
.from(contentItems)
|
? and(
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(contentItems.channelId, data.channelId),
|
eq(contentItems.channelId, data.channelId),
|
||||||
eq(contentItems.platformContentId, data.platformContentId)
|
eq(contentItems.platformContentId, data.platformContentId)
|
||||||
)
|
)
|
||||||
)
|
: and(
|
||||||
|
sql`${contentItems.channelId} IS NULL`,
|
||||||
|
eq(contentItems.platformContentId, data.platformContentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: contentItems.id })
|
||||||
|
.from(contentItems)
|
||||||
|
.where(dedupConditions)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
|
|
@ -167,21 +174,26 @@ function resolveSortColumn(sortBy?: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a specific content item exists for a channel. Returns the item or null. */
|
/** Check if a specific content item exists for a channel (or ad-hoc if channelId is null). Returns the item or null. */
|
||||||
export async function getContentByPlatformContentId(
|
export async function getContentByPlatformContentId(
|
||||||
db: Db,
|
db: Db,
|
||||||
channelId: number,
|
channelId: number | null,
|
||||||
platformContentId: string
|
platformContentId: string
|
||||||
): Promise<ContentItem | null> {
|
): Promise<ContentItem | null> {
|
||||||
const rows = await db
|
const conditions = channelId !== null
|
||||||
.select()
|
? and(
|
||||||
.from(contentItems)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(contentItems.channelId, channelId),
|
eq(contentItems.channelId, channelId),
|
||||||
eq(contentItems.platformContentId, platformContentId)
|
eq(contentItems.platformContentId, platformContentId)
|
||||||
)
|
)
|
||||||
)
|
: and(
|
||||||
|
sql`${contentItems.channelId} IS NULL`,
|
||||||
|
eq(contentItems.platformContentId, platformContentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(contentItems)
|
||||||
|
.where(conditions)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return rows.length > 0 ? mapRow(rows[0]) : null;
|
return rows.length > 0 ? mapRow(rows[0]) : null;
|
||||||
|
|
@ -361,6 +373,35 @@ function buildContentFilterConditions(filters?: ContentItemFilters) {
|
||||||
return conditions;
|
return conditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Content Counts by Type ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get content counts grouped by content type for a single channel.
|
||||||
|
* Returns { video, audio, livestream } with zero-defaults for missing types.
|
||||||
|
*/
|
||||||
|
export async function getContentCountsByType(
|
||||||
|
db: Db,
|
||||||
|
channelId: number
|
||||||
|
): Promise<ContentTypeCounts> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
contentType: contentItems.contentType,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(contentItems)
|
||||||
|
.where(eq(contentItems.channelId, channelId))
|
||||||
|
.groupBy(contentItems.contentType);
|
||||||
|
|
||||||
|
const counts: ContentTypeCounts = { video: 0, audio: 0, livestream: 0 };
|
||||||
|
for (const row of rows) {
|
||||||
|
const ct = row.contentType as keyof ContentTypeCounts;
|
||||||
|
if (ct in counts) {
|
||||||
|
counts[ct] = Number(row.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Content Counts by Channel ──
|
// ── Content Counts by Channel ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -386,7 +427,8 @@ export async function getContentCountsByChannelIds(
|
||||||
|
|
||||||
const map = new Map<number, ContentCounts>();
|
const map = new Map<number, ContentCounts>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
map.set(row.channelId, {
|
// channelId is always non-null here — we're filtering by specific channelIds via inArray
|
||||||
|
map.set(row.channelId!, {
|
||||||
total: Number(row.total),
|
total: Number(row.total),
|
||||||
monitored: Number(row.monitored),
|
monitored: Number(row.monitored),
|
||||||
downloaded: Number(row.downloaded),
|
downloaded: Number(row.downloaded),
|
||||||
|
|
@ -444,6 +486,7 @@ function mapRow(row: typeof contentItems.$inferSelect): ContentItem {
|
||||||
publishedAt: row.publishedAt ?? null,
|
publishedAt: row.publishedAt ?? null,
|
||||||
downloadedAt: row.downloadedAt ?? null,
|
downloadedAt: row.downloadedAt ?? null,
|
||||||
monitored: row.monitored,
|
monitored: row.monitored,
|
||||||
|
contentRating: row.contentRating ?? null,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ function mapRow(row: typeof formatProfiles.$inferSelect): FormatProfile {
|
||||||
embedChapters: row.embedChapters,
|
embedChapters: row.embedChapters,
|
||||||
embedThumbnail: row.embedThumbnail,
|
embedThumbnail: row.embedThumbnail,
|
||||||
sponsorBlockRemove: row.sponsorBlockRemove ?? null,
|
sponsorBlockRemove: row.sponsorBlockRemove ?? null,
|
||||||
|
outputTemplate: row.outputTemplate ?? null,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
137
src/db/repositories/media-server-repository.ts
Normal file
137
src/db/repositories/media-server-repository.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
|
import type * as schema from '../schema/index';
|
||||||
|
import { mediaServers } from '../schema/index';
|
||||||
|
import type { MediaServer, MediaServerType } from '../../types/index';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
/** Fields needed to create a new media server. */
|
||||||
|
export interface CreateMediaServerData {
|
||||||
|
name: string;
|
||||||
|
type: MediaServerType;
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
librarySection?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields that can be updated on an existing media server. */
|
||||||
|
export interface UpdateMediaServerData {
|
||||||
|
name?: string;
|
||||||
|
type?: MediaServerType;
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
librarySection?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
||||||
|
// ── Repository Functions ──
|
||||||
|
|
||||||
|
/** Insert a new media server. Returns the created row. */
|
||||||
|
export async function createMediaServer(
|
||||||
|
db: Db,
|
||||||
|
data: CreateMediaServerData
|
||||||
|
): Promise<MediaServer> {
|
||||||
|
const result = await db
|
||||||
|
.insert(mediaServers)
|
||||||
|
.values({
|
||||||
|
name: data.name,
|
||||||
|
type: data.type,
|
||||||
|
url: data.url,
|
||||||
|
token: data.token,
|
||||||
|
librarySection: data.librarySection ?? null,
|
||||||
|
enabled: data.enabled ?? true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return mapRow(result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all media servers, ordered by name. */
|
||||||
|
export async function getAllMediaServers(db: Db): Promise<MediaServer[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(mediaServers)
|
||||||
|
.orderBy(mediaServers.name);
|
||||||
|
|
||||||
|
return rows.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a media server by ID. Returns null if not found. */
|
||||||
|
export async function getMediaServerById(
|
||||||
|
db: Db,
|
||||||
|
id: number
|
||||||
|
): Promise<MediaServer | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(mediaServers)
|
||||||
|
.where(eq(mediaServers.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return rows.length > 0 ? mapRow(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all enabled media servers. */
|
||||||
|
export async function getEnabledMediaServers(
|
||||||
|
db: Db
|
||||||
|
): Promise<MediaServer[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(mediaServers)
|
||||||
|
.where(eq(mediaServers.enabled, true));
|
||||||
|
|
||||||
|
return rows.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a media server. Sets updatedAt to current time.
|
||||||
|
* Returns updated server or null if not found.
|
||||||
|
*/
|
||||||
|
export async function updateMediaServer(
|
||||||
|
db: Db,
|
||||||
|
id: number,
|
||||||
|
data: UpdateMediaServerData
|
||||||
|
): Promise<MediaServer | null> {
|
||||||
|
const result = await db
|
||||||
|
.update(mediaServers)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(mediaServers.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result.length > 0 ? mapRow(result[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a media server by ID. Returns true if a row was deleted. */
|
||||||
|
export async function deleteMediaServer(
|
||||||
|
db: Db,
|
||||||
|
id: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.delete(mediaServers)
|
||||||
|
.where(eq(mediaServers.id, id))
|
||||||
|
.returning({ id: mediaServers.id });
|
||||||
|
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row Mapping ──
|
||||||
|
|
||||||
|
function mapRow(row: typeof mediaServers.$inferSelect): MediaServer {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type as MediaServerType,
|
||||||
|
url: row.url,
|
||||||
|
token: row.token,
|
||||||
|
librarySection: row.librarySection,
|
||||||
|
enabled: row.enabled,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -192,6 +192,7 @@ export async function countQueueItemsByStatus(
|
||||||
completed: 0,
|
completed: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
cancelled: 0,
|
cancelled: 0,
|
||||||
|
paused: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
||||||
export const APP_CHECK_INTERVAL = 'app.check_interval';
|
export const APP_CHECK_INTERVAL = 'app.check_interval';
|
||||||
export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads';
|
export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads';
|
||||||
|
export const APP_OUTPUT_TEMPLATE = 'app.output_template';
|
||||||
|
export const APP_NFO_ENABLED = 'app.nfo_enabled';
|
||||||
|
export const APP_TIMEZONE = 'app.timezone';
|
||||||
|
export const APP_THEME = 'app.theme';
|
||||||
export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated';
|
export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated';
|
||||||
|
|
||||||
// ── Read / Write ──
|
// ── Read / Write ──
|
||||||
|
|
@ -86,6 +90,7 @@ export async function seedAppDefaults(db: Db): Promise<void> {
|
||||||
const defaults: Array<{ key: string; value: string }> = [
|
const defaults: Array<{ key: string; value: string }> = [
|
||||||
{ key: APP_CHECK_INTERVAL, value: appConfig.scheduler.defaultCheckInterval.toString() },
|
{ key: APP_CHECK_INTERVAL, value: appConfig.scheduler.defaultCheckInterval.toString() },
|
||||||
{ key: APP_CONCURRENT_DOWNLOADS, value: appConfig.concurrentDownloads.toString() },
|
{ key: APP_CONCURRENT_DOWNLOADS, value: appConfig.concurrentDownloads.toString() },
|
||||||
|
{ key: APP_OUTPUT_TEMPLATE, value: '{platform}/{channel}/{title}.{ext}' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { key, value } of defaults) {
|
for (const { key, value } of defaults) {
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,7 @@ export const channels = sqliteTable('channels', {
|
||||||
bannerUrl: text('banner_url'),
|
bannerUrl: text('banner_url'),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
subscriberCount: integer('subscriber_count'),
|
subscriberCount: integer('subscriber_count'),
|
||||||
|
includeKeywords: text('include_keywords'), // nullable — pipe-separated patterns for auto-enqueue filtering
|
||||||
|
excludeKeywords: text('exclude_keywords'), // nullable — pipe-separated patterns for auto-enqueue filtering
|
||||||
|
contentRating: text('content_rating'), // nullable — default rating for all content from this channel (e.g. 'TV-PG', 'TV-MA')
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { channels } from './channels';
|
||||||
export const contentItems = sqliteTable('content_items', {
|
export const contentItems = sqliteTable('content_items', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
channelId: integer('channel_id')
|
channelId: integer('channel_id')
|
||||||
.notNull()
|
|
||||||
.references(() => channels.id, { onDelete: 'cascade' }),
|
.references(() => channels.id, { onDelete: 'cascade' }),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
platformContentId: text('platform_content_id').notNull(),
|
platformContentId: text('platform_content_id').notNull(),
|
||||||
|
|
@ -17,11 +16,12 @@ export const contentItems = sqliteTable('content_items', {
|
||||||
fileSize: integer('file_size'), // bytes
|
fileSize: integer('file_size'), // bytes
|
||||||
format: text('format'), // container format e.g. 'mp4', 'webm', 'mp3'
|
format: text('format'), // container format e.g. 'mp4', 'webm', 'mp3'
|
||||||
qualityMetadata: text('quality_metadata', { mode: 'json' }), // actual quality info post-download
|
qualityMetadata: text('quality_metadata', { mode: 'json' }), // actual quality info post-download
|
||||||
status: text('status').notNull().default('monitored'), // monitored|queued|downloading|downloaded|failed|ignored
|
status: text('status').notNull().default('monitored'), // monitored|queued|downloading|downloaded|failed|ignored|missing
|
||||||
thumbnailUrl: text('thumbnail_url'),
|
thumbnailUrl: text('thumbnail_url'),
|
||||||
publishedAt: text('published_at'), // ISO datetime from platform (nullable)
|
publishedAt: text('published_at'), // ISO datetime from platform (nullable)
|
||||||
downloadedAt: text('downloaded_at'), // ISO datetime when download completed (nullable)
|
downloadedAt: text('downloaded_at'), // ISO datetime when download completed (nullable)
|
||||||
monitored: integer('monitored', { mode: 'boolean' }).notNull().default(true), // per-item monitoring toggle
|
monitored: integer('monitored', { mode: 'boolean' }).notNull().default(true), // per-item monitoring toggle
|
||||||
|
contentRating: text('content_rating'), // nullable — per-item rating override (e.g. 'TV-PG', 'TV-MA')
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(datetime('now'))`),
|
.default(sql`(datetime('now'))`),
|
||||||
|
|
@ -44,6 +44,7 @@ export const formatProfiles = sqliteTable('format_profiles', {
|
||||||
embedChapters: integer('embed_chapters', { mode: 'boolean' }).notNull().default(false),
|
embedChapters: integer('embed_chapters', { mode: 'boolean' }).notNull().default(false),
|
||||||
embedThumbnail: integer('embed_thumbnail', { mode: 'boolean' }).notNull().default(false),
|
embedThumbnail: integer('embed_thumbnail', { mode: 'boolean' }).notNull().default(false),
|
||||||
sponsorBlockRemove: text('sponsor_block_remove'), // comma-separated categories: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
|
sponsorBlockRemove: text('sponsor_block_remove'), // comma-separated categories: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
|
||||||
|
outputTemplate: text('output_template'), // per-profile path template override e.g. '{platform}/{channel}/{title}.{ext}'
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(datetime('now'))`),
|
.default(sql`(datetime('now'))`),
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,4 @@ export { downloadHistory } from './history';
|
||||||
export { notificationSettings } from './notifications';
|
export { notificationSettings } from './notifications';
|
||||||
export { platformSettings } from './platform-settings';
|
export { platformSettings } from './platform-settings';
|
||||||
export { playlists, contentPlaylist } from './playlists';
|
export { playlists, contentPlaylist } from './playlists';
|
||||||
|
export { mediaServers } from './media-servers';
|
||||||
|
|
|
||||||
19
src/db/schema/media-servers.ts
Normal file
19
src/db/schema/media-servers.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/** Media server connections for triggering library scans (Plex, Jellyfin). */
|
||||||
|
export const mediaServers = sqliteTable('media_servers', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
type: text('type').notNull(), // 'plex' | 'jellyfin'
|
||||||
|
url: text('url').notNull(),
|
||||||
|
token: text('token').notNull(),
|
||||||
|
librarySection: text('library_section'), // nullable — Plex section ID or Jellyfin library ID
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
createdAt: text('created_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(datetime('now'))`),
|
||||||
|
updatedAt: text('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(datetime('now'))`),
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,13 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>
|
||||||
|
// Apply saved theme instantly to prevent flash of wrong theme
|
||||||
|
(function() {
|
||||||
|
var t = localStorage.getItem('tubearr-theme');
|
||||||
|
if (t === 'light') document.documentElement.dataset.theme = 'light';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { Channels } from './pages/Channels';
|
import { Channels } from './pages/Channels';
|
||||||
import { ChannelDetail } from './pages/ChannelDetail';
|
import { ChannelDetail } from './pages/ChannelDetail';
|
||||||
import { Library } from './pages/Library';
|
import { Library } from './pages/Library';
|
||||||
|
|
@ -10,6 +11,8 @@ import { SettingsPage } from './pages/Settings';
|
||||||
import { SystemPage } from './pages/System';
|
import { SystemPage } from './pages/System';
|
||||||
|
|
||||||
function AuthenticatedLayout() {
|
function AuthenticatedLayout() {
|
||||||
|
// Apply theme from settings to documentElement at the app root
|
||||||
|
useTheme();
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
|
||||||
52
src/frontend/src/api/hooks/useAdhocDownload.ts
Normal file
52
src/frontend/src/api/hooks/useAdhocDownload.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '../client';
|
||||||
|
import type { ContentType } from '@shared/types/index';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface UrlPreviewResponse {
|
||||||
|
title: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
platform: string;
|
||||||
|
channelName: string | null;
|
||||||
|
contentType: ContentType;
|
||||||
|
platformContentId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmRequest {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
platform: string;
|
||||||
|
platformContentId: string;
|
||||||
|
contentType: string;
|
||||||
|
channelName?: string;
|
||||||
|
duration?: number | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
formatProfileId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmResponse {
|
||||||
|
contentItemId: number;
|
||||||
|
queueItemId: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations ──
|
||||||
|
|
||||||
|
/** Resolve URL metadata via yt-dlp (preview step). */
|
||||||
|
export function useUrlPreview() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (url: string) =>
|
||||||
|
apiClient.post<UrlPreviewResponse>('/api/v1/download/url/preview', { url }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm ad-hoc download — creates content item and enqueues. */
|
||||||
|
export function useUrlConfirm() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: ConfirmRequest) =>
|
||||||
|
apiClient.post<ConfirmResponse>('/api/v1/download/url/confirm', data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -59,7 +59,7 @@ export function useUpdateChannel(id: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null }) =>
|
mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; contentRating?: string | null }) =>
|
||||||
apiClient.put<Channel>(`/api/v1/channel/${id}`, data),
|
apiClient.put<Channel>(`/api/v1/channel/${id}`, data),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.setQueryData(channelKeys.detail(id), updated);
|
queryClient.setQueryData(channelKeys.detail(id), updated);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tansta
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client';
|
||||||
import { queueKeys } from './useQueue';
|
import { queueKeys } from './useQueue';
|
||||||
import type { ContentItem } from '@shared/types/index';
|
import type { ContentItem } from '@shared/types/index';
|
||||||
import type { ApiResponse, PaginatedResponse } from '@shared/types/api';
|
import type { ApiResponse, PaginatedResponse, ContentTypeCounts } from '@shared/types/api';
|
||||||
|
|
||||||
// ── Collect Types ──
|
// ── Collect Types ──
|
||||||
|
|
||||||
|
|
@ -31,6 +31,8 @@ export const contentKeys = {
|
||||||
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
||||||
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
|
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
|
||||||
['content', 'channel', channelId, 'paginated', filters] as const,
|
['content', 'channel', channelId, 'paginated', filters] as const,
|
||||||
|
countsByType: (channelId: number) =>
|
||||||
|
['content', 'channel', channelId, 'counts-by-type'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
@ -59,6 +61,20 @@ export function useChannelContentPaginated(channelId: number, filters: ChannelCo
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch content counts grouped by content type for a channel. */
|
||||||
|
export function useContentTypeCounts(channelId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: contentKeys.countsByType(channelId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get<ApiResponse<ContentTypeCounts>>(
|
||||||
|
`/api/v1/channel/${channelId}/content-counts`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: channelId > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
|
|
||||||
/** Enqueue a content item for download. Returns 202 with queue item. */
|
/** Enqueue a content item for download. Returns 202 with queue item. */
|
||||||
|
|
@ -138,3 +154,16 @@ export function useCollectAllMonitored() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update content item rating. */
|
||||||
|
export function useUpdateContentRating(channelId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ contentId, contentRating }: { contentId: number; contentRating: string | null }) =>
|
||||||
|
apiClient.patch<ApiResponse<ContentItem>>(`/api/v1/content/${contentId}/rating`, { contentRating }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['content', 'channel', channelId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ interface CreateFormatProfileInput {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
|
outputTemplate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateFormatProfileInput {
|
interface UpdateFormatProfileInput {
|
||||||
|
|
@ -40,6 +41,7 @@ interface UpdateFormatProfileInput {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
|
outputTemplate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mutations ──
|
// ── Mutations ──
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||||
import type { PaginatedResponse } from '@shared/types/api';
|
import type { PaginatedResponse } from '@shared/types/api';
|
||||||
|
|
@ -44,3 +44,17 @@ export function useLibraryContent(filters: LibraryFilters = {}) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mutations ──
|
||||||
|
|
||||||
|
/** Re-download a missing content item. Invalidates library queries on success. */
|
||||||
|
export function useRequeueContent() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (contentId: number) =>
|
||||||
|
apiClient.post(`/api/v1/content/${contentId}/requeue`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: libraryKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
111
src/frontend/src/api/hooks/useMediaServers.ts
Normal file
111
src/frontend/src/api/hooks/useMediaServers.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '../client';
|
||||||
|
import type { MediaServer } from '@shared/types/index';
|
||||||
|
|
||||||
|
// ── Query Keys ──
|
||||||
|
|
||||||
|
export const mediaServerKeys = {
|
||||||
|
all: ['media-servers'] as const,
|
||||||
|
sections: (id: number) => ['media-servers', id, 'sections'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Re-export for convenience ──
|
||||||
|
|
||||||
|
export type { MediaServer };
|
||||||
|
|
||||||
|
// ── Input Types ──
|
||||||
|
|
||||||
|
export interface CreateMediaServerInput {
|
||||||
|
name: string;
|
||||||
|
type: 'plex' | 'jellyfin';
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
librarySection?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMediaServerInput {
|
||||||
|
name?: string;
|
||||||
|
type?: 'plex' | 'jellyfin';
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
librarySection?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibrarySection {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionTestResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
serverName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Queries ──
|
||||||
|
|
||||||
|
/** Fetch all media servers. */
|
||||||
|
export function useMediaServers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: mediaServerKeys.all,
|
||||||
|
queryFn: () => apiClient.get<MediaServer[]>('/api/v1/media-servers'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch library sections for a saved media server by ID. */
|
||||||
|
export function useMediaServerSections(id: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: id !== null ? mediaServerKeys.sections(id) : ['media-servers', 'sections', 'none'],
|
||||||
|
queryFn: () => apiClient.get<LibrarySection[]>(`/api/v1/media-servers/${id}/sections`),
|
||||||
|
enabled: id !== null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations ──
|
||||||
|
|
||||||
|
/** Create a new media server. Invalidates list on success. */
|
||||||
|
export function useCreateMediaServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateMediaServerInput) =>
|
||||||
|
apiClient.post<MediaServer>('/api/v1/media-servers', input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: mediaServerKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a media server by ID. Invalidates list on success. */
|
||||||
|
export function useUpdateMediaServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...input }: UpdateMediaServerInput & { id: number }) =>
|
||||||
|
apiClient.put<MediaServer>(`/api/v1/media-servers/${id}`, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: mediaServerKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a media server by ID. Invalidates list on success. */
|
||||||
|
export function useDeleteMediaServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiClient.del<void>(`/api/v1/media-servers/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: mediaServerKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test connection for a saved media server by ID. */
|
||||||
|
export function useTestMediaServer() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiClient.post<ConnectionTestResult>(`/api/v1/media-servers/${id}/test`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -57,3 +57,33 @@ export function useCancelQueueItem() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pause a pending or downloading queue item. */
|
||||||
|
export function usePauseQueueItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiClient.put<{ success: boolean; data: QueueItem }>(
|
||||||
|
`/api/v1/queue/${id}/pause`,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queueKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume a paused queue item. */
|
||||||
|
export function useResumeQueueItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) =>
|
||||||
|
apiClient.put<{ success: boolean; data: QueueItem }>(
|
||||||
|
`/api/v1/queue/${id}/resume`,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queueKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const systemKeys = {
|
||||||
apiKey: ['system', 'apikey'] as const,
|
apiKey: ['system', 'apikey'] as const,
|
||||||
appSettings: ['system', 'appSettings'] as const,
|
appSettings: ['system', 'appSettings'] as const,
|
||||||
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
|
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
|
||||||
|
missingScanStatus: ['system', 'missingScanStatus'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
@ -91,3 +92,44 @@ export function useUpdateYtDlp() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Missing File Scan ──
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
checked: number;
|
||||||
|
missing: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissingScanStatusResponse {
|
||||||
|
lastRun: string;
|
||||||
|
result: ScanResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissingScanTriggerResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: ScanResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch last missing file scan status. Does not auto-refresh. */
|
||||||
|
export function useMissingScanStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: systemKeys.missingScanStatus,
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.get<{ success: boolean; data: MissingScanStatusResponse | null }>(
|
||||||
|
'/api/v1/system/missing-scan/status',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger an on-demand missing file scan. Invalidates scan status on success. */
|
||||||
|
export function useTriggerMissingScan() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient.post<MissingScanTriggerResponse>('/api/v1/system/missing-scan'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: systemKeys.missingScanStatus });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
425
src/frontend/src/components/AddUrlModal.tsx
Normal file
425
src/frontend/src/components/AddUrlModal.tsx
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { useUrlPreview, useUrlConfirm } from '../api/hooks/useAdhocDownload';
|
||||||
|
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
||||||
|
import { useToast } from './Toast';
|
||||||
|
import { Loader, Download, Clock, Film, Music, Radio } from 'lucide-react';
|
||||||
|
import type { UrlPreviewResponse } from '../api/hooks/useAdhocDownload';
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function formatDuration(seconds: number | null): string {
|
||||||
|
if (seconds === null || seconds <= 0) return '—';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_LABELS: Record<string, string> = {
|
||||||
|
youtube: 'YouTube',
|
||||||
|
soundcloud: 'SoundCloud',
|
||||||
|
generic: 'Generic',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ContentTypeIcon({ type }: { type: string }) {
|
||||||
|
switch (type) {
|
||||||
|
case 'video':
|
||||||
|
return <Film size={14} aria-hidden="true" />;
|
||||||
|
case 'audio':
|
||||||
|
return <Music size={14} aria-hidden="true" />;
|
||||||
|
case 'livestream':
|
||||||
|
return <Radio size={14} aria-hidden="true" />;
|
||||||
|
default:
|
||||||
|
return <Film size={14} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
interface AddUrlModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddUrlModal({ open, onClose }: AddUrlModalProps) {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
|
||||||
|
const [preview, setPreview] = useState<UrlPreviewResponse | null>(null);
|
||||||
|
|
||||||
|
const urlPreview = useUrlPreview();
|
||||||
|
const urlConfirm = useUrlConfirm();
|
||||||
|
const { data: formatProfiles } = useFormatProfiles();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setUrl('');
|
||||||
|
setFormatProfileId(undefined);
|
||||||
|
setPreview(null);
|
||||||
|
urlPreview.reset();
|
||||||
|
urlConfirm.reset();
|
||||||
|
}, [urlPreview, urlConfirm]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (!urlPreview.isPending && !urlConfirm.isPending) {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [urlPreview.isPending, urlConfirm.isPending, resetForm, onClose]);
|
||||||
|
|
||||||
|
const handlePreview = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url.trim()) return;
|
||||||
|
|
||||||
|
urlConfirm.reset();
|
||||||
|
urlPreview.mutate(url.trim(), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPreview(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[url, urlPreview, urlConfirm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (!preview) return;
|
||||||
|
|
||||||
|
urlConfirm.mutate(
|
||||||
|
{
|
||||||
|
url: preview.url,
|
||||||
|
title: preview.title,
|
||||||
|
platform: preview.platform,
|
||||||
|
platformContentId: preview.platformContentId,
|
||||||
|
contentType: preview.contentType,
|
||||||
|
channelName: preview.channelName ?? undefined,
|
||||||
|
duration: preview.duration,
|
||||||
|
thumbnailUrl: preview.thumbnail,
|
||||||
|
formatProfileId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast('Download queued', 'success');
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [preview, formatProfileId, urlConfirm, toast, resetForm, onClose]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setPreview(null);
|
||||||
|
urlPreview.reset();
|
||||||
|
urlConfirm.reset();
|
||||||
|
}, [urlPreview, urlConfirm]);
|
||||||
|
|
||||||
|
const isPending = urlPreview.isPending || urlConfirm.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Download URL" open={open} onClose={handleClose} width={520}>
|
||||||
|
{!preview ? (
|
||||||
|
/* ── Step 1: URL input ── */
|
||||||
|
<form onSubmit={handlePreview}>
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="adhoc-url"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Paste a video or audio URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="adhoc-url"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{urlPreview.isError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
backgroundColor: 'var(--danger-bg)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{urlPreview.error instanceof Error
|
||||||
|
? urlPreview.error.message
|
||||||
|
: 'Failed to resolve URL'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!url.trim() || isPending}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--accent)',
|
||||||
|
color: 'var(--text-inverse)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: !url.trim() || isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{urlPreview.isPending && (
|
||||||
|
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{urlPreview.isPending ? 'Resolving…' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
/* ── Step 2: Preview & confirm ── */
|
||||||
|
<div>
|
||||||
|
{/* Preview card */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
{preview.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={preview.thumbnail}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
height: 90,
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
height: 90,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentTypeIcon type={preview.contentType} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--font-size-base)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{preview.channelName && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview.channelName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
marginTop: 'var(--space-2)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentTypeIcon type={preview.contentType} />
|
||||||
|
{preview.contentType}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{preview.duration !== null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock size={14} aria-hidden="true" />
|
||||||
|
{formatDuration(preview.duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span>{PLATFORM_LABELS[preview.platform] ?? preview.platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format profile selector */}
|
||||||
|
{formatProfiles && formatProfiles.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="adhoc-format-profile"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Format Profile
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="adhoc-format-profile"
|
||||||
|
value={formatProfileId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormatProfileId(e.target.value ? Number(e.target.value) : undefined)
|
||||||
|
}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<option value="">None (use default)</option>
|
||||||
|
{formatProfiles.map((fp) => (
|
||||||
|
<option key={fp.id} value={fp.id}>
|
||||||
|
{fp.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{urlConfirm.isError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
backgroundColor: 'var(--danger-bg)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{urlConfirm.error instanceof Error
|
||||||
|
? urlConfirm.error.message
|
||||||
|
: 'Failed to start download'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--accent)',
|
||||||
|
color: 'var(--text-inverse)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{urlConfirm.isPending ? (
|
||||||
|
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Download size={14} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{urlConfirm.isPending ? 'Queuing…' : 'Download'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback, type FormEvent } from 'react';
|
import { useState, useCallback, useMemo, type FormEvent } from 'react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import type { FormatProfile } from '@shared/types/index';
|
import type { FormatProfile } from '@shared/types/index';
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface FormatProfileFormValues {
|
||||||
embedChapters: boolean;
|
embedChapters: boolean;
|
||||||
embedThumbnail: boolean;
|
embedThumbnail: boolean;
|
||||||
sponsorBlockRemove: string | null;
|
sponsorBlockRemove: string | null;
|
||||||
|
outputTemplate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatProfileFormProps {
|
interface FormatProfileFormProps {
|
||||||
|
|
@ -98,6 +99,38 @@ export function FormatProfileForm({
|
||||||
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 [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? '');
|
||||||
|
const [outputTemplate, setOutputTemplate] = useState(profile?.outputTemplate ?? '');
|
||||||
|
|
||||||
|
const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const;
|
||||||
|
|
||||||
|
const templatePreview = useMemo(() => {
|
||||||
|
if (!outputTemplate.trim()) return '';
|
||||||
|
const exampleVars: Record<string, string> = {
|
||||||
|
platform: 'youtube',
|
||||||
|
channel: 'TechChannel',
|
||||||
|
title: 'How to Build a Server',
|
||||||
|
date: '2026-04-04',
|
||||||
|
year: '2026',
|
||||||
|
month: '04',
|
||||||
|
contentType: 'video',
|
||||||
|
id: 'dQw4w9WgXcQ',
|
||||||
|
ext: 'mp4',
|
||||||
|
};
|
||||||
|
return outputTemplate.replace(/\{([a-zA-Z]+)\}/g, (_m, v: string) => exampleVars[v] ?? `{${v}}`);
|
||||||
|
}, [outputTemplate]);
|
||||||
|
|
||||||
|
const templateErrors = useMemo(() => {
|
||||||
|
if (!outputTemplate.trim()) return []; // empty = use system default
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!outputTemplate.includes('{ext}')) errors.push('Must contain {ext}');
|
||||||
|
const matches = [...outputTemplate.matchAll(/\{([a-zA-Z]+)\}/g)];
|
||||||
|
for (const m of matches) {
|
||||||
|
if (!(TEMPLATE_VARIABLES as readonly string[]).includes(m[1])) {
|
||||||
|
errors.push(`Unknown variable: {${m[1]}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}, [outputTemplate]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
|
|
@ -115,9 +148,10 @@ export function FormatProfileForm({
|
||||||
embedChapters,
|
embedChapters,
|
||||||
embedThumbnail,
|
embedThumbnail,
|
||||||
sponsorBlockRemove: sponsorBlockRemove.trim() || null,
|
sponsorBlockRemove: sponsorBlockRemove.trim() || null,
|
||||||
|
outputTemplate: outputTemplate.trim() || null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, onSubmit],
|
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, outputTemplate, onSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -312,6 +346,39 @@ export function FormatProfileForm({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Output Template (per-profile override) */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="fp-output-template" style={labelStyle}>
|
||||||
|
Output Template Override
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fp-output-template"
|
||||||
|
type="text"
|
||||||
|
value={outputTemplate}
|
||||||
|
onChange={(e) => setOutputTemplate(e.target.value)}
|
||||||
|
placeholder="Leave blank to use system default"
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
borderColor: templateErrors.length > 0 ? 'var(--danger)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{templateErrors.length > 0 && (
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--danger)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
{templateErrors.join('. ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{templatePreview && templateErrors.length === 0 && (
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block', fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)' }}>
|
||||||
|
Preview: {templatePreview}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
Variables: {'{platform}'}, {'{channel}'}, {'{title}'}, {'{date}'}, {'{year}'}, {'{month}'}, {'{contentType}'}, {'{id}'}, {'{ext}'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Is Default checkbox */}
|
{/* Is Default checkbox */}
|
||||||
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import type { ComponentHealth } from '@shared/types/api';
|
import type { ComponentHealth } from '@shared/types/api';
|
||||||
import { formatBytes } from '../utils/format';
|
import { formatBytes, formatLocalDateTime } from '../utils/format';
|
||||||
|
import { useTimezone } from '../hooks/useTimezone';
|
||||||
import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
|
import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
import type { UseMutationResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ interface HealthStatusProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
|
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
|
||||||
|
const timezone = useTimezone();
|
||||||
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
|
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
|
||||||
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
|
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
|
||||||
|
|
||||||
|
|
@ -292,7 +294,7 @@ function YtDlpDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
||||||
{/* Last Updated */}
|
{/* Last Updated */}
|
||||||
{!ytdlpLoading && ytdlpStatus && (
|
{!ytdlpLoading && ytdlpStatus && (
|
||||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
||||||
Last checked: {ytdlpStatus.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'}
|
Last checked: {ytdlpStatus.lastUpdated ? formatLocalDateTime(ytdlpStatus.lastUpdated, timezone) : 'Never'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
399
src/frontend/src/components/MediaServerForm.tsx
Normal file
399
src/frontend/src/components/MediaServerForm.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
import { useState, useCallback, useEffect, type FormEvent } from 'react';
|
||||||
|
import { Loader, CheckCircle, XCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import type { MediaServer } from '@shared/types/index';
|
||||||
|
import {
|
||||||
|
useTestMediaServer,
|
||||||
|
useMediaServerSections,
|
||||||
|
type LibrarySection,
|
||||||
|
} from '../api/hooks/useMediaServers';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface MediaServerFormValues {
|
||||||
|
name: string;
|
||||||
|
type: 'plex' | 'jellyfin';
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
librarySection: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaServerFormProps {
|
||||||
|
/** Pass a server for edit mode. Omit for create mode. */
|
||||||
|
server?: MediaServer;
|
||||||
|
onSubmit: (values: MediaServerFormValues) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isPending?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared styles ──
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
backgroundColor: 'var(--bg-input)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-base)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldGroupStyle: React.CSSProperties = {
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkboxRowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
marginBottom: 'var(--space-2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkboxStyle: React.CSSProperties = {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkboxLabelStyle: React.CSSProperties = {
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function MediaServerForm({
|
||||||
|
server,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isPending = false,
|
||||||
|
error,
|
||||||
|
}: MediaServerFormProps) {
|
||||||
|
const isEdit = !!server;
|
||||||
|
|
||||||
|
const [name, setName] = useState(server?.name ?? '');
|
||||||
|
const [type, setType] = useState<'plex' | 'jellyfin'>(server?.type ?? 'plex');
|
||||||
|
const [url, setUrl] = useState(server?.url ?? '');
|
||||||
|
const [token, setToken] = useState(''); // Never pre-fill redacted token
|
||||||
|
const [librarySection, setLibrarySection] = useState<string | null>(server?.librarySection ?? null);
|
||||||
|
const [enabled, setEnabled] = useState(server?.enabled ?? true);
|
||||||
|
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Test Connection ──
|
||||||
|
const testMutation = useTestMediaServer();
|
||||||
|
const [testResult, setTestResult] = useState<'success' | 'error' | null>(null);
|
||||||
|
const [testMessage, setTestMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleTestConnection = useCallback(() => {
|
||||||
|
if (!server) return; // Can only test saved servers
|
||||||
|
setTestResult(null);
|
||||||
|
setTestMessage(null);
|
||||||
|
testMutation.mutate(server.id, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setTestResult(data.success ? 'success' : 'error');
|
||||||
|
setTestMessage(data.message);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setTestResult('error');
|
||||||
|
setTestMessage(err instanceof Error ? err.message : 'Connection test failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [server, testMutation]);
|
||||||
|
|
||||||
|
// ── Sections fetch (only for saved servers) ──
|
||||||
|
const {
|
||||||
|
data: sections,
|
||||||
|
isLoading: sectionsLoading,
|
||||||
|
refetch: refetchSections,
|
||||||
|
} = useMediaServerSections(server?.id ?? null);
|
||||||
|
|
||||||
|
// Reset library section when type changes (sections differ between Plex and Jellyfin)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) {
|
||||||
|
setLibrarySection(null);
|
||||||
|
}
|
||||||
|
}, [type, isEdit]);
|
||||||
|
|
||||||
|
// ── Validation ──
|
||||||
|
const isValid =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
url.trim().length > 0 &&
|
||||||
|
(isEdit || token.trim().length > 0); // Token required for create, optional for edit (keep existing)
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setValidationError('Name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!url.trim()) {
|
||||||
|
setValidationError('URL is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEdit && !token.trim()) {
|
||||||
|
setValidationError('Token is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: MediaServerFormValues = {
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
url: url.trim().replace(/\/+$/, ''), // Strip trailing slashes
|
||||||
|
token: token.trim(),
|
||||||
|
librarySection: librarySection || null,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
// In edit mode, only send token if user entered a new one
|
||||||
|
if (isEdit && !token.trim()) {
|
||||||
|
// Omit token field — the hook's UpdateMediaServerInput has all fields optional
|
||||||
|
const { token: _omit, ...rest } = values;
|
||||||
|
onSubmit(rest as MediaServerFormValues);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(values);
|
||||||
|
},
|
||||||
|
[name, type, url, token, librarySection, enabled, isEdit, onSubmit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayError = validationError ?? error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{displayError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
backgroundColor: 'var(--danger-bg)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="ms-name" style={labelStyle}>
|
||||||
|
Name <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ms-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Living Room Plex"
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="ms-type" style={labelStyle}>
|
||||||
|
Type <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ms-type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as 'plex' | 'jellyfin')}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="plex">Plex</option>
|
||||||
|
<option value="jellyfin">Jellyfin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="ms-url" style={labelStyle}>
|
||||||
|
URL <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ms-url"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder={type === 'plex' ? 'http://192.168.1.100:32400' : 'http://192.168.1.100:8096'}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
{type === 'plex'
|
||||||
|
? 'Plex server URL including port (default 32400)'
|
||||||
|
: 'Jellyfin server URL including port (default 8096)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="ms-token" style={labelStyle}>
|
||||||
|
Token {!isEdit && <span style={{ color: 'var(--danger)' }}>*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ms-token"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder={isEdit ? '(leave empty to keep current)' : type === 'plex' ? 'X-Plex-Token value' : 'API key from Jellyfin dashboard'}
|
||||||
|
required={!isEdit}
|
||||||
|
style={inputStyle}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
{type === 'plex'
|
||||||
|
? 'Find in Plex Web → Settings → General → X-Plex-Token'
|
||||||
|
: 'Dashboard → API Keys → Create'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Library Section (dropdown, only for saved servers) */}
|
||||||
|
{isEdit && (
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label htmlFor="ms-section" style={labelStyle}>
|
||||||
|
Library Section
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||||
|
<select
|
||||||
|
id="ms-section"
|
||||||
|
value={librarySection ?? ''}
|
||||||
|
onChange={(e) => setLibrarySection(e.target.value || null)}
|
||||||
|
style={{ ...selectStyle, flex: 1 }}
|
||||||
|
disabled={sectionsLoading}
|
||||||
|
>
|
||||||
|
<option value="">All libraries (scan all)</option>
|
||||||
|
{sections?.map((s: LibrarySection) => (
|
||||||
|
<option key={s.key} value={s.key}>
|
||||||
|
{s.title} ({s.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetchSections()}
|
||||||
|
disabled={sectionsLoading}
|
||||||
|
title="Refresh library sections"
|
||||||
|
aria-label="Refresh library sections"
|
||||||
|
className="btn-icon btn-icon-edit"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{sectionsLoading ? (
|
||||||
|
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
||||||
|
Choose a specific library to scan, or scan all libraries after downloads.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enabled */}
|
||||||
|
<div style={{ ...fieldGroupStyle, ...checkboxRowStyle }}>
|
||||||
|
<input
|
||||||
|
id="ms-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
style={checkboxStyle}
|
||||||
|
/>
|
||||||
|
<label htmlFor="ms-enabled" style={checkboxLabelStyle}>
|
||||||
|
Enabled — trigger scans when downloads complete
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Connection (only for saved servers) */}
|
||||||
|
{isEdit && (
|
||||||
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
) : testResult === 'success' ? (
|
||||||
|
<CheckCircle size={14} style={{ color: 'var(--success)' }} />
|
||||||
|
) : testResult === 'error' ? (
|
||||||
|
<XCircle size={14} style={{ color: 'var(--danger)' }} />
|
||||||
|
) : null}
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
{testMessage && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
color: testResult === 'success' ? 'var(--success)' : 'var(--danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', marginTop: 'var(--space-5)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isPending}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || !isValid}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
opacity: isPending || !isValid ? 0.6 : 1,
|
||||||
|
cursor: isPending || !isValid ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
|
{isEdit ? 'Save Changes' : 'Add Server'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/frontend/src/components/RatingBadge.tsx
Normal file
113
src/frontend/src/components/RatingBadge.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// ── Content Rating Badge ──
|
||||||
|
|
||||||
|
export const CONTENT_RATINGS = [
|
||||||
|
'G', 'PG', 'PG-13', 'R', 'NC-17',
|
||||||
|
'TV-Y', 'TV-G', 'TV-PG', 'TV-14', 'TV-MA',
|
||||||
|
'NR',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ContentRating = (typeof CONTENT_RATINGS)[number];
|
||||||
|
|
||||||
|
interface BadgeStyle {
|
||||||
|
color: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color map: green for family-friendly, yellow for teen, red for mature */
|
||||||
|
const RATING_STYLES: Record<string, BadgeStyle> = {
|
||||||
|
'G': { color: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.12)' },
|
||||||
|
'TV-Y': { color: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.12)' },
|
||||||
|
'TV-G': { color: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.12)' },
|
||||||
|
'PG': { color: '#eab308', backgroundColor: 'rgba(234, 179, 8, 0.12)' },
|
||||||
|
'TV-PG': { color: '#eab308', backgroundColor: 'rgba(234, 179, 8, 0.12)' },
|
||||||
|
'PG-13': { color: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.12)' },
|
||||||
|
'TV-14': { color: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.12)' },
|
||||||
|
'R': { color: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.12)' },
|
||||||
|
'TV-MA': { color: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.12)' },
|
||||||
|
'NC-17': { color: '#dc2626', backgroundColor: 'rgba(220, 38, 38, 0.12)' },
|
||||||
|
'NR': { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_STYLE: BadgeStyle = {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Badge Component ──
|
||||||
|
|
||||||
|
interface RatingBadgeProps {
|
||||||
|
rating: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RatingBadge({ rating }: RatingBadgeProps) {
|
||||||
|
if (!rating) return null;
|
||||||
|
|
||||||
|
const style = RATING_STYLES[rating] ?? DEFAULT_STYLE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '2px var(--space-2)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rating}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Picker Component ──
|
||||||
|
|
||||||
|
interface RatingPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (rating: string | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Compact mode for inline use in tables */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RatingPicker({ value, onChange, disabled, compact }: RatingPickerProps) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Content rating"
|
||||||
|
style={{
|
||||||
|
padding: compact ? '2px 6px' : 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: compact ? 'var(--font-size-xs)' : 'var(--font-size-sm)',
|
||||||
|
minWidth: compact ? 70 : 100,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">No Rating</option>
|
||||||
|
<optgroup label="Movie Ratings">
|
||||||
|
<option value="G">G</option>
|
||||||
|
<option value="PG">PG</option>
|
||||||
|
<option value="PG-13">PG-13</option>
|
||||||
|
<option value="R">R</option>
|
||||||
|
<option value="NC-17">NC-17</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="TV Ratings">
|
||||||
|
<option value="TV-Y">TV-Y</option>
|
||||||
|
<option value="TV-G">TV-G</option>
|
||||||
|
<option value="TV-PG">TV-PG</option>
|
||||||
|
<option value="TV-14">TV-14</option>
|
||||||
|
<option value="TV-MA">TV-MA</option>
|
||||||
|
</optgroup>
|
||||||
|
<option value="NR">NR (Not Rated)</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,10 +8,12 @@ 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' },
|
||||||
|
|
@ -24,6 +26,7 @@ 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';
|
||||||
|
|
@ -129,6 +132,38 @@ 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={{
|
||||||
|
|
@ -163,6 +198,8 @@ export function Sidebar() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddUrlModal open={showAddUrl} onClose={() => setShowAddUrl(false)} />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ const STATUS_STYLES: Record<string, BadgeStyle> = {
|
||||||
downloading: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
|
downloading: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
|
||||||
failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-bg)' },
|
failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-bg)' },
|
||||||
queued: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
queued: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
||||||
|
missing: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
||||||
ignored: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
ignored: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
||||||
// Queue statuses
|
// Queue statuses
|
||||||
pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
|
||||||
completed: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' },
|
completed: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' },
|
||||||
|
paused: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
|
||||||
cancelled: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
cancelled: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
|
||||||
// Check statuses
|
// Check statuses
|
||||||
success: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' },
|
success: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' },
|
||||||
|
|
|
||||||
21
src/frontend/src/hooks/useTheme.ts
Normal file
21
src/frontend/src/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAppSettings } from '../api/hooks/useSystem';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the user-configured theme from app settings and applies it
|
||||||
|
* to the document element. Falls back to localStorage (set by the
|
||||||
|
* inline script in index.html) then 'dark'.
|
||||||
|
*
|
||||||
|
* Call once near the app root so theme stays in sync across all pages.
|
||||||
|
*/
|
||||||
|
export function useTheme(): 'dark' | 'light' {
|
||||||
|
const { data: settings } = useAppSettings();
|
||||||
|
const theme: 'dark' | 'light' = settings?.theme ?? (localStorage.getItem('tubearr-theme') as 'dark' | 'light') ?? 'dark';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
localStorage.setItem('tubearr-theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
13
src/frontend/src/hooks/useTimezone.ts
Normal file
13
src/frontend/src/hooks/useTimezone.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { useAppSettings } from '../api/hooks/useSystem';
|
||||||
|
|
||||||
|
/** Browser's local timezone as fallback. */
|
||||||
|
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user-configured timezone from app settings,
|
||||||
|
* falling back to the browser's timezone if settings haven't loaded yet.
|
||||||
|
*/
|
||||||
|
export function useTimezone(): string {
|
||||||
|
const { data: settings } = useAppSettings();
|
||||||
|
return settings?.timezone || BROWSER_TIMEZONE;
|
||||||
|
}
|
||||||
|
|
@ -6,22 +6,10 @@ import { Pagination } from '../components/Pagination';
|
||||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||||
import { SkeletonActivityList } from '../components/Skeleton';
|
import { SkeletonActivityList } from '../components/Skeleton';
|
||||||
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
|
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
|
||||||
import { formatRelativeTime } from '../utils/format';
|
import { formatRelativeTime, formatTimestamp } from '../utils/format';
|
||||||
|
import { useTimezone } from '../hooks/useTimezone';
|
||||||
import type { DownloadHistoryRecord } from '@shared/types/index';
|
import type { DownloadHistoryRecord } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEventType(type: string): string {
|
function formatEventType(type: string): string {
|
||||||
return type
|
return type
|
||||||
.split('_')
|
.split('_')
|
||||||
|
|
@ -58,6 +46,7 @@ const EVENT_TYPES = [
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function ActivityPage() {
|
export function ActivityPage() {
|
||||||
|
const timezone = useTimezone();
|
||||||
const [activeTab, setActiveTab] = useState<'history' | 'recent'>('history');
|
const [activeTab, setActiveTab] = useState<'history' | 'recent'>('history');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({
|
||||||
|
|
@ -176,7 +165,7 @@ export function ActivityPage() {
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatTimestamp(item.createdAt)}
|
{formatTimestamp(item.createdAt, timezone)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
import { usePersistedState } from '../hooks/usePersistedState';
|
import { usePersistedState } from '../hooks/usePersistedState';
|
||||||
import { useBulkSelection } from '../hooks/useBulkSelection';
|
import { useBulkSelection } from '../hooks/useBulkSelection';
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Filter,
|
||||||
Film,
|
Film,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
|
|
@ -26,12 +27,13 @@ import {
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels';
|
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels';
|
||||||
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, 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 { 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';
|
||||||
|
import { RatingBadge, RatingPicker } from '../components/RatingBadge';
|
||||||
import { QualityLabel } from '../components/QualityLabel';
|
import { QualityLabel } from '../components/QualityLabel';
|
||||||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
||||||
|
|
@ -42,7 +44,8 @@ import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext';
|
||||||
import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount } from '../utils/format';
|
import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount, formatLocalDateTime } from '../utils/format';
|
||||||
|
import { useTimezone } from '../hooks/useTimezone';
|
||||||
import type { ContentItem, MonitoringMode } from '@shared/types/index';
|
import type { ContentItem, MonitoringMode } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
@ -68,25 +71,47 @@ const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function ChannelDetail() {
|
export function ChannelDetail() {
|
||||||
|
const timezone = useTimezone();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const channelId = parseInt(id ?? '0', 10);
|
const channelId = parseInt(id ?? '0', 10);
|
||||||
|
|
||||||
// ── Data hooks ──
|
// ── Data hooks ──
|
||||||
const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId);
|
const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId);
|
||||||
const { data: formatProfiles } = useFormatProfiles();
|
const { data: formatProfiles } = useFormatProfiles();
|
||||||
const { data: playlistData } = useChannelPlaylists(channelId);
|
const { data: playlistData } = useChannelPlaylists(channelId);
|
||||||
|
const { data: contentTypeCounts } = useContentTypeCounts(channelId);
|
||||||
|
|
||||||
|
// ── Content type tab (URL-driven) ──
|
||||||
|
const activeTab = searchParams.get('tab') ?? 'all';
|
||||||
|
|
||||||
// ── Content pagination state ──
|
// ── Content pagination state ──
|
||||||
const [contentPage, setContentPage] = useState(1);
|
const [contentPage, setContentPage] = useState(1);
|
||||||
|
|
||||||
|
const setActiveTab = useCallback((tab: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (tab === 'all') {
|
||||||
|
next.delete('tab');
|
||||||
|
} else {
|
||||||
|
next.set('tab', tab);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
setContentPage(1);
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const [contentSearch, setContentSearch] = useState('');
|
const [contentSearch, setContentSearch] = useState('');
|
||||||
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
const [contentStatusFilter, setContentStatusFilter] = useState('');
|
||||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
|
||||||
const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null);
|
const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null);
|
||||||
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');
|
||||||
|
|
||||||
|
// Derive contentType filter from active tab
|
||||||
|
const contentTypeFilter = activeTab === 'all' ? '' : activeTab;
|
||||||
|
|
||||||
const contentFilters: ChannelContentFilters = useMemo(() => ({
|
const contentFilters: ChannelContentFilters = useMemo(() => ({
|
||||||
page: contentPage,
|
page: contentPage,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
|
|
@ -116,6 +141,7 @@ export function ChannelDetail() {
|
||||||
const toggleMonitored = useToggleMonitored(channelId);
|
const toggleMonitored = useToggleMonitored(channelId);
|
||||||
const refreshPlaylists = useRefreshPlaylists(channelId);
|
const refreshPlaylists = useRefreshPlaylists(channelId);
|
||||||
const bulkMonitored = useBulkMonitored(channelId);
|
const bulkMonitored = useBulkMonitored(channelId);
|
||||||
|
const updateContentRating = useUpdateContentRating(channelId);
|
||||||
|
|
||||||
// ── Scan state (WebSocket-driven) ──
|
// ── Scan state (WebSocket-driven) ──
|
||||||
const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId);
|
const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId);
|
||||||
|
|
@ -126,6 +152,10 @@ export function ChannelDetail() {
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
||||||
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
||||||
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
|
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
|
||||||
|
const [localIncludeKeywords, setLocalIncludeKeywords] = useState('');
|
||||||
|
const [localExcludeKeywords, setLocalExcludeKeywords] = useState('');
|
||||||
|
const [keywordsSaved, setKeywordsSaved] = useState(false);
|
||||||
|
const [showKeywordFilters, setShowKeywordFilters] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// ── Collapsible header ──
|
// ── Collapsible header ──
|
||||||
|
|
@ -153,6 +183,18 @@ export function ChannelDetail() {
|
||||||
}
|
}
|
||||||
}, [channel?.checkInterval]);
|
}, [channel?.checkInterval]);
|
||||||
|
|
||||||
|
// Sync local keyword fields from channel data
|
||||||
|
useEffect(() => {
|
||||||
|
if (channel) {
|
||||||
|
setLocalIncludeKeywords(channel.includeKeywords ?? '');
|
||||||
|
setLocalExcludeKeywords(channel.excludeKeywords ?? '');
|
||||||
|
// Auto-expand if filters are already set
|
||||||
|
if (channel.includeKeywords || channel.excludeKeywords) {
|
||||||
|
setShowKeywordFilters(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [channel?.includeKeywords, channel?.excludeKeywords]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Surface download errors via toast
|
// Surface download errors via toast
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (downloadContent.isError) {
|
if (downloadContent.isError) {
|
||||||
|
|
@ -188,6 +230,32 @@ export function ChannelDetail() {
|
||||||
);
|
);
|
||||||
}, [localCheckInterval, updateChannel]);
|
}, [localCheckInterval, updateChannel]);
|
||||||
|
|
||||||
|
const handleKeywordsSave = useCallback(() => {
|
||||||
|
// Convert newlines to pipes for storage, preserving pipes inside /regex/ patterns
|
||||||
|
const toStored = (raw: string): string | null => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
// Replace newlines with pipe separators
|
||||||
|
return trimmed
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.join('|');
|
||||||
|
};
|
||||||
|
const include = toStored(localIncludeKeywords);
|
||||||
|
const exclude = toStored(localExcludeKeywords);
|
||||||
|
updateChannel.mutate(
|
||||||
|
{ includeKeywords: include, excludeKeywords: exclude },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setKeywordsSaved(true);
|
||||||
|
setTimeout(() => setKeywordsSaved(false), 2500);
|
||||||
|
toast('Keyword filters saved', 'success');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [localIncludeKeywords, localExcludeKeywords, updateChannel, toast]);
|
||||||
|
|
||||||
const handleMonitoringModeChange = useCallback(
|
const handleMonitoringModeChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setMonitoringMode.mutate({ monitoringMode: e.target.value });
|
setMonitoringMode.mutate({ monitoringMode: e.target.value });
|
||||||
|
|
@ -568,6 +636,19 @@ export function ChannelDetail() {
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (item) => <ContentStatusCell item={item} />,
|
render: (item) => <ContentStatusCell item={item} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'contentRating',
|
||||||
|
label: 'Rating',
|
||||||
|
width: '90px',
|
||||||
|
render: (item) => (
|
||||||
|
<RatingPicker
|
||||||
|
value={item.contentRating}
|
||||||
|
onChange={(rating) => updateContentRating.mutate({ contentId: item.id, contentRating: rating })}
|
||||||
|
disabled={updateContentRating.isPending}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'quality',
|
key: 'quality',
|
||||||
label: 'Quality',
|
label: 'Quality',
|
||||||
|
|
@ -583,7 +664,7 @@ export function ChannelDetail() {
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<span
|
<span
|
||||||
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
|
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
|
||||||
title={item.publishedAt ?? ''}
|
title={item.publishedAt ? formatLocalDateTime(item.publishedAt, timezone) : ''}
|
||||||
>
|
>
|
||||||
{formatRelativeTime(item.publishedAt)}
|
{formatRelativeTime(item.publishedAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -597,7 +678,7 @@ export function ChannelDetail() {
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<span
|
<span
|
||||||
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
|
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
|
||||||
title={item.downloadedAt ?? ''}
|
title={item.downloadedAt ? formatLocalDateTime(item.downloadedAt, timezone) : ''}
|
||||||
>
|
>
|
||||||
{formatRelativeTime(item.downloadedAt)}
|
{formatRelativeTime(item.downloadedAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -626,7 +707,7 @@ export function ChannelDetail() {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll],
|
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Render helpers ──
|
// ── Render helpers ──
|
||||||
|
|
@ -1197,6 +1278,18 @@ export function ChannelDetail() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content Rating group */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
Rating
|
||||||
|
</span>
|
||||||
|
<RatingPicker
|
||||||
|
value={channel.contentRating}
|
||||||
|
onChange={(rating) => updateChannel.mutate({ contentRating: rating })}
|
||||||
|
disabled={updateChannel.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions group */}
|
{/* Actions group */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
|
@ -1263,6 +1356,164 @@ export function ChannelDetail() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Keyword Filter Section — collapsible */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 'var(--space-4)',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
marginTop: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKeywordFilters((prev) => !prev)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: (channel.includeKeywords || channel.excludeKeywords) ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
aria-expanded={showKeywordFilters}
|
||||||
|
>
|
||||||
|
<Filter size={14} />
|
||||||
|
Keyword Filters
|
||||||
|
{(channel.includeKeywords || channel.excludeKeywords) && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--accent)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(channel.includeKeywords ? 1 : 0) + (channel.excludeKeywords ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showKeywordFilters ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showKeywordFilters && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
marginTop: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="include-keywords"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Include Patterns
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="include-keywords"
|
||||||
|
value={localIncludeKeywords}
|
||||||
|
onChange={(e) => setLocalIncludeKeywords(e.target.value)}
|
||||||
|
placeholder={'One pattern per line. Supports:\n• plain text match\n• glob: *livestream*\n• regex: /^ep\\d+/'}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: 60,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
||||||
|
Only titles matching at least one pattern will be auto-enqueued
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="exclude-keywords"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Exclude Patterns
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="exclude-keywords"
|
||||||
|
value={localExcludeKeywords}
|
||||||
|
onChange={(e) => setLocalExcludeKeywords(e.target.value)}
|
||||||
|
placeholder={'One pattern per line. Supports:\n• plain text: shorts\n• glob: *#shorts*\n• regex: /\\bshorts?\\b/'}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: 60,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
||||||
|
Titles matching any pattern will be skipped during scan
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleKeywordsSave}
|
||||||
|
disabled={updateChannel.isPending}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
opacity: updateChannel.isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateChannel.isPending ? (
|
||||||
|
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
) : keywordsSaved ? (
|
||||||
|
<CheckCircle size={14} style={{ color: 'var(--success)' }} />
|
||||||
|
) : (
|
||||||
|
<Save size={14} />
|
||||||
|
)}
|
||||||
|
{keywordsSaved ? 'Saved!' : 'Save Filters'}
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
||||||
|
Use pipe <code style={{ backgroundColor: 'var(--bg-hover)', padding: '1px 4px', borderRadius: 'var(--radius-sm)' }}>|</code> to separate multiple patterns on one line, or put each on its own line
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1280,11 +1531,163 @@ export function ChannelDetail() {
|
||||||
padding: 'var(--space-4) var(--space-5)',
|
padding: 'var(--space-4) var(--space-5)',
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
gap: 'var(--space-3)',
|
gap: 'var(--space-3)',
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Content type tabs */}
|
||||||
|
{contentTypeCounts && (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Content type filter"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
paddingBottom: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* All tab */}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'all'}
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'all' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'all' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'all' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'all' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'all' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.video + contentTypeCounts.audio + contentTypeCounts.livestream}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{contentTypeCounts.video > 0 && (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'video'}
|
||||||
|
onClick={() => setActiveTab('video')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'video' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'video' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'video' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'video' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Film size={14} />
|
||||||
|
Videos
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'video' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.video}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{contentTypeCounts.livestream > 0 && (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'livestream'}
|
||||||
|
onClick={() => setActiveTab('livestream')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'livestream' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'livestream' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'livestream' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'livestream' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg>
|
||||||
|
Streams
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'livestream' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.livestream}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{contentTypeCounts.audio > 0 && (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'audio'}
|
||||||
|
onClick={() => setActiveTab('audio')}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: activeTab === 'audio' ? 600 : 400,
|
||||||
|
backgroundColor: activeTab === 'audio' ? 'var(--accent)' : 'transparent',
|
||||||
|
color: activeTab === 'audio' ? '#fff' : 'var(--text-secondary)',
|
||||||
|
border: activeTab === 'audio' ? 'none' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Music size={14} />
|
||||||
|
Audio
|
||||||
|
<span style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: activeTab === 'audio' ? 'rgba(255,255,255,0.2)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{contentTypeCounts.audio}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Toolbar row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
fontSize: 'var(--font-size-md)',
|
fontSize: 'var(--font-size-md)',
|
||||||
|
|
@ -1337,23 +1740,6 @@ export function ChannelDetail() {
|
||||||
<option value="failed">Failed</option>
|
<option value="failed">Failed</option>
|
||||||
<option value="ignored">Ignored</option>
|
<option value="ignored">Ignored</option>
|
||||||
</select>
|
</select>
|
||||||
{/* Type filter */}
|
|
||||||
<select
|
|
||||||
value={contentTypeFilter}
|
|
||||||
onChange={(e) => { setContentTypeFilter(e.target.value); setContentPage(1); }}
|
|
||||||
aria-label="Filter by type"
|
|
||||||
style={{
|
|
||||||
padding: 'var(--space-2) var(--space-3)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
minWidth: 90,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">All Types</option>
|
|
||||||
<option value="video">Video</option>
|
|
||||||
<option value="audio">Audio</option>
|
|
||||||
<option value="livestream">Livestream</option>
|
|
||||||
</select>
|
|
||||||
{/* View mode segmented control */}
|
{/* View mode segmented control */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1424,6 +1810,7 @@ export function ChannelDetail() {
|
||||||
<LayoutList size={16} />
|
<LayoutList size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Sort & Group controls */}
|
{/* Sort & Group controls */}
|
||||||
<SortGroupBar
|
<SortGroupBar
|
||||||
|
|
@ -1504,12 +1891,12 @@ export function ChannelDetail() {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 'var(--space-3)',
|
gap: 'var(--space-3)',
|
||||||
padding: 'var(--space-3) var(--space-5)',
|
padding: 'var(--space-3) var(--space-5)',
|
||||||
backgroundColor: 'rgba(30, 32, 40, 0.75)',
|
backgroundColor: 'var(--glass-bg)',
|
||||||
backdropFilter: 'blur(16px) saturate(1.4)',
|
backdropFilter: 'blur(16px) saturate(1.4)',
|
||||||
WebkitBackdropFilter: 'blur(16px) saturate(1.4)',
|
WebkitBackdropFilter: 'blur(16px) saturate(1.4)',
|
||||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
border: '1px solid var(--glass-border)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-xl)',
|
||||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.35), inset 0 0.5px 0 rgba(255, 255, 255, 0.06)',
|
boxShadow: 'var(--shadow-md)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Library as LibraryIcon, RefreshCw, Film, Music } from 'lucide-react';
|
import { Library as LibraryIcon, RefreshCw, Film, Music, RotateCcw } 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 { QualityLabel } from '../components/QualityLabel';
|
import { QualityLabel } from '../components/QualityLabel';
|
||||||
|
|
@ -9,7 +9,7 @@ import { Pagination } from '../components/Pagination';
|
||||||
import { SearchBar } from '../components/SearchBar';
|
import { SearchBar } from '../components/SearchBar';
|
||||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||||
import { SkeletonLibrary } from '../components/Skeleton';
|
import { SkeletonLibrary } from '../components/Skeleton';
|
||||||
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
import { useLibraryContent, useRequeueContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
||||||
import { useChannels } from '../api/hooks/useChannels';
|
import { useChannels } from '../api/hooks/useChannels';
|
||||||
import { formatDuration, formatFileSize } from '../utils/format';
|
import { formatDuration, formatFileSize } from '../utils/format';
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||||
|
|
@ -44,6 +44,7 @@ export function Library() {
|
||||||
// Queries
|
// Queries
|
||||||
const { data, isLoading, error, refetch } = useLibraryContent(filters);
|
const { data, isLoading, error, refetch } = useLibraryContent(filters);
|
||||||
const { data: channels } = useChannels();
|
const { data: channels } = useChannels();
|
||||||
|
const requeue = useRequeueContent();
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
// Reset to page 1 when filters change
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
|
@ -80,6 +81,7 @@ export function Library() {
|
||||||
{ value: 'downloading', label: 'Downloading' },
|
{ value: 'downloading', label: 'Downloading' },
|
||||||
{ value: 'downloaded', label: 'Downloaded' },
|
{ value: 'downloaded', label: 'Downloaded' },
|
||||||
{ value: 'failed', label: 'Failed' },
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
{ value: 'missing', label: 'Missing' },
|
||||||
{ value: 'ignored', label: 'Ignored' },
|
{ value: 'ignored', label: 'Ignored' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -260,8 +262,30 @@ export function Library() {
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '',
|
||||||
|
width: '110px',
|
||||||
|
render: (item) =>
|
||||||
|
item.status === 'missing' ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
requeue.mutate(item.id);
|
||||||
|
}}
|
||||||
|
disabled={requeue.isPending}
|
||||||
|
className="btn btn-warning"
|
||||||
|
style={{ fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)', gap: 'var(--space-1)' }}
|
||||||
|
title="Re-download this item"
|
||||||
|
aria-label={`Re-download ${item.title}`}
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
Re-download
|
||||||
|
</button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[channels, navigate],
|
[channels, navigate, requeue],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract pagination from response
|
// Extract pagination from response
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ 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 } from '../api/hooks/useQueue';
|
||||||
|
import { formatShortDateTime } from '../utils/format';
|
||||||
|
import { useTimezone } from '../hooks/useTimezone';
|
||||||
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
function formatTime(iso: string | null): string {
|
function formatTime(iso: string | null, timezone: string): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -19,7 +21,7 @@ function formatTime(iso: string | null): string {
|
||||||
if (diffMs < 60_000) return 'just now';
|
if (diffMs < 60_000) return 'just now';
|
||||||
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
||||||
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`;
|
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`;
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
return formatShortDateTime(iso, timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status Tab Options ──
|
// ── Status Tab Options ──
|
||||||
|
|
@ -28,6 +30,7 @@ const STATUS_TABS: { value: QueueStatus | ''; label: string }[] = [
|
||||||
{ value: '', label: 'All' },
|
{ value: '', label: 'All' },
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
{ value: 'downloading', label: 'Downloading' },
|
{ value: 'downloading', label: 'Downloading' },
|
||||||
|
{ value: 'paused', label: 'Paused' },
|
||||||
{ value: 'completed', label: 'Completed' },
|
{ value: 'completed', label: 'Completed' },
|
||||||
{ value: 'failed', label: 'Failed' },
|
{ value: 'failed', label: 'Failed' },
|
||||||
];
|
];
|
||||||
|
|
@ -47,6 +50,7 @@ function QueueItemProgress({ item }: { item: QueueItem }) {
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Queue() {
|
export function Queue() {
|
||||||
|
const timezone = useTimezone();
|
||||||
const [statusFilter, setStatusFilter] = useState<QueueStatus | ''>('');
|
const [statusFilter, setStatusFilter] = useState<QueueStatus | ''>('');
|
||||||
|
|
||||||
// Query with 5s auto-refresh
|
// Query with 5s auto-refresh
|
||||||
|
|
@ -144,7 +148,7 @@ export function Queue() {
|
||||||
width: '110px',
|
width: '110px',
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
||||||
{formatTime(item.startedAt)}
|
{formatTime(item.startedAt, timezone)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -154,16 +158,46 @@ export function Queue() {
|
||||||
width: '110px',
|
width: '110px',
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
||||||
{formatTime(item.completedAt)}
|
{formatTime(item.completedAt, timezone)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
width: '100px',
|
width: '120px',
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-1)' }}>
|
<div style={{ display: 'flex', gap: 'var(--space-1)' }}>
|
||||||
|
{(item.status === 'pending' || item.status === 'downloading') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
pauseMutation.mutate(item.id);
|
||||||
|
}}
|
||||||
|
disabled={pauseMutation.isPending}
|
||||||
|
title="Pause"
|
||||||
|
aria-label="Pause item"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ color: 'var(--info)' }}
|
||||||
|
>
|
||||||
|
<Pause size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.status === 'paused' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
resumeMutation.mutate(item.id);
|
||||||
|
}}
|
||||||
|
disabled={resumeMutation.isPending}
|
||||||
|
title="Resume"
|
||||||
|
aria-label="Resume paused item"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ color: 'var(--success)' }}
|
||||||
|
>
|
||||||
|
<Play size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{item.status === 'failed' && (
|
{item.status === 'failed' && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -179,7 +213,7 @@ export function Queue() {
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{item.status === 'pending' && (
|
{(item.status === 'pending' || item.status === 'paused') && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -187,7 +221,7 @@ export function Queue() {
|
||||||
}}
|
}}
|
||||||
disabled={cancelMutation.isPending}
|
disabled={cancelMutation.isPending}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
aria-label="Cancel pending item"
|
aria-label="Cancel item"
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
style={{ color: 'var(--danger)' }}
|
style={{ color: 'var(--danger)' }}
|
||||||
>
|
>
|
||||||
|
|
@ -289,7 +323,7 @@ export function Queue() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mutation errors */}
|
{/* Mutation errors */}
|
||||||
{(retryMutation.error || cancelMutation.error) && (
|
{(retryMutation.error || cancelMutation.error || pauseMutation.error || resumeMutation.error) && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: 'var(--space-3)',
|
padding: 'var(--space-3)',
|
||||||
|
|
@ -306,7 +340,11 @@ export function Queue() {
|
||||||
? retryMutation.error.message
|
? retryMutation.error.message
|
||||||
: cancelMutation.error instanceof Error
|
: cancelMutation.error instanceof Error
|
||||||
? cancelMutation.error.message
|
? cancelMutation.error.message
|
||||||
: 'Action failed'}
|
: pauseMutation.error instanceof Error
|
||||||
|
? pauseMutation.error.message
|
||||||
|
: resumeMutation.error instanceof Error
|
||||||
|
? resumeMutation.error.message
|
||||||
|
: 'Action failed'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree, Server, Sun, Moon, Clock } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFormatProfiles,
|
useFormatProfiles,
|
||||||
useCreateFormatProfile,
|
useCreateFormatProfile,
|
||||||
|
|
@ -20,11 +20,20 @@ import {
|
||||||
type NotificationSetting,
|
type NotificationSetting,
|
||||||
} from '../api/hooks/useNotifications';
|
} from '../api/hooks/useNotifications';
|
||||||
import { useApiKey, useRegenerateApiKey, useAppSettings, useUpdateAppSettings } from '../api/hooks/useSystem';
|
import { useApiKey, useRegenerateApiKey, useAppSettings, useUpdateAppSettings } from '../api/hooks/useSystem';
|
||||||
|
import {
|
||||||
|
useMediaServers,
|
||||||
|
useCreateMediaServer,
|
||||||
|
useUpdateMediaServer,
|
||||||
|
useDeleteMediaServer,
|
||||||
|
useTestMediaServer,
|
||||||
|
type MediaServer,
|
||||||
|
} from '../api/hooks/useMediaServers';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
||||||
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
||||||
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
||||||
|
import { MediaServerForm, type MediaServerFormValues } from '../components/MediaServerForm';
|
||||||
import { SkeletonSettings } from '../components/Skeleton';
|
import { SkeletonSettings } from '../components/Skeleton';
|
||||||
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
||||||
|
|
||||||
|
|
@ -78,34 +87,116 @@ export function SettingsPage() {
|
||||||
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
|
||||||
|
// ── Media Servers state ──
|
||||||
|
const { data: mediaServers, isLoading: mediaServersLoading } = useMediaServers();
|
||||||
|
const createMediaServerMutation = useCreateMediaServer();
|
||||||
|
const updateMediaServerMutation = useUpdateMediaServer();
|
||||||
|
const deleteMediaServerMutation = useDeleteMediaServer();
|
||||||
|
const testMediaServerMutation = useTestMediaServer();
|
||||||
|
|
||||||
|
const [showCreateMediaServerModal, setShowCreateMediaServerModal] = useState(false);
|
||||||
|
const [editingMediaServer, setEditingMediaServer] = useState<MediaServer | null>(null);
|
||||||
|
const [deletingMediaServer, setDeletingMediaServer] = useState<MediaServer | null>(null);
|
||||||
|
const [mediaServerTestResults, setMediaServerTestResults] = useState<Record<number, 'success' | 'error' | 'loading' | null>>({});
|
||||||
|
|
||||||
// ── App Settings state ──
|
// ── App Settings state ──
|
||||||
const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings();
|
const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings();
|
||||||
const updateAppSettingsMutation = useUpdateAppSettings();
|
const updateAppSettingsMutation = useUpdateAppSettings();
|
||||||
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 [nfoEnabled, setNfoEnabled] = useState(false);
|
||||||
|
const [timezone, setTimezone] = useState('UTC');
|
||||||
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||||
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
|
||||||
|
|
||||||
|
// Timezone options — computed once
|
||||||
|
const timezoneOptions = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return Intl.supportedValuesOf('timeZone');
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
return ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney'];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Timezone search filter
|
||||||
|
const [tzSearch, setTzSearch] = useState('');
|
||||||
|
const filteredTimezones = useMemo(() => {
|
||||||
|
if (!tzSearch) return timezoneOptions;
|
||||||
|
const lower = tzSearch.toLowerCase();
|
||||||
|
return timezoneOptions.filter(tz => tz.toLowerCase().includes(lower));
|
||||||
|
}, [timezoneOptions, tzSearch]);
|
||||||
|
|
||||||
// Initialize local state from fetched app settings
|
// Initialize local state from fetched app settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appSettings) {
|
if (appSettings) {
|
||||||
setCheckInterval(appSettings.checkInterval);
|
setCheckInterval(appSettings.checkInterval);
|
||||||
setConcurrentDownloads(appSettings.concurrentDownloads);
|
setConcurrentDownloads(appSettings.concurrentDownloads);
|
||||||
|
setOutputTemplate(appSettings.outputTemplate);
|
||||||
|
setNfoEnabled(appSettings.nfoEnabled);
|
||||||
|
setTimezone(appSettings.timezone);
|
||||||
|
setTheme(appSettings.theme);
|
||||||
}
|
}
|
||||||
}, [appSettings]);
|
}, [appSettings]);
|
||||||
|
|
||||||
|
// Apply theme preview immediately when user toggles (before save).
|
||||||
|
// The App-level useTheme hook reconciles with the API value after save.
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
localStorage.setItem('tubearr-theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
const settingsDirty =
|
const settingsDirty =
|
||||||
checkInterval !== '' &&
|
checkInterval !== '' &&
|
||||||
concurrentDownloads !== '' &&
|
concurrentDownloads !== '' &&
|
||||||
appSettings != null &&
|
appSettings != null &&
|
||||||
(Number(checkInterval) !== appSettings.checkInterval ||
|
(Number(checkInterval) !== appSettings.checkInterval ||
|
||||||
Number(concurrentDownloads) !== appSettings.concurrentDownloads);
|
Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
|
||||||
|
outputTemplate !== appSettings.outputTemplate ||
|
||||||
|
nfoEnabled !== appSettings.nfoEnabled ||
|
||||||
|
timezone !== appSettings.timezone ||
|
||||||
|
theme !== appSettings.theme);
|
||||||
|
|
||||||
const settingsValid =
|
const settingsValid =
|
||||||
checkInterval !== '' &&
|
checkInterval !== '' &&
|
||||||
concurrentDownloads !== '' &&
|
concurrentDownloads !== '' &&
|
||||||
Number(checkInterval) >= 1 &&
|
Number(checkInterval) >= 1 &&
|
||||||
Number(concurrentDownloads) >= 1 &&
|
Number(concurrentDownloads) >= 1 &&
|
||||||
Number(concurrentDownloads) <= 10;
|
Number(concurrentDownloads) <= 10 &&
|
||||||
|
outputTemplate.trim().length > 0 &&
|
||||||
|
outputTemplate.includes('{ext}');
|
||||||
|
|
||||||
|
// ── Template preview ──
|
||||||
|
const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const;
|
||||||
|
|
||||||
|
const templatePreview = useMemo(() => {
|
||||||
|
const exampleVars: Record<string, string> = {
|
||||||
|
platform: 'youtube',
|
||||||
|
channel: 'TechChannel',
|
||||||
|
title: 'How to Build a Server',
|
||||||
|
date: '2026-04-04',
|
||||||
|
year: '2026',
|
||||||
|
month: '04',
|
||||||
|
contentType: 'video',
|
||||||
|
id: 'dQw4w9WgXcQ',
|
||||||
|
ext: 'mp4',
|
||||||
|
};
|
||||||
|
return outputTemplate.replace(/\{([a-zA-Z]+)\}/g, (_m, v: string) => exampleVars[v] ?? `{${v}}`);
|
||||||
|
}, [outputTemplate]);
|
||||||
|
|
||||||
|
const templateErrors = useMemo(() => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (outputTemplate.trim().length === 0) return ['Template must not be empty'];
|
||||||
|
if (!outputTemplate.includes('{ext}')) errors.push('Must contain {ext}');
|
||||||
|
const matches = [...outputTemplate.matchAll(/\{([a-zA-Z]+)\}/g)];
|
||||||
|
for (const m of matches) {
|
||||||
|
if (!(TEMPLATE_VARIABLES as readonly string[]).includes(m[1])) {
|
||||||
|
errors.push(`Unknown variable: {${m[1]}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}, [outputTemplate]);
|
||||||
|
|
||||||
// ── App Settings handlers ──
|
// ── App Settings handlers ──
|
||||||
|
|
||||||
|
|
@ -115,6 +206,10 @@ export function SettingsPage() {
|
||||||
{
|
{
|
||||||
checkInterval: Number(checkInterval),
|
checkInterval: Number(checkInterval),
|
||||||
concurrentDownloads: Number(concurrentDownloads),
|
concurrentDownloads: Number(concurrentDownloads),
|
||||||
|
outputTemplate,
|
||||||
|
nfoEnabled,
|
||||||
|
timezone,
|
||||||
|
theme,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -125,6 +220,67 @@ export function SettingsPage() {
|
||||||
);
|
);
|
||||||
}, [settingsDirty, settingsValid, checkInterval, concurrentDownloads, updateAppSettingsMutation]);
|
}, [settingsDirty, settingsValid, checkInterval, concurrentDownloads, updateAppSettingsMutation]);
|
||||||
|
|
||||||
|
// ── Media Server handlers ──
|
||||||
|
|
||||||
|
const handleCreateMediaServer = useCallback(
|
||||||
|
(values: MediaServerFormValues) => {
|
||||||
|
createMediaServerMutation.mutate(values, {
|
||||||
|
onSuccess: () => setShowCreateMediaServerModal(false),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[createMediaServerMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateMediaServer = useCallback(
|
||||||
|
(values: MediaServerFormValues) => {
|
||||||
|
if (!editingMediaServer) return;
|
||||||
|
// Only send token if user entered a new one
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
id: editingMediaServer.id,
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
url: values.url,
|
||||||
|
librarySection: values.librarySection,
|
||||||
|
enabled: values.enabled,
|
||||||
|
};
|
||||||
|
if (values.token) {
|
||||||
|
payload.token = values.token;
|
||||||
|
}
|
||||||
|
updateMediaServerMutation.mutate(payload as Parameters<typeof updateMediaServerMutation.mutate>[0], {
|
||||||
|
onSuccess: () => setEditingMediaServer(null),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editingMediaServer, updateMediaServerMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteMediaServer = useCallback(() => {
|
||||||
|
if (!deletingMediaServer) return;
|
||||||
|
deleteMediaServerMutation.mutate(deletingMediaServer.id, {
|
||||||
|
onSuccess: () => setDeletingMediaServer(null),
|
||||||
|
});
|
||||||
|
}, [deletingMediaServer, deleteMediaServerMutation]);
|
||||||
|
|
||||||
|
const handleTestMediaServer = useCallback(
|
||||||
|
(id: number) => {
|
||||||
|
setMediaServerTestResults((prev) => ({ ...prev, [id]: 'loading' }));
|
||||||
|
testMediaServerMutation.mutate(id, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setMediaServerTestResults((prev) => ({ ...prev, [id]: data.success ? 'success' : 'error' }));
|
||||||
|
setTimeout(() => {
|
||||||
|
setMediaServerTestResults((prev) => ({ ...prev, [id]: null }));
|
||||||
|
}, 4000);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setMediaServerTestResults((prev) => ({ ...prev, [id]: 'error' }));
|
||||||
|
setTimeout(() => {
|
||||||
|
setMediaServerTestResults((prev) => ({ ...prev, [id]: null }));
|
||||||
|
}, 4000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[testMediaServerMutation],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Format Profile handlers ──
|
// ── Format Profile handlers ──
|
||||||
|
|
||||||
const handleCreateProfile = useCallback(
|
const handleCreateProfile = useCallback(
|
||||||
|
|
@ -482,6 +638,118 @@ export function SettingsPage() {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Media Server columns ──
|
||||||
|
|
||||||
|
const mediaServerColumns = useMemo<Column<MediaServer>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
render: (s) => (
|
||||||
|
<span style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{s.name}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
width: '100px',
|
||||||
|
render: (s) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...badgeBase,
|
||||||
|
color: s.type === 'plex' ? '#e5a00d' : '#00a4dc',
|
||||||
|
backgroundColor: s.type === 'plex' ? 'rgba(229, 160, 13, 0.1)' : 'rgba(0, 164, 220, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.type === 'plex' ? 'Plex' : 'Jellyfin'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: 'URL',
|
||||||
|
render: (s) => (
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-mono, monospace)' }}>
|
||||||
|
{s.url}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'section',
|
||||||
|
label: 'Library',
|
||||||
|
width: '120px',
|
||||||
|
render: (s) => (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||||||
|
{s.librarySection ?? 'All'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
label: 'Status',
|
||||||
|
width: '90px',
|
||||||
|
render: (s) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...badgeBase,
|
||||||
|
color: s.enabled ? 'var(--success)' : 'var(--text-muted)',
|
||||||
|
backgroundColor: s.enabled ? 'var(--success-bg)' : 'var(--bg-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.enabled ? 'Active' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '',
|
||||||
|
width: '130px',
|
||||||
|
render: (s) => {
|
||||||
|
const result = mediaServerTestResults[s.id];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-2)', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||||
|
{result === 'success' && <CheckCircle size={14} style={{ color: 'var(--success)' }} />}
|
||||||
|
{result === 'error' && <XCircle size={14} style={{ color: 'var(--danger)' }} />}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleTestMediaServer(s.id); }}
|
||||||
|
title="Test connection"
|
||||||
|
aria-label={`Test ${s.name}`}
|
||||||
|
disabled={result === 'loading'}
|
||||||
|
className="btn-icon btn-icon-test"
|
||||||
|
style={{ opacity: result === 'loading' ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{result === 'loading'
|
||||||
|
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
: <Send size={14} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setEditingMediaServer(s); }}
|
||||||
|
title="Edit server"
|
||||||
|
aria-label={`Edit ${s.name}`}
|
||||||
|
className="btn-icon btn-icon-edit"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeletingMediaServer(s); }}
|
||||||
|
title="Delete server"
|
||||||
|
aria-label={`Delete ${s.name}`}
|
||||||
|
className="btn-icon btn-icon-delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[mediaServerTestResults, handleTestMediaServer],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Notification columns ──
|
// ── Notification columns ──
|
||||||
|
|
||||||
const notificationColumns = useMemo<Column<NotificationSetting>[]>(
|
const notificationColumns = useMemo<Column<NotificationSetting>[]>(
|
||||||
|
|
@ -798,6 +1066,116 @@ export function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{/* Timezone selector */}
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ width: '200px', padding: 'var(--space-4)', fontWeight: 500, color: 'var(--text-primary)', verticalAlign: 'middle' }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<Clock size={14} />
|
||||||
|
Timezone
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: 'var(--space-4)' }}>
|
||||||
|
{appSettingsLoading ? (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Loading…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', maxWidth: 320 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tzSearch}
|
||||||
|
onChange={(e) => setTzSearch(e.target.value)}
|
||||||
|
placeholder="Filter timezones…"
|
||||||
|
aria-label="Filter timezone list"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
aria-label="Timezone"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTimezones.map(tz => (
|
||||||
|
<option key={tz} value={tz}>{tz.replace(/_/g, ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<tr>
|
||||||
|
<td style={{ width: '200px', padding: 'var(--space-4)', fontWeight: 500, color: 'var(--text-primary)', verticalAlign: 'middle' }}>
|
||||||
|
Theme
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: 'var(--space-4)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme('dark')}
|
||||||
|
aria-label="Dark theme"
|
||||||
|
aria-pressed={theme === 'dark'}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: `1px solid ${theme === 'dark' ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
|
backgroundColor: theme === 'dark' ? 'var(--accent-subtle)' : 'var(--bg-main)',
|
||||||
|
color: theme === 'dark' ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: theme === 'dark' ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Moon size={14} />
|
||||||
|
Dark
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme('light')}
|
||||||
|
aria-label="Light theme"
|
||||||
|
aria-pressed={theme === 'light'}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: `1px solid ${theme === 'light' ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
|
backgroundColor: theme === 'light' ? 'var(--accent-subtle)' : 'var(--bg-main)',
|
||||||
|
color: theme === 'light' ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: theme === 'light' ? 600 : 400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sun size={14} />
|
||||||
|
Light
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -836,6 +1214,212 @@ export function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── File Organization section ── */}
|
||||||
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
<FolderTree size={20} />
|
||||||
|
File Organization
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', marginTop: 'var(--space-1)' }}>
|
||||||
|
Configure the default output path template for downloaded files. Individual format profiles can override this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Template input */}
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<label
|
||||||
|
htmlFor="output-template"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Output Template
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="output-template"
|
||||||
|
type="text"
|
||||||
|
value={outputTemplate}
|
||||||
|
onChange={(e) => setOutputTemplate(e.target.value)}
|
||||||
|
placeholder="{platform}/{channel}/{title}.{ext}"
|
||||||
|
aria-label="Output path template"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: `1px solid ${templateErrors.length > 0 ? 'var(--danger)' : 'var(--border)'}`,
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{templateErrors.length > 0 && (
|
||||||
|
<div style={{ color: 'var(--danger)', fontSize: 'var(--font-size-xs)', marginTop: 'var(--space-1)' }}>
|
||||||
|
{templateErrors.join('. ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available variables */}
|
||||||
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginBottom: 'var(--space-2)', display: 'block' }}>
|
||||||
|
Available variables
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
|
||||||
|
{TEMPLATE_VARIABLES.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.getElementById('output-template') as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
const start = input.selectionStart ?? outputTemplate.length;
|
||||||
|
const end = input.selectionEnd ?? outputTemplate.length;
|
||||||
|
const newVal = outputTemplate.slice(0, start) + `{${v}}` + outputTemplate.slice(end);
|
||||||
|
setOutputTemplate(newVal);
|
||||||
|
// Restore cursor position after React re-render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
input.focus();
|
||||||
|
const newPos = start + v.length + 2;
|
||||||
|
input.setSelectionRange(newPos, newPos);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOutputTemplate((prev) => prev + `{${v}}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
title={`Insert {${v}} at cursor`}
|
||||||
|
>
|
||||||
|
{`{${v}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live preview */}
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginBottom: 'var(--space-1)', display: 'block' }}>
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-main)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{templatePreview || '—'}
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── Platform Settings section ── */}
|
{/* ── Platform Settings section ── */}
|
||||||
<section style={{ marginBottom: 'var(--space-8)' }}>
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
|
@ -904,6 +1488,58 @@ export function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Media Servers section ── */}
|
||||||
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
<Server size={20} />
|
||||||
|
Media Servers
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', marginTop: 'var(--space-1)' }}>
|
||||||
|
Connect Plex or Jellyfin servers to automatically scan libraries after downloads complete.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateMediaServerModal(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaServersLoading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={16} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-2)' }} />
|
||||||
|
Loading media servers...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={mediaServerColumns}
|
||||||
|
data={mediaServers ?? []}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
emptyMessage="No media servers configured. Add a Plex or Jellyfin server to enable automatic library scans."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── Notifications section ── */}
|
{/* ── Notifications section ── */}
|
||||||
<section style={{ marginBottom: 'var(--space-8)' }}>
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1046,6 +1682,81 @@ export function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Media Server: Create modal ── */}
|
||||||
|
<Modal title="Add Media Server" open={showCreateMediaServerModal} onClose={() => setShowCreateMediaServerModal(false)} width={520}>
|
||||||
|
<MediaServerForm
|
||||||
|
onSubmit={handleCreateMediaServer}
|
||||||
|
onCancel={() => setShowCreateMediaServerModal(false)}
|
||||||
|
isPending={createMediaServerMutation.isPending}
|
||||||
|
error={createMediaServerMutation.error instanceof Error ? createMediaServerMutation.error.message : null}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Media Server: Edit modal ── */}
|
||||||
|
<Modal
|
||||||
|
title={`Edit "${editingMediaServer?.name ?? ''}"`}
|
||||||
|
open={!!editingMediaServer}
|
||||||
|
onClose={() => setEditingMediaServer(null)}
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
{editingMediaServer && (
|
||||||
|
<MediaServerForm
|
||||||
|
server={editingMediaServer}
|
||||||
|
onSubmit={handleUpdateMediaServer}
|
||||||
|
onCancel={() => setEditingMediaServer(null)}
|
||||||
|
isPending={updateMediaServerMutation.isPending}
|
||||||
|
error={updateMediaServerMutation.error instanceof Error ? updateMediaServerMutation.error.message : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Media Server: Delete confirmation ── */}
|
||||||
|
<Modal
|
||||||
|
title="Delete Media Server"
|
||||||
|
open={!!deletingMediaServer}
|
||||||
|
onClose={() => setDeletingMediaServer(null)}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: 'var(--space-5)', lineHeight: 1.6 }}>
|
||||||
|
Are you sure you want to delete <strong style={{ color: 'var(--text-primary)' }}>{deletingMediaServer?.name}</strong>?
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
{deleteMediaServerMutation.error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
backgroundColor: 'var(--danger-bg)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMediaServerMutation.error instanceof Error ? deleteMediaServerMutation.error.message : 'Delete failed'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingMediaServer(null)}
|
||||||
|
disabled={deleteMediaServerMutation.isPending}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteMediaServer}
|
||||||
|
disabled={deleteMediaServerMutation.isPending}
|
||||||
|
className="btn btn-danger"
|
||||||
|
style={{ opacity: deleteMediaServerMutation.isPending ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{deleteMediaServerMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* ── Notification: Create modal ── */}
|
{/* ── Notification: Create modal ── */}
|
||||||
<Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}>
|
<Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}>
|
||||||
<NotificationForm
|
<NotificationForm
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
import { RefreshCw, Server, Activity, Cpu, HardDrive, Search } from 'lucide-react';
|
||||||
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem';
|
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp, useMissingScanStatus, useTriggerMissingScan } from '../api/hooks/useSystem';
|
||||||
import { HealthStatus } from '../components/HealthStatus';
|
import { HealthStatus } from '../components/HealthStatus';
|
||||||
import { SkeletonSystem } from '../components/Skeleton';
|
import { SkeletonSystem } from '../components/Skeleton';
|
||||||
import { formatBytes } from '../utils/format';
|
import { formatBytes, formatLocalDateTime } from '../utils/format';
|
||||||
|
import { useTimezone } from '../hooks/useTimezone';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
|
|
@ -21,10 +22,13 @@ function formatUptime(seconds: number): string {
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function SystemPage() {
|
export function SystemPage() {
|
||||||
|
const timezone = useTimezone();
|
||||||
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
|
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
|
||||||
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
||||||
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
||||||
const updateYtDlp = useUpdateYtDlp();
|
const updateYtDlp = useUpdateYtDlp();
|
||||||
|
const { data: missingScanData } = useMissingScanStatus();
|
||||||
|
const triggerMissingScan = useTriggerMissingScan();
|
||||||
|
|
||||||
const isLoading = healthLoading || statusLoading;
|
const isLoading = healthLoading || statusLoading;
|
||||||
|
|
||||||
|
|
@ -92,6 +96,102 @@ export function SystemPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Missing File Scan section ── */}
|
||||||
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
<Search size={18} style={{ color: 'var(--warning)' }} />
|
||||||
|
Missing File Scan
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerMissingScan.mutate()}
|
||||||
|
disabled={triggerMissingScan.isPending}
|
||||||
|
className="btn btn-warning"
|
||||||
|
>
|
||||||
|
{triggerMissingScan.isPending ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Scanning…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search size={14} />
|
||||||
|
Scan Now
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{triggerMissingScan.data && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: triggerMissingScan.data.data.missing > 0 ? 'var(--warning-bg)' : 'var(--success-bg)',
|
||||||
|
color: triggerMissingScan.data.data.missing > 0 ? 'var(--warning)' : 'var(--success)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scan complete: checked {triggerMissingScan.data.data.checked} files, found {triggerMissingScan.data.data.missing} missing
|
||||||
|
{' '}({(triggerMissingScan.data.data.duration / 1000).toFixed(1)}s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{triggerMissingScan.error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
marginBottom: 'var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger-bg)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scan failed: {triggerMissingScan.error instanceof Error ? triggerMissingScan.error.message : 'Unknown error'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{missingScanData?.data ? (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
<SystemInfoRow
|
||||||
|
icon={<Activity size={14} style={{ color: 'var(--text-muted)' }} />}
|
||||||
|
label="Last Scan"
|
||||||
|
value={formatLocalDateTime(missingScanData.data.lastRun, timezone)}
|
||||||
|
/>
|
||||||
|
<SystemInfoRow
|
||||||
|
icon={<Search size={14} style={{ color: 'var(--info)' }} />}
|
||||||
|
label="Files Checked"
|
||||||
|
value={String(missingScanData.data.result.checked)}
|
||||||
|
/>
|
||||||
|
<SystemInfoRow
|
||||||
|
icon={<HardDrive size={14} style={{ color: missingScanData.data.result.missing > 0 ? 'var(--warning)' : 'var(--success)' }} />}
|
||||||
|
label="Missing Files"
|
||||||
|
value={String(missingScanData.data.result.missing)}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', margin: 0 }}>
|
||||||
|
No scan has been run yet. Click "Scan Now" to check for missing files.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── System Status section ── */}
|
{/* ── System Status section ── */}
|
||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,12 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--bg-selected);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons base ── */
|
/* ── Buttons base ── */
|
||||||
|
|
@ -158,7 +158,7 @@ tr:hover {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--bg-input) 25%,
|
var(--bg-input) 25%,
|
||||||
rgba(255, 255, 255, 0.04) 50%,
|
var(--bg-hover) 50%,
|
||||||
var(--bg-input) 75%
|
var(--bg-input) 75%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
|
|
|
||||||
|
|
@ -97,3 +97,57 @@
|
||||||
--glass-bg: rgba(20, 22, 30, 0.6);
|
--glass-bg: rgba(20, 22, 30, 0.6);
|
||||||
--glass-border: rgba(255, 255, 255, 0.08);
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Light Theme ── */
|
||||||
|
[data-theme="light"] {
|
||||||
|
/* ── Backgrounds ── */
|
||||||
|
--bg-main: #f5f5f8;
|
||||||
|
--bg-sidebar: #ebedf2;
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.9);
|
||||||
|
--bg-card-solid: #ffffff;
|
||||||
|
--bg-input: #ffffff;
|
||||||
|
--bg-hover: rgba(0, 0, 0, 0.04);
|
||||||
|
--bg-selected: rgba(0, 0, 0, 0.06);
|
||||||
|
--bg-header: #f0f1f5;
|
||||||
|
--bg-toolbar: #f0f1f5;
|
||||||
|
--bg-modal-overlay: rgba(0, 0, 0, 0.3);
|
||||||
|
--bg-glass: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
/* ── Accent ── */
|
||||||
|
--accent: #d14836;
|
||||||
|
--accent-hover: #c03c2b;
|
||||||
|
--accent-subtle: rgba(209, 72, 54, 0.08);
|
||||||
|
--accent-glow: rgba(209, 72, 54, 0.15);
|
||||||
|
|
||||||
|
/* ── Text ── */
|
||||||
|
--text-primary: #1a1c24;
|
||||||
|
--text-secondary: #5a5d6b;
|
||||||
|
--text-muted: #9397a5;
|
||||||
|
--text-inverse: #f5f5f8;
|
||||||
|
--text-link: #d14836;
|
||||||
|
|
||||||
|
/* ── Status colors ── */
|
||||||
|
--success: #1e8e3e;
|
||||||
|
--success-bg: rgba(30, 142, 62, 0.08);
|
||||||
|
--warning: #e37400;
|
||||||
|
--warning-bg: rgba(227, 116, 0, 0.08);
|
||||||
|
--danger: #d93025;
|
||||||
|
--danger-bg: rgba(217, 48, 37, 0.08);
|
||||||
|
--info: #d14836;
|
||||||
|
--info-bg: rgba(209, 72, 54, 0.08);
|
||||||
|
|
||||||
|
/* ── Borders ── */
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--border-light: rgba(0, 0, 0, 0.12);
|
||||||
|
--border-accent: rgba(209, 72, 54, 0.25);
|
||||||
|
|
||||||
|
/* ── Shadows ── */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
--shadow-glow: 0 0 20px rgba(209, 72, 54, 0.1);
|
||||||
|
|
||||||
|
/* ── Glassmorphism ── */
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.7);
|
||||||
|
--glass-border: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
*
|
*
|
||||||
* Consolidates format helpers that were previously duplicated across
|
* Consolidates format helpers that were previously duplicated across
|
||||||
* ChannelDetail, Library, ContentCard, ContentListItem, Channels, Activity.
|
* ChannelDetail, Library, ContentCard, ContentListItem, Channels, Activity.
|
||||||
|
*
|
||||||
|
* Timezone-aware helpers accept an IANA timezone string (e.g. "America/New_York").
|
||||||
|
* Pass the value from useTimezone() at the call site.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Format a byte count into a human-readable string (B, KB, MB, GB, TB). */
|
/** Format a byte count into a human-readable string (B, KB, MB, GB, TB). */
|
||||||
|
|
@ -60,3 +63,51 @@ export function formatSubscriberCount(count: number | null): string | null {
|
||||||
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
||||||
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
|
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Timezone-aware formatters ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO date string as a full timestamp in the given timezone.
|
||||||
|
* Example: "Jan 5, 2:30:45 PM"
|
||||||
|
*/
|
||||||
|
export function formatTimestamp(isoString: string | null, timezone: string): string {
|
||||||
|
if (!isoString) return '—';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
if (isNaN(d.getTime())) return '—';
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
timeZone: timezone,
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO date string as a short date+time in the given timezone.
|
||||||
|
* Example: "Jan 5, 02:30 PM"
|
||||||
|
*/
|
||||||
|
export function formatShortDateTime(isoString: string | null, timezone: string): string {
|
||||||
|
if (!isoString) return '—';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
if (isNaN(d.getTime())) return '—';
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
timeZone: timezone,
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO date string as a locale date+time string in the given timezone.
|
||||||
|
* Uses the browser's default locale format. Example: "1/5/2025, 2:30:45 PM"
|
||||||
|
*/
|
||||||
|
export function formatLocalDateTime(isoString: string | null, timezone: string): string {
|
||||||
|
if (!isoString) return '—';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
if (isNaN(d.getTime())) return '—';
|
||||||
|
return d.toLocaleString(undefined, { timeZone: timezone });
|
||||||
|
}
|
||||||
|
|
|
||||||
40
src/index.ts
40
src/index.ts
|
|
@ -19,6 +19,9 @@ import { DownloadEventBus } from './services/event-bus';
|
||||||
import { QueueService } from './services/queue';
|
import { QueueService } from './services/queue';
|
||||||
import { NotificationService } from './services/notification';
|
import { NotificationService } from './services/notification';
|
||||||
import { HealthService } from './services/health';
|
import { HealthService } from './services/health';
|
||||||
|
import { MissingFileScanner } from './services/missing-file-scanner';
|
||||||
|
import { MediaServerService } from './services/media-server';
|
||||||
|
import { getEnabledMediaServers } from './db/repositories/media-server-repository';
|
||||||
import { PlatformRegistry } from './sources/platform-source';
|
import { PlatformRegistry } from './sources/platform-source';
|
||||||
import { YouTubeSource } from './sources/youtube';
|
import { YouTubeSource } from './sources/youtube';
|
||||||
import { SoundCloudSource } from './sources/soundcloud';
|
import { SoundCloudSource } from './sources/soundcloud';
|
||||||
|
|
@ -165,6 +168,43 @@ async function main(): Promise<void> {
|
||||||
);
|
);
|
||||||
(server as { healthService: HealthService | null }).healthService = healthService;
|
(server as { healthService: HealthService | null }).healthService = healthService;
|
||||||
|
|
||||||
|
// 5c-ii. Set up missing file scanner
|
||||||
|
const missingFileScanner = new MissingFileScanner(db);
|
||||||
|
(server as { missingFileScanner: MissingFileScanner | null }).missingFileScanner = missingFileScanner;
|
||||||
|
|
||||||
|
// 5d. Wire automatic media-server scans on download completion
|
||||||
|
const mediaServerService = new MediaServerService();
|
||||||
|
eventBus.onDownload('download:complete', (payload) => {
|
||||||
|
getEnabledMediaServers(db)
|
||||||
|
.then(async (servers) => {
|
||||||
|
if (servers.length === 0) return;
|
||||||
|
console.log(
|
||||||
|
`[media-server] download complete contentItemId=${payload.contentItemId} — triggering ${servers.length} server scan(s)`
|
||||||
|
);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
servers.map((s) => mediaServerService.triggerScan(s))
|
||||||
|
);
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
const s = servers[i];
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
if (r.value.success) {
|
||||||
|
console.log(`[media-server] scan ok server="${s.name}" msg="${r.value.message}"`);
|
||||||
|
} else {
|
||||||
|
console.log(`[media-server] scan failed server="${s.name}" msg="${r.value.message}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[media-server] scan error server="${s.name}" err="${r.reason}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(
|
||||||
|
`[media-server] failed to query enabled servers: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 6. Graceful shutdown handler
|
// 6. Graceful shutdown handler
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`[${APP_NAME}] ${signal} received — shutting down gracefully...`);
|
console.log(`[${APP_NAME}] ${signal} received — shutting down gracefully...`);
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,16 @@ import { platformSettingsRoutes } from './routes/platform-settings';
|
||||||
import { scanRoutes } from './routes/scan';
|
import { scanRoutes } from './routes/scan';
|
||||||
import { collectRoutes } from './routes/collect';
|
import { collectRoutes } from './routes/collect';
|
||||||
import { playlistRoutes } from './routes/playlist';
|
import { playlistRoutes } from './routes/playlist';
|
||||||
|
import { adhocDownloadRoutes } from './routes/adhoc-download';
|
||||||
|
import { mediaServerRoutes } from './routes/media-server';
|
||||||
|
import { feedRoutes } from './routes/feed';
|
||||||
import { websocketRoutes } from './routes/websocket';
|
import { websocketRoutes } from './routes/websocket';
|
||||||
import type { SchedulerService } from '../services/scheduler';
|
import type { SchedulerService } from '../services/scheduler';
|
||||||
import type { DownloadService } from '../services/download';
|
import type { DownloadService } from '../services/download';
|
||||||
import type { QueueService } from '../services/queue';
|
import type { QueueService } from '../services/queue';
|
||||||
import type { HealthService } from '../services/health';
|
import type { HealthService } from '../services/health';
|
||||||
import type { DownloadEventBus } from '../services/event-bus';
|
import type { DownloadEventBus } from '../services/event-bus';
|
||||||
|
import type { MissingFileScanner } from '../services/missing-file-scanner';
|
||||||
import type { ViteDevServer } from 'vite';
|
import type { ViteDevServer } from 'vite';
|
||||||
|
|
||||||
// Extend Fastify's type system so routes can access the database and scheduler
|
// Extend Fastify's type system so routes can access the database and scheduler
|
||||||
|
|
@ -38,6 +42,7 @@ declare module 'fastify' {
|
||||||
downloadService: DownloadService | null;
|
downloadService: DownloadService | null;
|
||||||
queueService: QueueService | null;
|
queueService: QueueService | null;
|
||||||
healthService: HealthService | null;
|
healthService: HealthService | null;
|
||||||
|
missingFileScanner: MissingFileScanner | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +91,9 @@ export async function buildServer(opts: BuildServerOptions): Promise<FastifyInst
|
||||||
// Decorate with health service (null until set by startup code)
|
// Decorate with health service (null until set by startup code)
|
||||||
server.decorate('healthService', null);
|
server.decorate('healthService', null);
|
||||||
|
|
||||||
|
// Decorate with missing file scanner (null until set by startup code)
|
||||||
|
server.decorate('missingFileScanner', null);
|
||||||
|
|
||||||
// Register CORS — permissive for development, tightened later
|
// Register CORS — permissive for development, tightened later
|
||||||
await server.register(cors, { origin: true });
|
await server.register(cors, { origin: true });
|
||||||
|
|
||||||
|
|
@ -109,6 +117,9 @@ export async function buildServer(opts: BuildServerOptions): Promise<FastifyInst
|
||||||
await server.register(scanRoutes);
|
await server.register(scanRoutes);
|
||||||
await server.register(collectRoutes);
|
await server.register(collectRoutes);
|
||||||
await server.register(playlistRoutes);
|
await server.register(playlistRoutes);
|
||||||
|
await server.register(adhocDownloadRoutes);
|
||||||
|
await server.register(mediaServerRoutes);
|
||||||
|
await server.register(feedRoutes);
|
||||||
|
|
||||||
// Register WebSocket route (before static file serving so /ws is handled)
|
// Register WebSocket route (before static file serving so /ws is handled)
|
||||||
if (opts.eventBus) {
|
if (opts.eventBus) {
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,13 @@ async function authPluginHandler(fastify: FastifyInstance): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip auth for public routes that must work without API keys
|
||||||
|
// RSS readers and podcast apps can't easily send API keys
|
||||||
|
const urlPath = request.url.split('?')[0];
|
||||||
|
if (urlPath.startsWith('/api/v1/feed/') || urlPath.startsWith('/api/v1/media/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Same-origin bypass: browser UI requests are trusted internal clients
|
// Same-origin bypass: browser UI requests are trusted internal clients
|
||||||
if (isSameOriginRequest(request)) {
|
if (isSameOriginRequest(request)) {
|
||||||
request.log.debug(`[auth] same-origin bypass for ${request.url}`);
|
request.log.debug(`[auth] same-origin bypass for ${request.url}`);
|
||||||
|
|
|
||||||
364
src/server/routes/adhoc-download.ts
Normal file
364
src/server/routes/adhoc-download.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { execYtDlp, parseSingleJson, YtDlpError } from '../../sources/yt-dlp';
|
||||||
|
import { createContentItem, getContentByPlatformContentId } from '../../db/repositories/content-repository';
|
||||||
|
import type { Platform, ContentType } from '../../types/index';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface PreviewRequestBody {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmRequestBody {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
platform: string;
|
||||||
|
platformContentId: string;
|
||||||
|
contentType: string;
|
||||||
|
channelName?: string;
|
||||||
|
duration?: number | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
formatProfileId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlPreviewResponse {
|
||||||
|
title: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
platform: string;
|
||||||
|
channelName: string | null;
|
||||||
|
contentType: ContentType;
|
||||||
|
platformContentId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
const URL_PATTERN = /^https?:\/\/.+/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer platform from yt-dlp extractor key / webpage_url.
|
||||||
|
*/
|
||||||
|
function inferPlatform(extractorKey: string, url: string): Platform {
|
||||||
|
const key = (extractorKey || '').toLowerCase();
|
||||||
|
if (key.includes('youtube') || url.includes('youtube.com') || url.includes('youtu.be')) {
|
||||||
|
return 'youtube';
|
||||||
|
}
|
||||||
|
if (key.includes('soundcloud') || url.includes('soundcloud.com')) {
|
||||||
|
return 'soundcloud';
|
||||||
|
}
|
||||||
|
return 'generic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer content type from yt-dlp metadata.
|
||||||
|
*/
|
||||||
|
function inferContentType(info: Record<string, unknown>): ContentType {
|
||||||
|
const isLive = info.is_live === true || info.was_live === true;
|
||||||
|
if (isLive) return 'livestream';
|
||||||
|
|
||||||
|
// SoundCloud and audio-only extractors
|
||||||
|
const extractor = String(info.extractor_key || '').toLowerCase();
|
||||||
|
if (extractor.includes('soundcloud')) return 'audio';
|
||||||
|
|
||||||
|
// Fallback: if there's no video codec, it's audio
|
||||||
|
const vcodec = String(info.vcodec || 'none');
|
||||||
|
if (vcodec === 'none') return 'audio';
|
||||||
|
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map raw yt-dlp --dump-json output to a preview response.
|
||||||
|
*/
|
||||||
|
function mapToPreview(info: Record<string, unknown>, originalUrl: string): UrlPreviewResponse {
|
||||||
|
const extractorKey = String(info.extractor_key || info.extractor || '');
|
||||||
|
const webpageUrl = String(info.webpage_url || info.url || originalUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: String(info.title || info.fulltitle || 'Untitled'),
|
||||||
|
thumbnail: (info.thumbnail as string) || null,
|
||||||
|
duration: typeof info.duration === 'number' ? Math.round(info.duration) : null,
|
||||||
|
platform: inferPlatform(extractorKey, webpageUrl),
|
||||||
|
channelName: (info.channel as string) || (info.uploader as string) || null,
|
||||||
|
contentType: inferContentType(info),
|
||||||
|
platformContentId: String(info.id || ''),
|
||||||
|
url: webpageUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route Plugin ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ad-hoc download route plugin.
|
||||||
|
*
|
||||||
|
* Registers:
|
||||||
|
* POST /api/v1/download/url/preview — resolve metadata for a URL via yt-dlp
|
||||||
|
* POST /api/v1/download/url/confirm — create content item and enqueue download
|
||||||
|
*/
|
||||||
|
export async function adhocDownloadRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
// ── POST /api/v1/download/url/preview ──
|
||||||
|
|
||||||
|
fastify.post<{ Body: PreviewRequestBody }>(
|
||||||
|
'/api/v1/download/url/preview',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['url'],
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
if (!url || !URL_PATTERN.test(url)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'A valid HTTP or HTTPS URL is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use --dump-json to get metadata without downloading
|
||||||
|
const result = await execYtDlp(
|
||||||
|
['--dump-json', '--no-download', '--no-playlist', url],
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const info = parseSingleJson(result.stdout) as Record<string, unknown>;
|
||||||
|
const preview = mapToPreview(info, url);
|
||||||
|
|
||||||
|
return reply.status(200).send(preview);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof YtDlpError) {
|
||||||
|
request.log.warn(
|
||||||
|
{ url, category: err.category, exitCode: err.exitCode },
|
||||||
|
'URL preview failed: %s',
|
||||||
|
err.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map error categories to HTTP status codes
|
||||||
|
if (err.isRateLimit) {
|
||||||
|
return reply.status(429).send({
|
||||||
|
statusCode: 429,
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message: 'Rate limited by platform. Try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.category === 'private' || err.category === 'geo_blocked' || err.category === 'copyright') {
|
||||||
|
return reply.status(422).send({
|
||||||
|
statusCode: 422,
|
||||||
|
error: 'Unprocessable Entity',
|
||||||
|
message: `Content is not accessible: ${err.category.replace('_', ' ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.category === 'network') {
|
||||||
|
return reply.status(502).send({
|
||||||
|
statusCode: 502,
|
||||||
|
error: 'Bad Gateway',
|
||||||
|
message: 'Failed to reach the content platform. Check the URL and try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic yt-dlp failure — likely an unsupported URL or invalid content
|
||||||
|
return reply.status(422).send({
|
||||||
|
statusCode: 422,
|
||||||
|
error: 'Unprocessable Entity',
|
||||||
|
message: 'Could not resolve metadata for this URL. Verify it points to a supported video or audio page.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unexpected errors
|
||||||
|
request.log.error({ err, url }, 'Unexpected error during URL preview');
|
||||||
|
return reply.status(500).send({
|
||||||
|
statusCode: 500,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'An unexpected error occurred while resolving URL metadata',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── POST /api/v1/download/url/confirm ──
|
||||||
|
|
||||||
|
fastify.post<{ Body: ConfirmRequestBody }>(
|
||||||
|
'/api/v1/download/url/confirm',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['url', 'title', 'platform', 'platformContentId', 'contentType'],
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
platform: { type: 'string' },
|
||||||
|
platformContentId: { type: 'string' },
|
||||||
|
contentType: { type: 'string' },
|
||||||
|
channelName: { type: 'string' },
|
||||||
|
duration: { type: ['number', 'null'] },
|
||||||
|
thumbnailUrl: { type: ['string', 'null'] },
|
||||||
|
formatProfileId: { type: 'number' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
platform,
|
||||||
|
platformContentId,
|
||||||
|
contentType,
|
||||||
|
channelName,
|
||||||
|
duration,
|
||||||
|
thumbnailUrl,
|
||||||
|
formatProfileId,
|
||||||
|
} = request.body;
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
if (!url || !URL_PATTERN.test(url)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'A valid HTTP or HTTPS URL is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate platform
|
||||||
|
const validPlatforms = ['youtube', 'soundcloud', 'generic'];
|
||||||
|
if (!validPlatforms.includes(platform)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate contentType
|
||||||
|
const validContentTypes = ['video', 'audio', 'livestream'];
|
||||||
|
if (!validContentTypes.includes(contentType)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Invalid contentType: ${contentType}. Must be one of: ${validContentTypes.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check queue service is available
|
||||||
|
if (!fastify.queueService) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
statusCode: 503,
|
||||||
|
error: 'Service Unavailable',
|
||||||
|
message: 'Download queue is not available. Server may still be starting.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for existing ad-hoc content item with same platformContentId
|
||||||
|
const existing = await getContentByPlatformContentId(
|
||||||
|
fastify.db,
|
||||||
|
null,
|
||||||
|
platformContentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
let contentItemId: number;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
contentItemId = existing.id;
|
||||||
|
|
||||||
|
// If it's already queued or downloading, return conflict
|
||||||
|
if (existing.status === 'queued' || existing.status === 'downloading') {
|
||||||
|
return reply.status(409).send({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message: `This content is already ${existing.status}`,
|
||||||
|
contentItemId: existing.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already downloaded, return conflict with info
|
||||||
|
if (existing.status === 'downloaded') {
|
||||||
|
return reply.status(409).send({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message: 'This content has already been downloaded',
|
||||||
|
contentItemId: existing.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing item in failed/monitored/ignored state — re-enqueue it
|
||||||
|
request.log.info(
|
||||||
|
{ contentItemId, status: existing.status },
|
||||||
|
'Re-enqueuing existing ad-hoc content item',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new ad-hoc content item (channelId = null)
|
||||||
|
const created = await createContentItem(fastify.db, {
|
||||||
|
channelId: null,
|
||||||
|
title,
|
||||||
|
platformContentId,
|
||||||
|
url,
|
||||||
|
contentType: contentType as ContentType,
|
||||||
|
duration: duration ?? null,
|
||||||
|
thumbnailUrl: thumbnailUrl ?? null,
|
||||||
|
status: 'monitored',
|
||||||
|
monitored: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
// Shouldn't happen since we checked above, but handle the edge case
|
||||||
|
return reply.status(409).send({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message: 'Content item already exists',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItemId = created.id;
|
||||||
|
request.log.info(
|
||||||
|
{ contentItemId, platform, platformContentId },
|
||||||
|
'Created ad-hoc content item',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue for download
|
||||||
|
const queueItem = await fastify.queueService.enqueue(contentItemId);
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ contentItemId, queueItemId: queueItem.id },
|
||||||
|
'Ad-hoc download enqueued',
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.status(201).send({
|
||||||
|
contentItemId,
|
||||||
|
queueItemId: queueItem.id,
|
||||||
|
status: 'queued',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Handle double-enqueue from QueueService
|
||||||
|
if (err instanceof Error && err.message.includes('already in the queue')) {
|
||||||
|
return reply.status(409).send({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error({ err, url }, 'Failed to confirm ad-hoc download');
|
||||||
|
return reply.status(500).send({
|
||||||
|
statusCode: 500,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to enqueue download',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,9 @@ const updateChannelBodySchema = {
|
||||||
checkInterval: { type: 'number' as const, minimum: 1 },
|
checkInterval: { type: 'number' as const, minimum: 1 },
|
||||||
monitoringEnabled: { type: 'boolean' as const },
|
monitoringEnabled: { type: 'boolean' as const },
|
||||||
formatProfileId: { type: 'number' as const, nullable: true },
|
formatProfileId: { type: 'number' as const, nullable: true },
|
||||||
|
includeKeywords: { type: 'string' as const, nullable: true },
|
||||||
|
excludeKeywords: { type: 'string' as const, nullable: true },
|
||||||
|
contentRating: { type: 'string' as const, nullable: true },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -253,7 +256,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
fastify.put<{
|
fastify.put<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null };
|
Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; contentRating?: string | null };
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/channel/:id',
|
'/api/v1/channel/:id',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import {
|
||||||
getAllContentItems,
|
getAllContentItems,
|
||||||
getContentByChannelId,
|
getContentByChannelId,
|
||||||
getChannelContentPaginated,
|
getChannelContentPaginated,
|
||||||
|
getContentCountsByType,
|
||||||
setMonitored,
|
setMonitored,
|
||||||
bulkSetMonitored,
|
bulkSetMonitored,
|
||||||
|
updateContentItem,
|
||||||
} from '../../db/repositories/content-repository';
|
} from '../../db/repositories/content-repository';
|
||||||
import type { PaginatedResponse, ApiResponse } 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';
|
||||||
|
|
||||||
// ── JSON Schemas for Fastify Validation ──
|
// ── JSON Schemas for Fastify Validation ──
|
||||||
|
|
@ -31,6 +33,15 @@ const toggleMonitoredBodySchema = {
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateRatingBodySchema = {
|
||||||
|
type: 'object' as const,
|
||||||
|
required: ['contentRating'],
|
||||||
|
properties: {
|
||||||
|
contentRating: { type: 'string' as const, nullable: true },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
// ── Route Plugin ──
|
// ── Route Plugin ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,6 +51,7 @@ const toggleMonitoredBodySchema = {
|
||||||
* GET /api/v1/content — paginated content listing with optional filters
|
* GET /api/v1/content — paginated content listing with optional filters
|
||||||
* PATCH /api/v1/content/bulk/monitored — bulk toggle monitored state
|
* PATCH /api/v1/content/bulk/monitored — bulk toggle monitored state
|
||||||
* PATCH /api/v1/content/:id/monitored — toggle single item monitored state
|
* PATCH /api/v1/content/:id/monitored — toggle single item monitored state
|
||||||
|
* PATCH /api/v1/content/:id/rating — update content item rating
|
||||||
* GET /api/v1/channel/:id/content — content items for a specific channel
|
* GET /api/v1/channel/:id/content — content items for a specific channel
|
||||||
*/
|
*/
|
||||||
export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
@ -196,6 +208,83 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
// ── GET /api/v1/channel/:id/content ──
|
// ── GET /api/v1/channel/:id/content ──
|
||||||
|
|
||||||
|
// ── PATCH /api/v1/content/:id/rating ──
|
||||||
|
|
||||||
|
fastify.patch<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: { contentRating: string | null };
|
||||||
|
}>(
|
||||||
|
'/api/v1/content/:id/rating',
|
||||||
|
{ schema: { body: updateRatingBodySchema } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Content item ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateContentItem(
|
||||||
|
fastify.db,
|
||||||
|
id,
|
||||||
|
{ contentRating: request.body.contentRating },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Content item not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<ContentItem> = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error(
|
||||||
|
{ err, id },
|
||||||
|
'[content] Failed to update content rating'
|
||||||
|
);
|
||||||
|
return reply.status(500).send({
|
||||||
|
statusCode: 500,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to update content rating',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── GET /api/v1/channel/:id/content-counts ──
|
||||||
|
|
||||||
|
fastify.get<{
|
||||||
|
Params: { id: string };
|
||||||
|
}>('/api/v1/channel/:id/content-counts', async (request, reply) => {
|
||||||
|
const channelId = parseIdParam(request.params.id, reply, 'Channel ID');
|
||||||
|
if (channelId === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const counts = await getContentCountsByType(fastify.db, channelId);
|
||||||
|
const response: ApiResponse<ContentTypeCounts> = {
|
||||||
|
success: true,
|
||||||
|
data: counts,
|
||||||
|
};
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error(
|
||||||
|
{ err, channelId },
|
||||||
|
'[content] Failed to fetch content type counts'
|
||||||
|
);
|
||||||
|
return reply.status(500).send({
|
||||||
|
statusCode: 500,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: `Failed to retrieve content type counts for channel ${channelId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/v1/channel/:id/content (paginated) ──
|
||||||
|
|
||||||
fastify.get<{
|
fastify.get<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Querystring: {
|
Querystring: {
|
||||||
|
|
|
||||||
257
src/server/routes/feed.ts
Normal file
257
src/server/routes/feed.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { eq, and, desc, or, inArray, sql } from 'drizzle-orm';
|
||||||
|
import { contentItems } from '../../db/schema/index';
|
||||||
|
import { createReadStream, statSync } from 'node:fs';
|
||||||
|
import { extname, basename } from 'node:path';
|
||||||
|
import { appConfig } from '../../config/index';
|
||||||
|
|
||||||
|
/** Audio file extensions and their MIME types. */
|
||||||
|
const AUDIO_MIME_TYPES: Record<string, string> = {
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.m4a': 'audio/mp4',
|
||||||
|
'.opus': 'audio/opus',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.aac': 'audio/aac',
|
||||||
|
'.flac': 'audio/flac',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.wma': 'audio/x-ms-wma',
|
||||||
|
'.webm': 'audio/webm',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Known audio container format strings from yt-dlp. */
|
||||||
|
const AUDIO_FORMATS = ['mp3', 'm4a', 'opus', 'ogg', 'aac', 'flac', 'wav', 'webm'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special XML characters in a string.
|
||||||
|
*/
|
||||||
|
function escapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds into HH:MM:SS for itunes:duration.
|
||||||
|
*/
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (h > 0) {
|
||||||
|
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guess MIME type from a file path or format string.
|
||||||
|
*/
|
||||||
|
function guessMimeType(filePath: string | null, format: string | null): string {
|
||||||
|
if (filePath) {
|
||||||
|
const ext = extname(filePath).toLowerCase();
|
||||||
|
if (AUDIO_MIME_TYPES[ext]) return AUDIO_MIME_TYPES[ext];
|
||||||
|
}
|
||||||
|
if (format) {
|
||||||
|
const ext = `.${format.toLowerCase()}`;
|
||||||
|
if (AUDIO_MIME_TYPES[ext]) return AUDIO_MIME_TYPES[ext];
|
||||||
|
}
|
||||||
|
return 'audio/mpeg'; // safe default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the base URL for constructing absolute feed URLs.
|
||||||
|
* Uses the request's Host header to generate URLs that work
|
||||||
|
* regardless of reverse proxy configuration.
|
||||||
|
*/
|
||||||
|
function buildBaseUrl(request: { headers: Record<string, string | string[] | undefined>; protocol: string }): string {
|
||||||
|
const host = request.headers.host || `localhost:${appConfig.port}`;
|
||||||
|
const proto = request.headers['x-forwarded-proto'] || request.protocol || 'http';
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed & media route plugin.
|
||||||
|
*
|
||||||
|
* Registers:
|
||||||
|
* GET /api/v1/feed/rss — RSS 2.0 podcast feed of downloaded audio (no auth)
|
||||||
|
* GET /api/v1/media/:id/:filename — serves downloaded media files (no auth)
|
||||||
|
*/
|
||||||
|
export async function feedRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* GET /api/v1/feed/rss — RSS 2.0 podcast feed with iTunes namespace.
|
||||||
|
*
|
||||||
|
* Returns all downloaded audio content as podcast episodes.
|
||||||
|
* No authentication required — RSS readers can't send API keys.
|
||||||
|
*/
|
||||||
|
fastify.get('/api/v1/feed/rss', async (request, reply) => {
|
||||||
|
const db = fastify.db;
|
||||||
|
const baseUrl = buildBaseUrl(request);
|
||||||
|
|
||||||
|
// Query downloaded items that are audio (by contentType or format)
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(contentItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(contentItems.status, 'downloaded'),
|
||||||
|
or(
|
||||||
|
eq(contentItems.contentType, 'audio'),
|
||||||
|
inArray(contentItems.format, AUDIO_FORMATS)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(contentItems.downloadedAt), desc(contentItems.id))
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
const now = new Date().toUTCString();
|
||||||
|
|
||||||
|
// Build RSS XML
|
||||||
|
const items = rows.map((row) => {
|
||||||
|
const fileName = row.filePath ? basename(row.filePath) : `${row.id}.mp3`;
|
||||||
|
const enclosureUrl = `${baseUrl}/api/v1/media/${row.id}/${encodeURIComponent(fileName)}`;
|
||||||
|
const mimeType = guessMimeType(row.filePath, row.format);
|
||||||
|
const fileSize = row.fileSize ?? 0;
|
||||||
|
const pubDate = row.publishedAt
|
||||||
|
? new Date(row.publishedAt).toUTCString()
|
||||||
|
: row.downloadedAt
|
||||||
|
? new Date(row.downloadedAt).toUTCString()
|
||||||
|
: now;
|
||||||
|
const guid = `tubearr-${row.id}-${row.platformContentId}`;
|
||||||
|
const duration = row.duration ? formatDuration(row.duration) : '';
|
||||||
|
|
||||||
|
return ` <item>
|
||||||
|
<title>${escapeXml(row.title)}</title>
|
||||||
|
<description>${escapeXml(row.title)}</description>
|
||||||
|
<enclosure url="${escapeXml(enclosureUrl)}" length="${fileSize}" type="${mimeType}" />
|
||||||
|
<pubDate>${pubDate}</pubDate>
|
||||||
|
<guid isPermaLink="false">${escapeXml(guid)}</guid>${duration ? `\n <itunes:duration>${duration}</itunes:duration>` : ''}
|
||||||
|
</item>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const feedUrl = `${baseUrl}/api/v1/feed/rss`;
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"
|
||||||
|
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||||
|
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>Tubearr Audio Feed</title>
|
||||||
|
<link>${escapeXml(baseUrl)}</link>
|
||||||
|
<description>Downloaded audio from Tubearr</description>
|
||||||
|
<language>en</language>
|
||||||
|
<lastBuildDate>${now}</lastBuildDate>
|
||||||
|
<atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />
|
||||||
|
<itunes:author>Tubearr</itunes:author>
|
||||||
|
<itunes:summary>Downloaded audio from Tubearr</itunes:summary>
|
||||||
|
<itunes:explicit>false</itunes:explicit>
|
||||||
|
${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return reply
|
||||||
|
.type('application/rss+xml; charset=utf-8')
|
||||||
|
.header('Cache-Control', 'public, max-age=300')
|
||||||
|
.send(xml);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/media/:id/:filename — Serve a downloaded media file.
|
||||||
|
*
|
||||||
|
* Looks up the content item by ID, verifies it has a file on disk,
|
||||||
|
* and streams it back. The :filename param is for podcast app display
|
||||||
|
* purposes; the actual file is located via the DB filePath column.
|
||||||
|
*
|
||||||
|
* No authentication required — podcast apps need direct file access.
|
||||||
|
*/
|
||||||
|
fastify.get<{ Params: { id: string; filename: string } }>(
|
||||||
|
'/api/v1/media/:id/:filename',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseInt(request.params.id, 10);
|
||||||
|
if (isNaN(id) || id < 1) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Invalid content item ID',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }, '[feed] Media file not found on disk');
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Media file not found on disk',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = guessMimeType(item.filePath, item.format);
|
||||||
|
|
||||||
|
// Support range requests for podcast app seeking
|
||||||
|
const range = request.headers.range;
|
||||||
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||||
|
const chunkSize = end - start + 1;
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath, { start, end });
|
||||||
|
return reply
|
||||||
|
.status(206)
|
||||||
|
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
|
||||||
|
.header('Accept-Ranges', 'bytes')
|
||||||
|
.header('Content-Length', chunkSize)
|
||||||
|
.header('Content-Type', mimeType)
|
||||||
|
.send(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
return reply
|
||||||
|
.header('Content-Length', stat.size)
|
||||||
|
.header('Content-Type', mimeType)
|
||||||
|
.header('Accept-Ranges', 'bytes')
|
||||||
|
.header('Content-Disposition', `inline; filename="${encodeURIComponent(basename(item.filePath))}"`)
|
||||||
|
.send(stream);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/server/routes/media-server.ts
Normal file
224
src/server/routes/media-server.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
|
import {
|
||||||
|
createMediaServer,
|
||||||
|
getAllMediaServers,
|
||||||
|
getMediaServerById,
|
||||||
|
updateMediaServer,
|
||||||
|
deleteMediaServer,
|
||||||
|
} from '../../db/repositories/media-server-repository';
|
||||||
|
import { MediaServerService } from '../../services/media-server';
|
||||||
|
|
||||||
|
// ── JSON Schemas for Fastify Validation ──
|
||||||
|
|
||||||
|
const createMediaServerBodySchema = {
|
||||||
|
type: 'object' as const,
|
||||||
|
required: ['name', 'type', 'url', 'token'],
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' as const, minLength: 1 },
|
||||||
|
type: { type: 'string' as const, enum: ['plex', 'jellyfin'] },
|
||||||
|
url: { type: 'string' as const, minLength: 1 },
|
||||||
|
token: { type: 'string' as const, minLength: 1 },
|
||||||
|
librarySection: { type: 'string' as const, nullable: true },
|
||||||
|
enabled: { type: 'boolean' as const },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMediaServerBodySchema = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' as const, minLength: 1 },
|
||||||
|
type: { type: 'string' as const, enum: ['plex', 'jellyfin'] },
|
||||||
|
url: { type: 'string' as const, minLength: 1 },
|
||||||
|
token: { type: 'string' as const, minLength: 1 },
|
||||||
|
librarySection: { type: 'string' as const, nullable: true },
|
||||||
|
enabled: { type: 'boolean' as const },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/** Redact the token for API responses — show first 4 chars + '…'. */
|
||||||
|
function redactToken(token: string): string {
|
||||||
|
if (token.length <= 4) return '****';
|
||||||
|
return token.slice(0, 4) + '****';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a copy of the media server with the token redacted. */
|
||||||
|
function redactServer<T extends { token: string }>(server: T): T {
|
||||||
|
return { ...server, token: redactToken(server.token) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route Plugin ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media server CRUD + action route plugin.
|
||||||
|
*
|
||||||
|
* Registers:
|
||||||
|
* POST /api/v1/media-servers — create a media server
|
||||||
|
* GET /api/v1/media-servers — list all media servers (redacted tokens)
|
||||||
|
* GET /api/v1/media-servers/:id — get a single server (redacted token)
|
||||||
|
* PUT /api/v1/media-servers/:id — update server fields
|
||||||
|
* DELETE /api/v1/media-servers/:id — delete a server
|
||||||
|
* POST /api/v1/media-servers/:id/test — test connection
|
||||||
|
* GET /api/v1/media-servers/:id/sections — list library sections
|
||||||
|
*/
|
||||||
|
export async function mediaServerRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
const service = new MediaServerService();
|
||||||
|
|
||||||
|
// ── POST /api/v1/media-servers ──
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Body: {
|
||||||
|
name: string;
|
||||||
|
type: 'plex' | 'jellyfin';
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
librarySection?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'/api/v1/media-servers',
|
||||||
|
{ schema: { body: createMediaServerBodySchema } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const server = await createMediaServer(fastify.db, request.body);
|
||||||
|
request.log.info(
|
||||||
|
{ serverId: server.id, name: server.name, type: server.type },
|
||||||
|
`[media-server] created server="${server.name}" type=${server.type}`
|
||||||
|
);
|
||||||
|
return reply.status(201).send(redactServer(server));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── GET /api/v1/media-servers ──
|
||||||
|
|
||||||
|
fastify.get('/api/v1/media-servers', async () => {
|
||||||
|
const servers = await getAllMediaServers(fastify.db);
|
||||||
|
return servers.map(redactServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/v1/media-servers/:id ──
|
||||||
|
|
||||||
|
fastify.get<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/media-servers/:id',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Media server ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
const server = await getMediaServerById(fastify.db, id);
|
||||||
|
if (!server) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Media server with ID ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redactServer(server);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── PUT /api/v1/media-servers/:id ──
|
||||||
|
|
||||||
|
fastify.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: {
|
||||||
|
name?: string;
|
||||||
|
type?: 'plex' | 'jellyfin';
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
librarySection?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'/api/v1/media-servers/:id',
|
||||||
|
{ schema: { body: updateMediaServerBodySchema } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Media server ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
const updated = await updateMediaServer(fastify.db, id, request.body);
|
||||||
|
if (!updated) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Media server with ID ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ serverId: id, fields: Object.keys(request.body) },
|
||||||
|
`[media-server] updated server=${id}`
|
||||||
|
);
|
||||||
|
return redactServer(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── DELETE /api/v1/media-servers/:id ──
|
||||||
|
|
||||||
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/media-servers/:id',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Media server ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
const deleted = await deleteMediaServer(fastify.db, id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Media server with ID ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info({ serverId: id }, `[media-server] deleted server=${id}`);
|
||||||
|
return reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── POST /api/v1/media-servers/:id/test ──
|
||||||
|
|
||||||
|
fastify.post<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/media-servers/:id/test',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Media server ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
const server = await getMediaServerById(fastify.db, id);
|
||||||
|
if (!server) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Media server with ID ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.testConnection(server);
|
||||||
|
return reply.send(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── GET /api/v1/media-servers/:id/sections ──
|
||||||
|
|
||||||
|
fastify.get<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/media-servers/:id/sections',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Media server ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
const server = await getMediaServerById(fastify.db, id);
|
||||||
|
if (!server) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Media server with ID ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = await service.listLibrarySections(server);
|
||||||
|
return reply.send(sections);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,11 +14,13 @@ import type { QueueStatus } from '../../types/index';
|
||||||
* Queue management route plugin.
|
* Queue management route plugin.
|
||||||
*
|
*
|
||||||
* Registers:
|
* Registers:
|
||||||
* GET /api/v1/queue — list queue items (optional ?status= filter)
|
* GET /api/v1/queue — list queue items (optional ?status= filter)
|
||||||
* GET /api/v1/queue/:id — get a single queue item
|
* GET /api/v1/queue/:id — get a single queue item
|
||||||
* POST /api/v1/queue — enqueue a content item for download
|
* POST /api/v1/queue — enqueue a content item for download
|
||||||
* DELETE /api/v1/queue/:id — cancel a queue item
|
* DELETE /api/v1/queue/:id — cancel a queue item
|
||||||
* POST /api/v1/queue/:id/retry — retry a failed queue item
|
* POST /api/v1/queue/:id/retry — retry a failed queue item
|
||||||
|
* PUT /api/v1/queue/:id/pause — pause a pending or downloading queue item
|
||||||
|
* PUT /api/v1/queue/:id/resume — resume a paused queue item
|
||||||
*/
|
*/
|
||||||
export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
// ── GET /api/v1/queue ──
|
// ── GET /api/v1/queue ──
|
||||||
|
|
@ -35,6 +37,7 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
'completed',
|
'completed',
|
||||||
'failed',
|
'failed',
|
||||||
'cancelled',
|
'cancelled',
|
||||||
|
'paused',
|
||||||
];
|
];
|
||||||
if (!validStatuses.includes(status as QueueStatus)) {
|
if (!validStatuses.includes(status as QueueStatus)) {
|
||||||
return _reply.status(400).send({
|
return _reply.status(400).send({
|
||||||
|
|
@ -216,4 +219,90 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── PUT /api/v1/queue/:id/pause ──
|
||||||
|
|
||||||
|
fastify.put<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/queue/:id/pause',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
if (!fastify.queueService) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
statusCode: 503,
|
||||||
|
error: 'Service Unavailable',
|
||||||
|
message: 'Queue service is not initialized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paused = await fastify.queueService.pauseItem(id);
|
||||||
|
return reply.status(200).send({ success: true, data: paused });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
if (message.includes('not found')) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('Cannot pause')) {
|
||||||
|
return reply.status(409).send({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── PUT /api/v1/queue/:id/resume ──
|
||||||
|
|
||||||
|
fastify.put<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/queue/:id/resume',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
if (!fastify.queueService) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
statusCode: 503,
|
||||||
|
error: 'Service Unavailable',
|
||||||
|
message: 'Queue service is not initialized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resumed = await fastify.queueService.resumeItem(id);
|
||||||
|
return reply.status(200).send({ success: true, data: resumed });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
if (message.includes('not found')) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('Cannot resume')) {
|
||||||
|
return reply.status(409).send({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,18 @@ import { appConfig } from '../../config/index';
|
||||||
import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '../../types/api';
|
import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '../../types/api';
|
||||||
import { systemConfig } from '../../db/schema/index';
|
import { systemConfig } from '../../db/schema/index';
|
||||||
import { API_KEY_DB_KEY } from '../middleware/auth';
|
import { API_KEY_DB_KEY } from '../middleware/auth';
|
||||||
|
import { getContentItemById, updateContentItem } from '../../db/repositories/content-repository';
|
||||||
|
import { parseIdParam } from './helpers';
|
||||||
import {
|
import {
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
getAppSetting,
|
getAppSetting,
|
||||||
setAppSetting,
|
setAppSetting,
|
||||||
APP_CHECK_INTERVAL,
|
APP_CHECK_INTERVAL,
|
||||||
APP_CONCURRENT_DOWNLOADS,
|
APP_CONCURRENT_DOWNLOADS,
|
||||||
|
APP_OUTPUT_TEMPLATE,
|
||||||
|
APP_NFO_ENABLED,
|
||||||
|
APP_TIMEZONE,
|
||||||
|
APP_THEME,
|
||||||
YTDLP_LAST_UPDATED,
|
YTDLP_LAST_UPDATED,
|
||||||
} from '../../db/repositories/system-config-repository';
|
} from '../../db/repositories/system-config-repository';
|
||||||
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
|
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
|
||||||
|
|
@ -114,11 +120,15 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
*/
|
*/
|
||||||
fastify.get('/api/v1/system/settings', async (_request, _reply) => {
|
fastify.get('/api/v1/system/settings', async (_request, _reply) => {
|
||||||
const db = fastify.db;
|
const db = fastify.db;
|
||||||
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS]);
|
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED, APP_TIMEZONE, APP_THEME]);
|
||||||
|
|
||||||
const response: AppSettingsResponse = {
|
const response: AppSettingsResponse = {
|
||||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
||||||
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
||||||
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
|
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
||||||
|
timezone: settings[APP_TIMEZONE] ?? 'UTC',
|
||||||
|
theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -131,7 +141,7 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
*/
|
*/
|
||||||
fastify.put('/api/v1/system/settings', async (request, reply) => {
|
fastify.put('/api/v1/system/settings', async (request, reply) => {
|
||||||
const db = fastify.db;
|
const db = fastify.db;
|
||||||
const body = request.body as { checkInterval?: number; concurrentDownloads?: number };
|
const body = request.body as { checkInterval?: number; concurrentDownloads?: number; outputTemplate?: string; nfoEnabled?: boolean; timezone?: string; theme?: string };
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (body.checkInterval !== undefined) {
|
if (body.checkInterval !== undefined) {
|
||||||
|
|
@ -172,12 +182,56 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.queueService.setConcurrency(body.concurrentDownloads);
|
fastify.queueService.setConcurrency(body.concurrentDownloads);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (body.outputTemplate !== undefined) {
|
||||||
|
if (typeof body.outputTemplate !== 'string' || body.outputTemplate.trim().length === 0) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'outputTemplate must be a non-empty string',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!body.outputTemplate.includes('{ext}')) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'outputTemplate must contain {ext} for the file extension',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await setAppSetting(db, APP_OUTPUT_TEMPLATE, body.outputTemplate);
|
||||||
|
}
|
||||||
|
if (body.nfoEnabled !== undefined) {
|
||||||
|
await setAppSetting(db, APP_NFO_ENABLED, body.nfoEnabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (body.timezone !== undefined) {
|
||||||
|
if (typeof body.timezone !== 'string' || body.timezone.trim().length === 0) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'timezone must be a non-empty string',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await setAppSetting(db, APP_TIMEZONE, body.timezone);
|
||||||
|
}
|
||||||
|
if (body.theme !== undefined) {
|
||||||
|
if (body.theme !== 'dark' && body.theme !== 'light') {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'theme must be "dark" or "light"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await setAppSetting(db, APP_THEME, body.theme);
|
||||||
|
}
|
||||||
|
|
||||||
// Return updated values
|
// Return updated values
|
||||||
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS]);
|
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED, APP_TIMEZONE, APP_THEME]);
|
||||||
const response: AppSettingsResponse = {
|
const response: AppSettingsResponse = {
|
||||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
|
||||||
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
|
||||||
|
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
|
||||||
|
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
|
||||||
|
timezone: settings[APP_TIMEZONE] ?? 'UTC',
|
||||||
|
theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light',
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -222,4 +276,110 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
};
|
};
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Missing File Scan ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/system/missing-scan — Trigger an on-demand missing file scan.
|
||||||
|
* Returns scan results (checked, missing, duration).
|
||||||
|
*/
|
||||||
|
fastify.post('/api/v1/system/missing-scan', async (request, reply) => {
|
||||||
|
if (!fastify.missingFileScanner) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
statusCode: 503,
|
||||||
|
error: 'Service Unavailable',
|
||||||
|
message: 'Missing file scanner is not initialized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fastify.missingFileScanner.scanAll();
|
||||||
|
request.log.info(
|
||||||
|
{ checked: result.checked, missing: result.missing, duration: result.duration },
|
||||||
|
'[system] Missing file scan completed'
|
||||||
|
);
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, '[system] Missing file scan failed');
|
||||||
|
return reply.status(500).send({
|
||||||
|
statusCode: 500,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Missing file scan failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/system/missing-scan/status — Last scan time and results.
|
||||||
|
*/
|
||||||
|
fastify.get('/api/v1/system/missing-scan/status', async (_request, reply) => {
|
||||||
|
if (!fastify.missingFileScanner) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
statusCode: 503,
|
||||||
|
error: 'Service Unavailable',
|
||||||
|
message: 'Missing file scanner is not initialized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastScan = await fastify.missingFileScanner.getLastScanResult();
|
||||||
|
return { success: true, data: lastScan };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Content Requeue ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/content/:id/requeue — Re-download a missing content item.
|
||||||
|
* Resets the content status from 'missing' to 'monitored' and enqueues for download.
|
||||||
|
*/
|
||||||
|
fastify.post<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/content/:id/requeue',
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = parseIdParam(request.params.id, reply, 'Content item ID');
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
const contentItem = await getContentItemById(fastify.db, id);
|
||||||
|
if (!contentItem) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
statusCode: 404,
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Content item ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentItem.status !== 'missing') {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Content item ${id} has status '${contentItem.status}', expected 'missing'`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fastify.queueService) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
statusCode: 503,
|
||||||
|
error: 'Service Unavailable',
|
||||||
|
message: 'Queue service is not initialized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset status to 'monitored' so the download pipeline treats it as a fresh item
|
||||||
|
await updateContentItem(fastify.db, id, { status: 'monitored' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queueItem = await fastify.queueService.enqueue(id);
|
||||||
|
request.log.info({ contentItemId: id, queueItemId: queueItem.id }, '[system] Missing content item requeued');
|
||||||
|
return reply.status(201).send({ success: true, data: queueItem });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('already in the queue')) {
|
||||||
|
return reply.status(409).send({
|
||||||
|
statusCode: 409,
|
||||||
|
error: 'Conflict',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ 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 { 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';
|
||||||
import type { RateLimiter } from './rate-limiter';
|
import type { RateLimiter } from './rate-limiter';
|
||||||
|
|
@ -49,14 +51,20 @@ export class DownloadService {
|
||||||
*
|
*
|
||||||
* Status transitions: monitored → downloading → downloaded | failed
|
* Status transitions: monitored → downloading → downloaded | failed
|
||||||
*
|
*
|
||||||
|
* For ad-hoc downloads (no channel), pass channel as null and provide
|
||||||
|
* adhocOverrides with at least platform. channelName defaults to 'Ad-hoc'.
|
||||||
|
*
|
||||||
* @throws YtDlpError on download failure (after updating status to 'failed')
|
* @throws YtDlpError on download failure (after updating status to 'failed')
|
||||||
*/
|
*/
|
||||||
async downloadItem(
|
async downloadItem(
|
||||||
contentItem: ContentItem,
|
contentItem: ContentItem,
|
||||||
channel: Channel,
|
channel: Channel | null,
|
||||||
formatProfile?: FormatProfile
|
formatProfile?: FormatProfile,
|
||||||
|
adhocOverrides?: { platform?: Platform; channelName?: string }
|
||||||
): Promise<ContentItem> {
|
): Promise<ContentItem> {
|
||||||
const logPrefix = `[download] item=${contentItem.id} channel="${channel.name}"`;
|
const platform = channel?.platform ?? adhocOverrides?.platform ?? 'generic';
|
||||||
|
const channelName = channel?.name ?? adhocOverrides?.channelName ?? 'Ad-hoc';
|
||||||
|
const logPrefix = `[download] item=${contentItem.id} channel="${channelName}"`;
|
||||||
|
|
||||||
// Mark as downloading
|
// Mark as downloading
|
||||||
console.log(`${logPrefix} status=downloading`);
|
console.log(`${logPrefix} status=downloading`);
|
||||||
|
|
@ -64,14 +72,20 @@ export class DownloadService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Acquire rate limiter for platform
|
// Acquire rate limiter for platform
|
||||||
await this.rateLimiter.acquire(channel.platform as Platform);
|
await this.rateLimiter.acquire(platform as Platform);
|
||||||
|
|
||||||
// Build yt-dlp args
|
// Build yt-dlp args
|
||||||
|
const template = formatProfile?.outputTemplate ?? undefined;
|
||||||
const outputTemplate = this.fileOrganizer.buildOutputPath(
|
const outputTemplate = this.fileOrganizer.buildOutputPath(
|
||||||
channel.platform,
|
platform,
|
||||||
channel.name,
|
channelName,
|
||||||
contentItem.title,
|
contentItem.title,
|
||||||
this.guessExtension(contentItem.contentType, formatProfile)
|
this.guessExtension(contentItem.contentType, formatProfile),
|
||||||
|
template,
|
||||||
|
{
|
||||||
|
contentType: contentItem.contentType,
|
||||||
|
id: contentItem.platformContentId,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const args = this.buildYtDlpArgs(
|
const args = this.buildYtDlpArgs(
|
||||||
contentItem,
|
contentItem,
|
||||||
|
|
@ -123,7 +137,10 @@ export class DownloadService {
|
||||||
downloadedAt: new Date().toISOString(),
|
downloadedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rateLimiter.reportSuccess(channel.platform as Platform);
|
this.rateLimiter.reportSuccess(platform as Platform);
|
||||||
|
|
||||||
|
// Generate NFO sidecar if enabled
|
||||||
|
await this.maybeWriteNfo(contentItem, channel, finalPath, logPrefix);
|
||||||
|
|
||||||
// Emit download:complete event
|
// Emit download:complete event
|
||||||
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
||||||
|
|
@ -135,7 +152,7 @@ export class DownloadService {
|
||||||
return updated!;
|
return updated!;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Report error to rate limiter
|
// Report error to rate limiter
|
||||||
this.rateLimiter.reportError(channel.platform as Platform);
|
this.rateLimiter.reportError(platform as Platform);
|
||||||
|
|
||||||
// Classify the error for better retry decisions
|
// Classify the error for better retry decisions
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
|
@ -255,7 +272,7 @@ export class DownloadService {
|
||||||
*/
|
*/
|
||||||
private buildYtDlpArgs(
|
private buildYtDlpArgs(
|
||||||
contentItem: ContentItem,
|
contentItem: ContentItem,
|
||||||
channel: Channel,
|
channel: Channel | null,
|
||||||
formatProfile: FormatProfile | undefined,
|
formatProfile: FormatProfile | undefined,
|
||||||
outputTemplate: string
|
outputTemplate: string
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|
@ -293,9 +310,10 @@ export class DownloadService {
|
||||||
args.push('--no-playlist');
|
args.push('--no-playlist');
|
||||||
args.push('--print', 'after_move:filepath');
|
args.push('--print', 'after_move:filepath');
|
||||||
|
|
||||||
// Cookie support
|
// Cookie support — use channel platform if available, fallback to contentItem URL inference
|
||||||
|
const cookiePlatform = channel?.platform ?? this.inferPlatformFromUrl(contentItem.url);
|
||||||
const cookiePath = this.cookieManager.getCookieFilePath(
|
const cookiePath = this.cookieManager.getCookieFilePath(
|
||||||
channel.platform as Platform
|
cookiePlatform as Platform
|
||||||
);
|
);
|
||||||
if (cookiePath) {
|
if (cookiePath) {
|
||||||
args.push('--cookies', cookiePath);
|
args.push('--cookies', cookiePath);
|
||||||
|
|
@ -434,6 +452,39 @@ export class DownloadService {
|
||||||
}
|
}
|
||||||
return contentType === 'audio' ? 'mp3' : 'mp4';
|
return contentType === 'audio' ? 'mp3' : 'mp4';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer a platform string from a URL for cookie lookup.
|
||||||
|
* Used for ad-hoc downloads where no channel is available.
|
||||||
|
*/
|
||||||
|
private inferPlatformFromUrl(url: string): string {
|
||||||
|
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
|
||||||
|
if (url.includes('soundcloud.com')) return 'soundcloud';
|
||||||
|
return 'generic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an NFO sidecar file alongside the downloaded media if the feature is enabled.
|
||||||
|
* NFO generation is best-effort — failure is logged but never fails the download.
|
||||||
|
*/
|
||||||
|
private async maybeWriteNfo(
|
||||||
|
contentItem: ContentItem,
|
||||||
|
channel: Channel | null,
|
||||||
|
mediaFilePath: string,
|
||||||
|
logPrefix: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const nfoEnabled = await getAppSetting(this.db, APP_NFO_ENABLED);
|
||||||
|
if (nfoEnabled !== 'true') return;
|
||||||
|
|
||||||
|
const nfoXml = generateNfo(contentItem, channel);
|
||||||
|
const nfoPath = await writeNfoFile(nfoXml, mediaFilePath);
|
||||||
|
console.log(`${logPrefix} nfo written path="${nfoPath}"`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`${logPrefix} nfo generation failed (non-fatal): ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
|
||||||
|
|
@ -20,32 +20,156 @@ const MAX_FILENAME_LENGTH = 200;
|
||||||
/** Maximum attempts to find a unique filename. */
|
/** Maximum attempts to find a unique filename. */
|
||||||
const MAX_UNIQUE_ATTEMPTS = 100;
|
const MAX_UNIQUE_ATTEMPTS = 100;
|
||||||
|
|
||||||
|
/** Default output path template — matches the legacy hardcoded layout. */
|
||||||
|
export const DEFAULT_OUTPUT_TEMPLATE = '{platform}/{channel}/{title}.{ext}';
|
||||||
|
|
||||||
|
/** All supported template variable names. */
|
||||||
|
export const TEMPLATE_VARIABLES = [
|
||||||
|
'platform',
|
||||||
|
'channel',
|
||||||
|
'title',
|
||||||
|
'date',
|
||||||
|
'year',
|
||||||
|
'month',
|
||||||
|
'contentType',
|
||||||
|
'id',
|
||||||
|
'ext',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
|
||||||
|
|
||||||
|
/** Variables that callers can supply when resolving a template. */
|
||||||
|
export type TemplateVars = Partial<Record<TemplateVariable, string>>;
|
||||||
|
|
||||||
|
/** Result of template validation. */
|
||||||
|
export interface TemplateValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Regex helpers ──
|
||||||
|
|
||||||
|
/** Matches a `{variableName}` placeholder. */
|
||||||
|
const TEMPLATE_VAR_RE = /\{([a-zA-Z]+)\}/g;
|
||||||
|
|
||||||
|
/** Characters illegal in path segments (beyond what sanitizeFilename strips). */
|
||||||
|
const ILLEGAL_PATH_CHARS = /[<>"|?*\x00-\x1f]/;
|
||||||
|
|
||||||
// ── FileOrganizer ──
|
// ── FileOrganizer ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds structured output paths from content metadata and sanitizes
|
* Builds structured output paths from content metadata and sanitizes
|
||||||
* filenames for cross-platform safety.
|
* filenames for cross-platform safety. Supports configurable path
|
||||||
*
|
* templates with variables like {platform}, {channel}, {title}, {ext}.
|
||||||
* Path template: `{mediaPath}/{platform}/{channelName}/{title}.{ext}`
|
|
||||||
*/
|
*/
|
||||||
export class FileOrganizer {
|
export class FileOrganizer {
|
||||||
constructor(private readonly mediaPath: string) {}
|
constructor(private readonly mediaPath: string) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the full output path for a downloaded file.
|
* Build the full output path for a downloaded file.
|
||||||
* Sanitizes channelName and title for filesystem safety.
|
*
|
||||||
|
* When `template` is provided, resolves it against the supplied metadata.
|
||||||
|
* Otherwise falls back to the default `{platform}/{channel}/{title}.{ext}`.
|
||||||
|
*
|
||||||
|
* Backward-compatible: the 4-arg positional call still works identically.
|
||||||
*/
|
*/
|
||||||
buildOutputPath(
|
buildOutputPath(
|
||||||
platform: string,
|
platform: string,
|
||||||
channelName: string,
|
channelName: string,
|
||||||
title: string,
|
title: string,
|
||||||
ext: string
|
ext: string,
|
||||||
|
template?: string,
|
||||||
|
extra?: { contentType?: string; id?: string }
|
||||||
): string {
|
): string {
|
||||||
const safeName = this.sanitizeFilename(channelName);
|
|
||||||
const safeTitle = this.sanitizeFilename(title);
|
|
||||||
const safeExt = ext.startsWith('.') ? ext.slice(1) : ext;
|
const safeExt = ext.startsWith('.') ? ext.slice(1) : ext;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
return path.join(this.mediaPath, platform, safeName, `${safeTitle}.${safeExt}`);
|
const vars: TemplateVars = {
|
||||||
|
platform,
|
||||||
|
channel: channelName,
|
||||||
|
title,
|
||||||
|
ext: safeExt,
|
||||||
|
date: now.toISOString().slice(0, 10), // YYYY-MM-DD
|
||||||
|
year: String(now.getFullYear()),
|
||||||
|
month: String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
|
...(extra?.contentType != null ? { contentType: extra.contentType } : {}),
|
||||||
|
...(extra?.id != null ? { id: extra.id } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = this.resolveTemplate(template ?? DEFAULT_OUTPUT_TEMPLATE, vars);
|
||||||
|
return path.join(this.mediaPath, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a template string by replacing `{variable}` placeholders with
|
||||||
|
* sanitized values from `vars`. Path separators (`/`) in the template are
|
||||||
|
* preserved — each segment between separators is sanitized independently.
|
||||||
|
*
|
||||||
|
* Unknown variables are left as-is (e.g. `{unknown}` stays `{unknown}`).
|
||||||
|
* Missing known variables resolve to empty string.
|
||||||
|
*/
|
||||||
|
resolveTemplate(template: string, vars: TemplateVars): string {
|
||||||
|
// Split on forward slash to handle each path segment
|
||||||
|
const segments = template.split('/');
|
||||||
|
|
||||||
|
const resolvedSegments = segments.map((segment) => {
|
||||||
|
// Replace all {var} tokens in this segment
|
||||||
|
const resolved = segment.replace(TEMPLATE_VAR_RE, (_match, varName: string) => {
|
||||||
|
if (!TEMPLATE_VARIABLES.includes(varName as TemplateVariable)) {
|
||||||
|
return `{${varName}}`; // Unknown variable — leave untouched
|
||||||
|
}
|
||||||
|
const value = vars[varName as TemplateVariable] ?? '';
|
||||||
|
|
||||||
|
// Don't sanitize ext — it's a bare token used after the dot
|
||||||
|
if (varName === 'ext') return value;
|
||||||
|
|
||||||
|
return this.sanitizeFilename(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolvedSegments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a template string for correctness.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Must contain `{ext}` (required for file extension).
|
||||||
|
* - Must not contain illegal filesystem characters outside of `{var}` placeholders.
|
||||||
|
* - All `{var}` names must be recognized template variables.
|
||||||
|
* - Must not be empty.
|
||||||
|
*/
|
||||||
|
validateTemplate(template: string): TemplateValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!template || template.trim().length === 0) {
|
||||||
|
return { valid: false, errors: ['Template must not be empty'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required {ext}
|
||||||
|
if (!template.includes('{ext}')) {
|
||||||
|
errors.push('Template must contain {ext} for the file extension');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all variable references are recognized
|
||||||
|
const varMatches = [...template.matchAll(TEMPLATE_VAR_RE)];
|
||||||
|
for (const match of varMatches) {
|
||||||
|
const varName = match[1];
|
||||||
|
if (!TEMPLATE_VARIABLES.includes(varName as TemplateVariable)) {
|
||||||
|
errors.push(`Unknown template variable: {${varName}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for illegal characters outside variable placeholders
|
||||||
|
// Strip all {var} placeholders, then check what remains
|
||||||
|
const stripped = template.replace(TEMPLATE_VAR_RE, '');
|
||||||
|
if (ILLEGAL_PATH_CHARS.test(stripped)) {
|
||||||
|
errors.push('Template contains illegal filesystem characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
128
src/services/keyword-filter.ts
Normal file
128
src/services/keyword-filter.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// ── Keyword Filter Matching Engine ──
|
||||||
|
//
|
||||||
|
// Evaluates video/content titles against per-channel include/exclude keyword
|
||||||
|
// patterns. Patterns are pipe-separated strings stored in the DB; each
|
||||||
|
// individual pattern can be:
|
||||||
|
// • plain text → case-insensitive substring match
|
||||||
|
// • glob with * → converted to regex (e.g. "*shorts*" matches "My Shorts Video")
|
||||||
|
// • /regex/ → evaluated as a JS RegExp (case-insensitive)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a pipe-separated pattern string into individual trimmed patterns.
|
||||||
|
* Blank entries are silently dropped.
|
||||||
|
*
|
||||||
|
* Regex-aware: pipes inside `/regex/` delimiters are preserved as part of the
|
||||||
|
* pattern (e.g. `/shorts|clips/` stays as one pattern, not split into two).
|
||||||
|
*/
|
||||||
|
export function parsePatterns(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
const patterns: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inRegex = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const ch = raw[i];
|
||||||
|
|
||||||
|
if (ch === '/' && !inRegex && current.trim() === '') {
|
||||||
|
// Entering regex mode — slash at the start of a new pattern segment
|
||||||
|
inRegex = true;
|
||||||
|
current += ch;
|
||||||
|
} else if (ch === '/' && inRegex) {
|
||||||
|
// Closing regex delimiter
|
||||||
|
current += ch;
|
||||||
|
inRegex = false;
|
||||||
|
} else if (ch === '|' && !inRegex) {
|
||||||
|
// Pipe separator outside regex — flush current pattern
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed.length > 0) patterns.push(trimmed);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed.length > 0) patterns.push(trimmed);
|
||||||
|
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a single pattern matches a title.
|
||||||
|
*
|
||||||
|
* Pattern types:
|
||||||
|
* - /regex/ → JS regular expression (case-insensitive)
|
||||||
|
* - *glob* → wildcard matching (case-insensitive)
|
||||||
|
* - plain → case-insensitive substring contains
|
||||||
|
*/
|
||||||
|
export function patternMatches(pattern: string, title: string): boolean {
|
||||||
|
// Regex pattern: /something/
|
||||||
|
if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(pattern.slice(1, -1), 'i');
|
||||||
|
return regex.test(title);
|
||||||
|
} catch {
|
||||||
|
// Invalid regex — treat as a plain-text match
|
||||||
|
return title.toLowerCase().includes(pattern.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glob pattern: contains at least one *
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
const regexSource = pattern
|
||||||
|
.split('*')
|
||||||
|
.map(escapeRegex)
|
||||||
|
.join('.*');
|
||||||
|
const regex = new RegExp(`^${regexSource}$`, 'i');
|
||||||
|
return regex.test(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text: case-insensitive substring match
|
||||||
|
return title.toLowerCase().includes(pattern.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a title against include and exclude keyword patterns.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* 1. If excludePatterns is set and title matches ANY exclude pattern → false
|
||||||
|
* 2. If includePatterns is set, title must match AT LEAST ONE include → true/false
|
||||||
|
* 3. If neither is set → true (all titles pass)
|
||||||
|
*
|
||||||
|
* @param title The content title to evaluate
|
||||||
|
* @param includePatterns Pipe-separated include patterns (null = no filter)
|
||||||
|
* @param excludePatterns Pipe-separated exclude patterns (null = no filter)
|
||||||
|
* @returns true if the title should be enqueued, false if filtered out
|
||||||
|
*/
|
||||||
|
export function matchesKeywordFilter(
|
||||||
|
title: string,
|
||||||
|
includePatterns: string | null | undefined,
|
||||||
|
excludePatterns: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const excludes = parsePatterns(excludePatterns);
|
||||||
|
const includes = parsePatterns(includePatterns);
|
||||||
|
|
||||||
|
// Exclude check first — any match rejects
|
||||||
|
if (excludes.length > 0) {
|
||||||
|
for (const pattern of excludes) {
|
||||||
|
if (patternMatches(pattern, title)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include check — at least one must match (if set)
|
||||||
|
if (includes.length > 0) {
|
||||||
|
return includes.some((pattern) => patternMatches(pattern, title));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filters → pass
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape special regex characters in a string. */
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
267
src/services/media-server.ts
Normal file
267
src/services/media-server.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import type { MediaServer, MediaServerType } from '../types/index';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
/** A library section returned by Plex's /library/sections endpoint. */
|
||||||
|
export interface PlexLibrarySection {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result from a connection test. */
|
||||||
|
export interface ConnectionTestResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
serverName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result from a library scan trigger. */
|
||||||
|
export interface ScanResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/** Strip trailing slashes from a URL. */
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an AbortSignal that fires after the given ms. */
|
||||||
|
function timeoutSignal(ms: number = REQUEST_TIMEOUT_MS): AbortSignal {
|
||||||
|
return AbortSignal.timeout(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MediaServerService ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles communication with Plex and Jellyfin media servers.
|
||||||
|
* Stateless — each method takes a MediaServer config object.
|
||||||
|
* All network errors are caught and returned as structured results, never thrown.
|
||||||
|
*/
|
||||||
|
export class MediaServerService {
|
||||||
|
/**
|
||||||
|
* Trigger a library scan on the given media server.
|
||||||
|
* For Plex: refreshes a specific library section (requires librarySection).
|
||||||
|
* For Jellyfin: triggers a full library refresh.
|
||||||
|
*/
|
||||||
|
async triggerScan(server: MediaServer): Promise<ScanResult> {
|
||||||
|
if (server.type === 'plex') {
|
||||||
|
return this.plexScan(server);
|
||||||
|
}
|
||||||
|
return this.jellyfinScan(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether the server is reachable and the token is valid.
|
||||||
|
*/
|
||||||
|
async testConnection(server: MediaServer): Promise<ConnectionTestResult> {
|
||||||
|
if (server.type === 'plex') {
|
||||||
|
return this.testPlexConnection(server);
|
||||||
|
}
|
||||||
|
return this.testJellyfinConnection(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available library sections. Currently only supported for Plex.
|
||||||
|
* Returns an empty array for Jellyfin (no per-library scan targeting).
|
||||||
|
*/
|
||||||
|
async listLibrarySections(server: MediaServer): Promise<PlexLibrarySection[]> {
|
||||||
|
if (server.type === 'plex') {
|
||||||
|
return this.plexListSections(server);
|
||||||
|
}
|
||||||
|
// Jellyfin refreshes all libraries at once; no section listing needed.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plex ──
|
||||||
|
|
||||||
|
private async plexScan(server: MediaServer): Promise<ScanResult> {
|
||||||
|
if (!server.librarySection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Plex server requires a library section to scan',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = normalizeUrl(server.url);
|
||||||
|
const url = `${base}/library/sections/${encodeURIComponent(server.librarySection)}/refresh?X-Plex-Token=${encodeURIComponent(server.token)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: timeoutSignal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Plex scan triggered for section ${server.librarySection}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Plex scan failed: HTTP ${res.status} ${res.statusText}`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Plex scan error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testPlexConnection(server: MediaServer): Promise<ConnectionTestResult> {
|
||||||
|
const base = normalizeUrl(server.url);
|
||||||
|
const url = `${base}/identity?X-Plex-Token=${encodeURIComponent(server.token)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: timeoutSignal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Connection failed: HTTP ${res.status} ${res.statusText}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plex /identity returns { MediaContainer: { machineIdentifier, version } }
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
MediaContainer?: { friendlyName?: string };
|
||||||
|
};
|
||||||
|
const friendlyName = data?.MediaContainer?.friendlyName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Connection successful',
|
||||||
|
serverName: friendlyName ?? undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Connection error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async plexListSections(server: MediaServer): Promise<PlexLibrarySection[]> {
|
||||||
|
const base = normalizeUrl(server.url);
|
||||||
|
const url = `${base}/library/sections?X-Plex-Token=${encodeURIComponent(server.token)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: timeoutSignal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log(
|
||||||
|
`[media-server] failed to list Plex sections: HTTP ${res.status} server="${server.name}"`
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
MediaContainer?: {
|
||||||
|
Directory?: Array<{ key: string; title: string; type: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const directories = data?.MediaContainer?.Directory;
|
||||||
|
if (!Array.isArray(directories)) return [];
|
||||||
|
|
||||||
|
return directories.map((d) => ({
|
||||||
|
key: d.key,
|
||||||
|
title: d.title,
|
||||||
|
type: d.type,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.log(
|
||||||
|
`[media-server] error listing Plex sections: ${err instanceof Error ? err.message : String(err)} server="${server.name}"`
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Jellyfin ──
|
||||||
|
|
||||||
|
private async jellyfinScan(server: MediaServer): Promise<ScanResult> {
|
||||||
|
const base = normalizeUrl(server.url);
|
||||||
|
const url = `${base}/Library/Refresh`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Token': server.token,
|
||||||
|
},
|
||||||
|
signal: timeoutSignal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok || res.status === 204) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Jellyfin library refresh triggered',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Jellyfin scan failed: HTTP ${res.status} ${res.statusText}`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Jellyfin scan error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testJellyfinConnection(server: MediaServer): Promise<ConnectionTestResult> {
|
||||||
|
const base = normalizeUrl(server.url);
|
||||||
|
const url = `${base}/System/Info`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Token': server.token,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
signal: timeoutSignal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Connection failed: HTTP ${res.status} ${res.statusText}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { ServerName?: string };
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Connection successful',
|
||||||
|
serverName: data?.ServerName ?? undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Connection error: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/services/missing-file-scanner.ts
Normal file
171
src/services/missing-file-scanner.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { eq, and, isNotNull, sql } from 'drizzle-orm';
|
||||||
|
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
|
import type * as schema from '../db/schema/index';
|
||||||
|
import { contentItems } from '../db/schema/index';
|
||||||
|
import { systemConfig } from '../db/schema/index';
|
||||||
|
import { access } from 'node:fs/promises';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
checked: number;
|
||||||
|
missing: number;
|
||||||
|
duration: number; // milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadedRow {
|
||||||
|
id: number;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
const SCAN_LAST_RUN_KEY = 'missing_file_scan_last_run';
|
||||||
|
const SCAN_LAST_RESULT_KEY = 'missing_file_scan_last_result';
|
||||||
|
|
||||||
|
// ── Scanner ──
|
||||||
|
|
||||||
|
export class MissingFileScanner {
|
||||||
|
constructor(private readonly db: Db) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan all content items with status='downloaded' and a non-null filePath.
|
||||||
|
* For each, check if the file exists on disk. If not, update status to 'missing'.
|
||||||
|
* Works in batches of BATCH_SIZE to bound memory usage on large libraries.
|
||||||
|
*/
|
||||||
|
async scanAll(): Promise<ScanResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
let checked = 0;
|
||||||
|
let missing = 0;
|
||||||
|
let lastId = 0;
|
||||||
|
|
||||||
|
console.log('[missing-file-scanner] Scan started');
|
||||||
|
|
||||||
|
// Cursor-based pagination: since we mutate status from 'downloaded' to 'missing'
|
||||||
|
// during iteration, offset-based pagination would skip rows. Using id > lastId
|
||||||
|
// ensures we always pick up the next unconsumed batch.
|
||||||
|
while (true) {
|
||||||
|
const batch = await this.db
|
||||||
|
.select({ id: contentItems.id, filePath: contentItems.filePath })
|
||||||
|
.from(contentItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(contentItems.status, 'downloaded'),
|
||||||
|
isNotNull(contentItems.filePath),
|
||||||
|
sql`${contentItems.filePath} != ''`,
|
||||||
|
sql`${contentItems.id} > ${lastId}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(contentItems.id)
|
||||||
|
.limit(BATCH_SIZE);
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
const missingIds: number[] = [];
|
||||||
|
|
||||||
|
for (const row of batch as DownloadedRow[]) {
|
||||||
|
checked++;
|
||||||
|
const exists = await fileExists(row.filePath);
|
||||||
|
if (!exists) {
|
||||||
|
missingIds.push(row.id);
|
||||||
|
missing++;
|
||||||
|
console.log(`[missing-file-scanner] File missing: id=${row.id} path=${row.filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-update missing items
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
await this.markMissing(missingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance cursor to the last processed id
|
||||||
|
lastId = batch[batch.length - 1].id as number;
|
||||||
|
|
||||||
|
// If batch was smaller than BATCH_SIZE, we've exhausted the result set
|
||||||
|
if (batch.length < BATCH_SIZE) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const result: ScanResult = { checked, missing, duration };
|
||||||
|
|
||||||
|
console.log(`[missing-file-scanner] Scan completed: checked=${checked} missing=${missing} duration=${duration}ms`);
|
||||||
|
|
||||||
|
// Persist scan metadata
|
||||||
|
await this.persistScanResult(result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last scan result from system_config.
|
||||||
|
* Returns null if no scan has been run yet.
|
||||||
|
*/
|
||||||
|
async getLastScanResult(): Promise<{ lastRun: string; result: ScanResult } | null> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ key: systemConfig.key, value: systemConfig.value })
|
||||||
|
.from(systemConfig)
|
||||||
|
.where(eq(systemConfig.key, SCAN_LAST_RUN_KEY));
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const resultRows = await this.db
|
||||||
|
.select({ value: systemConfig.value })
|
||||||
|
.from(systemConfig)
|
||||||
|
.where(eq(systemConfig.key, SCAN_LAST_RESULT_KEY));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastRun: rows[0].value,
|
||||||
|
result: resultRows.length > 0 ? JSON.parse(resultRows[0].value) : { checked: 0, missing: 0, duration: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ──
|
||||||
|
|
||||||
|
private async markMissing(ids: number[]): Promise<void> {
|
||||||
|
// SQLite has a variable limit; chunk if needed, but BATCH_SIZE=100 is well within limits
|
||||||
|
await this.db
|
||||||
|
.update(contentItems)
|
||||||
|
.set({
|
||||||
|
status: 'missing',
|
||||||
|
updatedAt: sql`(datetime('now'))`,
|
||||||
|
})
|
||||||
|
.where(sql`${contentItems.id} IN (${sql.join(ids.map(id => sql`${id}`), sql`, `)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistScanResult(result: ScanResult): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const resultJson = JSON.stringify(result);
|
||||||
|
|
||||||
|
// Upsert last run timestamp
|
||||||
|
await this.db
|
||||||
|
.insert(systemConfig)
|
||||||
|
.values({ key: SCAN_LAST_RUN_KEY, value: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: systemConfig.key,
|
||||||
|
set: { value: now, updatedAt: sql`(datetime('now'))` },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert last result
|
||||||
|
await this.db
|
||||||
|
.insert(systemConfig)
|
||||||
|
.values({ key: SCAN_LAST_RESULT_KEY, value: resultJson })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: systemConfig.key,
|
||||||
|
set: { value: resultJson, updatedAt: sql`(datetime('now'))` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/services/nfo-generator.ts
Normal file
138
src/services/nfo-generator.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import type { ContentItem, Channel } from '../types/index';
|
||||||
|
|
||||||
|
// ── XML Helpers ──
|
||||||
|
|
||||||
|
/** Escape special XML characters in text content. */
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an XML element with escaped text content. Omits the element entirely if value is null/undefined/empty. */
|
||||||
|
function xmlElement(tag: string, value: string | null | undefined): string {
|
||||||
|
if (value == null || value === '') return '';
|
||||||
|
return ` <${tag}>${escapeXml(value)}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an XML element with attributes. Omits if value is null/undefined/empty. */
|
||||||
|
function xmlElementWithAttr(
|
||||||
|
tag: string,
|
||||||
|
value: string | null | undefined,
|
||||||
|
attrs: Record<string, string>
|
||||||
|
): string {
|
||||||
|
if (value == null || value === '') return '';
|
||||||
|
const attrStr = Object.entries(attrs)
|
||||||
|
.map(([k, v]) => `${k}="${escapeXml(v)}"`)
|
||||||
|
.join(' ');
|
||||||
|
return ` <${tag} ${attrStr}>${escapeXml(value)}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NFO Generation ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective content rating for an item.
|
||||||
|
* Priority: item-level → channel-level → 'NR' (Not Rated).
|
||||||
|
*/
|
||||||
|
export function resolveContentRating(
|
||||||
|
contentItem: Pick<ContentItem, 'contentRating'>,
|
||||||
|
channel: Pick<Channel, 'contentRating'> | null
|
||||||
|
): string {
|
||||||
|
return contentItem.contentRating ?? channel?.contentRating ?? 'NR';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a platform string to a genre label for NFO metadata.
|
||||||
|
* Kodi uses these to categorize content in the library UI.
|
||||||
|
*/
|
||||||
|
function platformToGenre(platform: string): string {
|
||||||
|
switch (platform) {
|
||||||
|
case 'youtube':
|
||||||
|
return 'YouTube';
|
||||||
|
case 'soundcloud':
|
||||||
|
return 'Music';
|
||||||
|
default:
|
||||||
|
return 'Online Media';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a Kodi-compatible NFO XML string for a content item.
|
||||||
|
*
|
||||||
|
* Uses `<episodedetails>` (not `<movie>`) since YouTube/SoundCloud content
|
||||||
|
* maps better to the episode model in Kodi — each video is one "episode"
|
||||||
|
* from a "show" (the channel).
|
||||||
|
*
|
||||||
|
* @param contentItem The content item to generate NFO for
|
||||||
|
* @param channel The parent channel (used for studio name and fallback rating)
|
||||||
|
* @returns A complete XML string ready to write to a .nfo file
|
||||||
|
*/
|
||||||
|
export function generateNfo(
|
||||||
|
contentItem: ContentItem,
|
||||||
|
channel: Channel | null
|
||||||
|
): string {
|
||||||
|
const rating = resolveContentRating(contentItem, channel);
|
||||||
|
const aired = contentItem.publishedAt
|
||||||
|
? contentItem.publishedAt.slice(0, 10) // YYYY-MM-DD
|
||||||
|
: null;
|
||||||
|
const genre = channel ? platformToGenre(channel.platform) : 'Online Media';
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
xmlElement('title', contentItem.title),
|
||||||
|
xmlElement('plot', contentItem.title), // Use title as plot fallback — no description field on ContentItem
|
||||||
|
xmlElement('aired', aired),
|
||||||
|
xmlElement('studio', channel?.name ?? null),
|
||||||
|
xmlElement('genre', genre),
|
||||||
|
xmlElement('mpaa', rating),
|
||||||
|
xmlElement('thumb', contentItem.thumbnailUrl),
|
||||||
|
xmlElementWithAttr('uniqueid', contentItem.platformContentId, {
|
||||||
|
type: channel?.platform ?? 'generic',
|
||||||
|
default: 'true',
|
||||||
|
}),
|
||||||
|
].filter((line) => line !== '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||||
|
'<episodedetails>',
|
||||||
|
...elements,
|
||||||
|
'</episodedetails>',
|
||||||
|
'', // trailing newline
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File Writing ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the .nfo file path from a media file path.
|
||||||
|
* Replaces the file extension with `.nfo`.
|
||||||
|
*
|
||||||
|
* @example nfoPathForMedia('/media/youtube/chan/video.mp4') → '/media/youtube/chan/video.nfo'
|
||||||
|
*/
|
||||||
|
export function nfoPathForMedia(mediaFilePath: string): string {
|
||||||
|
const ext = path.extname(mediaFilePath);
|
||||||
|
return mediaFilePath.slice(0, mediaFilePath.length - ext.length) + '.nfo';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an NFO file alongside a media file.
|
||||||
|
* Creates parent directories if they don't exist.
|
||||||
|
* Overwrites any existing .nfo file at the target path.
|
||||||
|
*
|
||||||
|
* @param nfoContent The XML string to write
|
||||||
|
* @param mediaFilePath The path to the media file (used to derive .nfo path)
|
||||||
|
* @returns The path where the .nfo file was written
|
||||||
|
*/
|
||||||
|
export async function writeNfoFile(
|
||||||
|
nfoContent: string,
|
||||||
|
mediaFilePath: string
|
||||||
|
): Promise<string> {
|
||||||
|
const nfoPath = nfoPathForMedia(mediaFilePath);
|
||||||
|
await fs.mkdir(path.dirname(nfoPath), { recursive: true });
|
||||||
|
await fs.writeFile(nfoPath, nfoContent, 'utf-8');
|
||||||
|
return nfoPath;
|
||||||
|
}
|
||||||
|
|
@ -34,18 +34,21 @@ export interface QueueState {
|
||||||
completed: number;
|
completed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
cancelled: number;
|
cancelled: number;
|
||||||
|
paused: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── QueueService ──
|
// ── QueueService ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrates the download queue lifecycle: enqueue, process with concurrency
|
* Orchestrates the download queue lifecycle: enqueue, process with concurrency
|
||||||
* control, retry on failure, cancel, and recover interrupted items on startup.
|
* control, retry on failure, cancel, pause/resume, and recover interrupted items on startup.
|
||||||
*
|
*
|
||||||
* Status transitions:
|
* Status transitions:
|
||||||
* pending → downloading → completed | failed
|
* pending → downloading → completed | failed
|
||||||
* failed → pending (retry) or failed (max attempts exhausted)
|
* failed → pending (retry) or failed (max attempts exhausted)
|
||||||
* pending | failed → cancelled
|
* pending | failed → cancelled
|
||||||
|
* pending | downloading → paused
|
||||||
|
* paused → pending (resume)
|
||||||
*
|
*
|
||||||
* Concurrency is managed via an in-memory counter — Node's single-threaded
|
* Concurrency is managed via an in-memory counter — Node's single-threaded
|
||||||
* event loop ensures processNext() is not re-entrant within a single tick.
|
* event loop ensures processNext() is not re-entrant within a single tick.
|
||||||
|
|
@ -56,6 +59,8 @@ export class QueueService {
|
||||||
private concurrency: number;
|
private concurrency: number;
|
||||||
private readonly onDownloadComplete?: (event: NotificationEvent) => void;
|
private readonly onDownloadComplete?: (event: NotificationEvent) => void;
|
||||||
private readonly onDownloadFailed?: (event: NotificationEvent) => void;
|
private readonly onDownloadFailed?: (event: NotificationEvent) => void;
|
||||||
|
/** Maps queueItemId → AbortController for in-flight downloads (used by pause to cancel yt-dlp). */
|
||||||
|
private readonly activeAbortControllers = new Map<number, AbortController>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: Db,
|
private readonly db: Db,
|
||||||
|
|
@ -231,6 +236,76 @@ export class QueueService {
|
||||||
return updated!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause a queue item. Pending items are set to 'paused' immediately.
|
||||||
|
* Downloading items have their yt-dlp process killed and are set to 'paused'.
|
||||||
|
*
|
||||||
|
* @throws Error if item not found or not in a pausable status.
|
||||||
|
*/
|
||||||
|
async pauseItem(queueItemId: number): Promise<QueueItem> {
|
||||||
|
const item = await getQueueItemById(this.db, queueItemId);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Queue item ${queueItemId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pausable: QueueStatus[] = ['pending', 'downloading'];
|
||||||
|
if (!pausable.includes(item.status)) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot pause queue item ${queueItemId} — status is '${item.status}', must be 'pending' or 'downloading'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If downloading, abort the yt-dlp process
|
||||||
|
if (item.status === 'downloading') {
|
||||||
|
const controller = this.activeAbortControllers.get(queueItemId);
|
||||||
|
if (controller) {
|
||||||
|
controller.abort('paused');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateQueueItemStatus(this.db, queueItemId, 'paused');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[queue] pause queueId=${queueItemId} contentId=${item.contentItemId} previousStatus=${item.status} status=paused`
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume a paused queue item. Sets it back to 'pending' and triggers processing.
|
||||||
|
*
|
||||||
|
* @throws Error if item not found or not paused.
|
||||||
|
*/
|
||||||
|
async resumeItem(queueItemId: number): Promise<QueueItem> {
|
||||||
|
const item = await getQueueItemById(this.db, queueItemId);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Queue item ${queueItemId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status !== 'paused') {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot resume queue item ${queueItemId} — status is '${item.status}', expected 'paused'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateQueueItemStatus(this.db, queueItemId, 'pending', {
|
||||||
|
error: null,
|
||||||
|
startedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset content status to queued
|
||||||
|
await updateContentItem(this.db, item.contentItemId, { status: 'queued' });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[queue] resume queueId=${queueItemId} contentId=${item.contentItemId} status=pending`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.processNext();
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover items that were stuck in 'downloading' status after a crash/restart.
|
* Recover items that were stuck in 'downloading' status after a crash/restart.
|
||||||
* Resets them to 'pending' so they'll be picked up again.
|
* Resets them to 'pending' so they'll be picked up again.
|
||||||
|
|
@ -281,6 +356,15 @@ export class QueueService {
|
||||||
this.processNext();
|
this.processNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer platform from a URL for ad-hoc downloads.
|
||||||
|
*/
|
||||||
|
private inferPlatformFromUrl(url: string): string {
|
||||||
|
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
|
||||||
|
if (url.includes('soundcloud.com')) return 'soundcloud';
|
||||||
|
return 'generic';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ──
|
// ── Internal ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -289,6 +373,8 @@ export class QueueService {
|
||||||
*/
|
*/
|
||||||
private async processItem(queueItem: QueueItem): Promise<void> {
|
private async processItem(queueItem: QueueItem): Promise<void> {
|
||||||
const logPrefix = `[queue] process queueId=${queueItem.id} contentId=${queueItem.contentItemId}`;
|
const logPrefix = `[queue] process queueId=${queueItem.id} contentId=${queueItem.contentItemId}`;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this.activeAbortControllers.set(queueItem.id, abortController);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Transition to downloading
|
// Transition to downloading
|
||||||
|
|
@ -304,22 +390,33 @@ export class QueueService {
|
||||||
throw new Error(`Content item ${queueItem.contentItemId} not found`);
|
throw new Error(`Content item ${queueItem.contentItemId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(this.db, contentItem.channelId);
|
const channel = contentItem.channelId
|
||||||
if (!channel) {
|
? await getChannelById(this.db, contentItem.channelId)
|
||||||
|
: null;
|
||||||
|
if (contentItem.channelId && !channel) {
|
||||||
throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`);
|
throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve format profile: channel-specific > default > undefined
|
// Resolve format profile: channel-specific > default > undefined
|
||||||
let formatProfile = undefined;
|
let formatProfile = undefined;
|
||||||
if (channel.formatProfileId) {
|
if (channel?.formatProfileId) {
|
||||||
formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined;
|
formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined;
|
||||||
}
|
}
|
||||||
if (!formatProfile) {
|
if (!formatProfile) {
|
||||||
formatProfile = await getDefaultFormatProfile(this.db) ?? undefined;
|
formatProfile = await getDefaultFormatProfile(this.db) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute download
|
// Execute download — ad-hoc items (no channel) pass null with platform/channelName overrides
|
||||||
await this.downloadService.downloadItem(contentItem, channel, formatProfile);
|
if (channel) {
|
||||||
|
await this.downloadService.downloadItem(contentItem, channel, formatProfile);
|
||||||
|
} else {
|
||||||
|
// Ad-hoc download: infer platform from URL, use stored title metadata
|
||||||
|
const platform = this.inferPlatformFromUrl(contentItem.url);
|
||||||
|
await this.downloadService.downloadItem(contentItem, null, formatProfile, {
|
||||||
|
platform: platform as import('../types/index').Platform,
|
||||||
|
channelName: 'Ad-hoc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Success — mark completed
|
// Success — mark completed
|
||||||
await updateQueueItemStatus(this.db, queueItem.id, 'completed', {
|
await updateQueueItemStatus(this.db, queueItem.id, 'completed', {
|
||||||
|
|
@ -329,7 +426,7 @@ export class QueueService {
|
||||||
// Record downloaded history event
|
// Record downloaded history event
|
||||||
await createHistoryEvent(this.db, {
|
await createHistoryEvent(this.db, {
|
||||||
contentItemId: queueItem.contentItemId,
|
contentItemId: queueItem.contentItemId,
|
||||||
channelId: channel.id,
|
channelId: channel?.id ?? null,
|
||||||
eventType: 'downloaded',
|
eventType: 'downloaded',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
details: {
|
details: {
|
||||||
|
|
@ -346,8 +443,8 @@ export class QueueService {
|
||||||
try {
|
try {
|
||||||
this.onDownloadComplete({
|
this.onDownloadComplete({
|
||||||
contentTitle: contentItem.title,
|
contentTitle: contentItem.title,
|
||||||
channelName: channel.name,
|
channelName: channel?.name ?? 'Ad-hoc',
|
||||||
platform: channel.platform,
|
platform: channel?.platform ?? 'generic',
|
||||||
url: contentItem.url,
|
url: contentItem.url,
|
||||||
filePath: contentItem.filePath ?? undefined,
|
filePath: contentItem.filePath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -358,10 +455,15 @@ export class QueueService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
// If aborted due to pause, don't treat as a failure — status is already set by pauseItem
|
||||||
const newAttempts = queueItem.attempts + 1;
|
if (abortController.signal.aborted) {
|
||||||
const exhausted = newAttempts >= queueItem.maxAttempts;
|
console.log(`${logPrefix} aborted (paused)`);
|
||||||
const newStatus: QueueStatus = exhausted ? 'failed' : 'pending';
|
// Don't increment attempts or record failure — the item was paused by the user
|
||||||
|
} else {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
const newAttempts = queueItem.attempts + 1;
|
||||||
|
const exhausted = newAttempts >= queueItem.maxAttempts;
|
||||||
|
const newStatus: QueueStatus = exhausted ? 'failed' : 'pending';
|
||||||
|
|
||||||
await updateQueueItemStatus(this.db, queueItem.id, newStatus, {
|
await updateQueueItemStatus(this.db, queueItem.id, newStatus, {
|
||||||
attempts: newAttempts,
|
attempts: newAttempts,
|
||||||
|
|
@ -421,8 +523,10 @@ export class QueueService {
|
||||||
`[queue] notification callback error: ${notifyErr instanceof Error ? notifyErr.message : String(notifyErr)}`
|
`[queue] notification callback error: ${notifyErr instanceof Error ? notifyErr.message : String(notifyErr)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
this.activeAbortControllers.delete(queueItem.id);
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
this.processNext();
|
this.processNext();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { PlatformRegistry, PlatformSource, FetchRecentContentOptions } from
|
||||||
import type { RateLimiter } from './rate-limiter';
|
import type { RateLimiter } from './rate-limiter';
|
||||||
import { YtDlpError } from '../sources/yt-dlp';
|
import { YtDlpError } from '../sources/yt-dlp';
|
||||||
import type { EventBus } from './event-bus';
|
import type { EventBus } from './event-bus';
|
||||||
|
import { matchesKeywordFilter } from './keyword-filter';
|
||||||
import {
|
import {
|
||||||
getEnabledChannels,
|
getEnabledChannels,
|
||||||
updateChannel,
|
updateChannel,
|
||||||
|
|
@ -236,9 +237,19 @@ export class SchedulerService {
|
||||||
(item) => !existingIds.has(item.platformContentId)
|
(item) => !existingIds.has(item.platformContentId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6b. Apply keyword filter — exclude/include patterns from channel settings
|
||||||
|
const filteredItems = newItems.filter((item) =>
|
||||||
|
matchesKeywordFilter(item.title, channel.includeKeywords, channel.excludeKeywords)
|
||||||
|
);
|
||||||
|
if (filteredItems.length < newItems.length) {
|
||||||
|
console.log(
|
||||||
|
`[scheduler] Keyword filter: ${newItems.length - filteredItems.length} of ${newItems.length} new items filtered out for channel ${channel.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Insert new items (check abort between each)
|
// 7. Insert new items (check abort between each)
|
||||||
let insertedCount = 0;
|
let insertedCount = 0;
|
||||||
for (const item of newItems) {
|
for (const item of filteredItems) {
|
||||||
// Check if scan was cancelled
|
// Check if scan was cancelled
|
||||||
if (effectiveSignal.aborted) {
|
if (effectiveSignal.aborted) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -310,7 +321,7 @@ export class SchedulerService {
|
||||||
// This runs after the scan result is returned — enrichment updates DB records
|
// This runs after the scan result is returned — enrichment updates DB records
|
||||||
// and triggers a final cache invalidation when done.
|
// and triggers a final cache invalidation when done.
|
||||||
if (insertedCount > 0 && !effectiveSignal.aborted) {
|
if (insertedCount > 0 && !effectiveSignal.aborted) {
|
||||||
this.enrichNewItems(channel, newItems, existingIds, rateLimitDelay, source, effectiveSignal)
|
this.enrichNewItems(channel, filteredItems, existingIds, rateLimitDelay, source, effectiveSignal)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(
|
console.error(
|
||||||
`[scheduler] Background enrichment failed for channel ${channel.id}:`,
|
`[scheduler] Background enrichment failed for channel ${channel.id}:`,
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,21 @@ export interface ContentCounts {
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-content-type counts for a channel. */
|
||||||
|
export interface ContentTypeCounts {
|
||||||
|
video: number;
|
||||||
|
audio: number;
|
||||||
|
livestream: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** App-wide settings (check interval, concurrent downloads). */
|
/** App-wide settings (check interval, concurrent downloads). */
|
||||||
export interface AppSettingsResponse {
|
export interface AppSettingsResponse {
|
||||||
checkInterval: number;
|
checkInterval: number;
|
||||||
concurrentDownloads: number;
|
concurrentDownloads: number;
|
||||||
|
outputTemplate: string;
|
||||||
|
nfoEnabled: boolean;
|
||||||
|
timezone: string;
|
||||||
|
theme: 'dark' | 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */
|
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export const ContentStatus = {
|
||||||
Downloaded: 'downloaded',
|
Downloaded: 'downloaded',
|
||||||
Failed: 'failed',
|
Failed: 'failed',
|
||||||
Ignored: 'ignored',
|
Ignored: 'ignored',
|
||||||
|
Missing: 'missing',
|
||||||
} as const;
|
} as const;
|
||||||
export type ContentStatus = (typeof ContentStatus)[keyof typeof ContentStatus];
|
export type ContentStatus = (typeof ContentStatus)[keyof typeof ContentStatus];
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ export const QueueStatus = {
|
||||||
Completed: 'completed',
|
Completed: 'completed',
|
||||||
Failed: 'failed',
|
Failed: 'failed',
|
||||||
Cancelled: 'cancelled',
|
Cancelled: 'cancelled',
|
||||||
|
Paused: 'paused',
|
||||||
} as const;
|
} as const;
|
||||||
export type QueueStatus = (typeof QueueStatus)[keyof typeof QueueStatus];
|
export type QueueStatus = (typeof QueueStatus)[keyof typeof QueueStatus];
|
||||||
|
|
||||||
|
|
@ -77,6 +79,9 @@ export interface Channel {
|
||||||
bannerUrl: string | null;
|
bannerUrl: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
subscriberCount: number | null;
|
subscriberCount: number | null;
|
||||||
|
contentRating: string | null;
|
||||||
|
includeKeywords: string | null;
|
||||||
|
excludeKeywords: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
|
|
@ -85,7 +90,7 @@ export interface Channel {
|
||||||
|
|
||||||
export interface ContentItem {
|
export interface ContentItem {
|
||||||
id: number;
|
id: number;
|
||||||
channelId: number;
|
channelId: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
platformContentId: string;
|
platformContentId: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -100,6 +105,7 @@ export interface ContentItem {
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
downloadedAt: string | null;
|
downloadedAt: string | null;
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
|
contentRating: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -144,6 +150,7 @@ export interface FormatProfile {
|
||||||
embedChapters: boolean;
|
embedChapters: boolean;
|
||||||
embedThumbnail: boolean;
|
embedThumbnail: boolean;
|
||||||
sponsorBlockRemove: string | null; // comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
|
sponsorBlockRemove: string | null; // comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
|
||||||
|
outputTemplate: string | null; // per-profile path template override e.g. '{platform}/{channel}/{title}.{ext}'
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +200,24 @@ export interface SystemConfig {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MediaServerType = {
|
||||||
|
Plex: 'plex',
|
||||||
|
Jellyfin: 'jellyfin',
|
||||||
|
} as const;
|
||||||
|
export type MediaServerType = (typeof MediaServerType)[keyof typeof MediaServerType];
|
||||||
|
|
||||||
|
export interface MediaServer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: MediaServerType;
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
librarySection: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
id: number;
|
id: number;
|
||||||
channelId: number;
|
channelId: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue