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,
|
||||
"tag": "0011_add_youtube_enhancements",
|
||||
"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.platform).toBe('soundcloud');
|
||||
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
|
||||
});
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ describe('Channel API', () => {
|
|||
method: 'POST',
|
||||
url: '/api/v1/channel',
|
||||
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);
|
||||
|
|
@ -319,7 +319,7 @@ describe('Channel API', () => {
|
|||
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({
|
||||
stdout: JSON.stringify({
|
||||
channel: 'Default Mode Channel',
|
||||
|
|
@ -343,8 +343,8 @@ describe('Channel API', () => {
|
|||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.monitoringMode).toBe('all');
|
||||
expect(body.monitoringEnabled).toBe(true);
|
||||
expect(body.monitoringMode).toBe('none');
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const noContentChannel = await createChannel(db, {
|
||||
name: 'Empty Channel',
|
||||
|
|
@ -306,4 +320,89 @@ describe('content-api', () => {
|
|||
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 { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
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 ──
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -334,7 +344,7 @@ describe('DownloadService', () => {
|
|||
containerFormat: 'mkv',
|
||||
isDefault: false,
|
||||
subtitleLanguages: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
|
@ -388,7 +398,7 @@ describe('DownloadService', () => {
|
|||
containerFormat: null,
|
||||
isDefault: false,
|
||||
subtitleLanguages: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
|
@ -642,7 +652,7 @@ describe('DownloadService', () => {
|
|||
containerFormat: null,
|
||||
isDefault: false,
|
||||
subtitleLanguages: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
|
@ -686,7 +696,7 @@ describe('DownloadService', () => {
|
|||
containerFormat: 'mkv',
|
||||
isDefault: false,
|
||||
subtitleLanguages: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
|
@ -738,7 +748,7 @@ describe('DownloadService', () => {
|
|||
containerFormat: null,
|
||||
isDefault: false,
|
||||
subtitleLanguages: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
|
@ -761,4 +771,107 @@ describe('DownloadService', () => {
|
|||
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 { join } from 'node:path';
|
||||
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;
|
||||
|
||||
|
|
@ -213,4 +213,230 @@ describe('FileOrganizer', () => {
|
|||
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
|
||||
expect(body.grabAllEnabled).toBe(false);
|
||||
expect(body.grabAllOrder).toBe('newest');
|
||||
expect(body.scanLimit).toBe(100);
|
||||
expect(body.scanLimit).toBe(500);
|
||||
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 ──
|
||||
|
||||
describe('getState', () => {
|
||||
|
|
@ -539,6 +662,7 @@ describe('QueueService', () => {
|
|||
expect(state.failed).toBe(1);
|
||||
expect(state.downloading).toBe(0);
|
||||
expect(state.cancelled).toBe(0);
|
||||
expect(state.paused).toBe(0);
|
||||
});
|
||||
|
||||
it('returns all zeros when queue is empty', async () => {
|
||||
|
|
@ -551,6 +675,7 @@ describe('QueueService', () => {
|
|||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
paused: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -179,14 +179,12 @@ describe('Scan API', () => {
|
|||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.statusCode).toBe(202);
|
||||
const body = res.json();
|
||||
expect(body).toMatchObject({
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
status: 'success',
|
||||
newItems: 3,
|
||||
totalFetched: 3,
|
||||
status: 'started',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -293,7 +291,7 @@ describe('Scan API', () => {
|
|||
errors: expect.any(Number),
|
||||
});
|
||||
// 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 () => {
|
||||
|
|
|
|||
|
|
@ -610,6 +610,7 @@ describe('SchedulerService', () => {
|
|||
qualityMetadata: null,
|
||||
status: 'monitored',
|
||||
monitored: false,
|
||||
contentRating: null,
|
||||
publishedAt: null,
|
||||
downloadedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
|
|
@ -659,6 +660,180 @@ describe('SchedulerService', () => {
|
|||
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 ──
|
||||
|
||||
it("creates items with monitored=false when channel monitoringMode is 'none'", async () => {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
|
|||
bannerUrl: null,
|
||||
description: null,
|
||||
subscriberCount: null,
|
||||
includeKeywords: null,
|
||||
excludeKeywords: null,
|
||||
contentRating: null,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
lastCheckedAt: null,
|
||||
|
|
@ -334,7 +337,7 @@ describe('YouTubeSource', () => {
|
|||
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['--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
|
||||
|
|
@ -443,7 +446,7 @@ describe('YouTubeSource', () => {
|
|||
|
||||
expect(mockExecYtDlp).toHaveBeenCalledWith(
|
||||
['--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(
|
||||
['--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',
|
||||
isDefault: false,
|
||||
subtitleLanguages: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -9,17 +9,20 @@ import type { Channel, Platform, MonitoringMode } from '../../types/index';
|
|||
/** Fields needed to create a new channel (auto-generated fields excluded). */
|
||||
export type CreateChannelData = Omit<
|
||||
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'];
|
||||
bannerUrl?: string | null;
|
||||
description?: string | null;
|
||||
subscriberCount?: number | null;
|
||||
includeKeywords?: string | null;
|
||||
excludeKeywords?: string | null;
|
||||
contentRating?: string | null;
|
||||
};
|
||||
|
||||
/** Fields that can be updated on an existing channel. */
|
||||
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>;
|
||||
|
|
@ -47,6 +50,8 @@ export async function createChannel(
|
|||
bannerUrl: data.bannerUrl ?? null,
|
||||
description: data.description ?? null,
|
||||
subscriberCount: data.subscriberCount ?? null,
|
||||
includeKeywords: data.includeKeywords ?? null,
|
||||
excludeKeywords: data.excludeKeywords ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -200,5 +205,8 @@ function mapRow(row: typeof channels.$inferSelect): Channel {
|
|||
updatedAt: row.updatedAt,
|
||||
lastCheckedAt: row.lastCheckedAt,
|
||||
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 { contentItems } from '../schema/index';
|
||||
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
|
||||
import type { ContentCounts } from '../../types/api';
|
||||
import type { ContentCounts, ContentTypeCounts } from '../../types/api';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
/** Fields needed to create a new content item. */
|
||||
export interface CreateContentItemData {
|
||||
channelId: number;
|
||||
channelId: number | null;
|
||||
title: string;
|
||||
platformContentId: string;
|
||||
url: string;
|
||||
|
|
@ -29,6 +29,7 @@ export interface UpdateContentItemData {
|
|||
qualityMetadata?: QualityInfo | null;
|
||||
status?: ContentStatus;
|
||||
downloadedAt?: string | null;
|
||||
contentRating?: string | null;
|
||||
}
|
||||
|
||||
type Db = LibSQLDatabase<typeof schema>;
|
||||
|
|
@ -43,16 +44,22 @@ export async function createContentItem(
|
|||
db: Db,
|
||||
data: CreateContentItemData
|
||||
): Promise<ContentItem | null> {
|
||||
// Check for existing item first — dedup by (channelId, platformContentId)
|
||||
const existing = await db
|
||||
.select({ id: contentItems.id })
|
||||
.from(contentItems)
|
||||
.where(
|
||||
and(
|
||||
// Check for existing item — dedup by (channelId, platformContentId) for channel items,
|
||||
// or by platformContentId alone for ad-hoc items (channelId=null)
|
||||
const dedupConditions = data.channelId !== null
|
||||
? and(
|
||||
eq(contentItems.channelId, data.channelId),
|
||||
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);
|
||||
|
||||
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(
|
||||
db: Db,
|
||||
channelId: number,
|
||||
channelId: number | null,
|
||||
platformContentId: string
|
||||
): Promise<ContentItem | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(contentItems)
|
||||
.where(
|
||||
and(
|
||||
const conditions = channelId !== null
|
||||
? and(
|
||||
eq(contentItems.channelId, channelId),
|
||||
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);
|
||||
|
||||
return rows.length > 0 ? mapRow(rows[0]) : null;
|
||||
|
|
@ -361,6 +373,35 @@ function buildContentFilterConditions(filters?: ContentItemFilters) {
|
|||
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 ──
|
||||
|
||||
/**
|
||||
|
|
@ -386,7 +427,8 @@ export async function getContentCountsByChannelIds(
|
|||
|
||||
const map = new Map<number, ContentCounts>();
|
||||
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),
|
||||
monitored: Number(row.monitored),
|
||||
downloaded: Number(row.downloaded),
|
||||
|
|
@ -444,6 +486,7 @@ function mapRow(row: typeof contentItems.$inferSelect): ContentItem {
|
|||
publishedAt: row.publishedAt ?? null,
|
||||
downloadedAt: row.downloadedAt ?? null,
|
||||
monitored: row.monitored,
|
||||
contentRating: row.contentRating ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ function mapRow(row: typeof formatProfiles.$inferSelect): FormatProfile {
|
|||
embedChapters: row.embedChapters,
|
||||
embedThumbnail: row.embedThumbnail,
|
||||
sponsorBlockRemove: row.sponsorBlockRemove ?? null,
|
||||
outputTemplate: row.outputTemplate ?? null,
|
||||
createdAt: row.createdAt,
|
||||
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,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
paused: 0,
|
||||
};
|
||||
|
||||
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_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';
|
||||
|
||||
// ── Read / Write ──
|
||||
|
|
@ -86,6 +90,7 @@ export async function seedAppDefaults(db: Db): Promise<void> {
|
|||
const defaults: Array<{ key: string; value: string }> = [
|
||||
{ key: APP_CHECK_INTERVAL, value: appConfig.scheduler.defaultCheckInterval.toString() },
|
||||
{ key: APP_CONCURRENT_DOWNLOADS, value: appConfig.concurrentDownloads.toString() },
|
||||
{ key: APP_OUTPUT_TEMPLATE, value: '{platform}/{channel}/{title}.{ext}' },
|
||||
];
|
||||
|
||||
for (const { key, value } of defaults) {
|
||||
|
|
|
|||
|
|
@ -31,4 +31,7 @@ export const channels = sqliteTable('channels', {
|
|||
bannerUrl: text('banner_url'),
|
||||
description: text('description'),
|
||||
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', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
channelId: integer('channel_id')
|
||||
.notNull()
|
||||
.references(() => channels.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
platformContentId: text('platform_content_id').notNull(),
|
||||
|
|
@ -17,11 +16,12 @@ export const contentItems = sqliteTable('content_items', {
|
|||
fileSize: integer('file_size'), // bytes
|
||||
format: text('format'), // container format e.g. 'mp4', 'webm', 'mp3'
|
||||
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'),
|
||||
publishedAt: text('published_at'), // ISO datetime from platform (nullable)
|
||||
downloadedAt: text('downloaded_at'), // ISO datetime when download completed (nullable)
|
||||
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')
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
|
|
@ -44,6 +44,7 @@ export const formatProfiles = sqliteTable('format_profiles', {
|
|||
embedChapters: integer('embed_chapters', { 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'
|
||||
outputTemplate: text('output_template'), // per-profile path template override e.g. '{platform}/{channel}/{title}.{ext}'
|
||||
createdAt: text('created_at')
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export { downloadHistory } from './history';
|
|||
export { notificationSettings } from './notifications';
|
||||
export { platformSettings } from './platform-settings';
|
||||
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" />
|
||||
</head>
|
||||
<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>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { Channels } from './pages/Channels';
|
||||
import { ChannelDetail } from './pages/ChannelDetail';
|
||||
import { Library } from './pages/Library';
|
||||
|
|
@ -10,6 +11,8 @@ import { SettingsPage } from './pages/Settings';
|
|||
import { SystemPage } from './pages/System';
|
||||
|
||||
function AuthenticatedLayout() {
|
||||
// Apply theme from settings to documentElement at the app root
|
||||
useTheme();
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<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();
|
||||
|
||||
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),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(channelKeys.detail(id), updated);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tansta
|
|||
import { apiClient } from '../client';
|
||||
import { queueKeys } from './useQueue';
|
||||
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 ──
|
||||
|
||||
|
|
@ -31,6 +31,8 @@ export const contentKeys = {
|
|||
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
||||
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
|
||||
['content', 'channel', channelId, 'paginated', filters] as const,
|
||||
countsByType: (channelId: number) =>
|
||||
['content', 'channel', channelId, 'counts-by-type'] as const,
|
||||
};
|
||||
|
||||
// ── 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 ──
|
||||
|
||||
/** 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;
|
||||
subtitleLanguages?: string | null;
|
||||
embedSubtitles?: boolean;
|
||||
outputTemplate?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateFormatProfileInput {
|
||||
|
|
@ -40,6 +41,7 @@ interface UpdateFormatProfileInput {
|
|||
isDefault?: boolean;
|
||||
subtitleLanguages?: string | null;
|
||||
embedSubtitles?: boolean;
|
||||
outputTemplate?: string | null;
|
||||
}
|
||||
|
||||
// ── Mutations ──
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../client';
|
||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||
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,
|
||||
appSettings: ['system', 'appSettings'] as const,
|
||||
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
|
||||
missingScanStatus: ['system', 'missingScanStatus'] as const,
|
||||
};
|
||||
|
||||
// ── 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 type { FormatProfile } from '@shared/types/index';
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ export interface FormatProfileFormValues {
|
|||
embedChapters: boolean;
|
||||
embedThumbnail: boolean;
|
||||
sponsorBlockRemove: string | null;
|
||||
outputTemplate: string | null;
|
||||
}
|
||||
|
||||
interface FormatProfileFormProps {
|
||||
|
|
@ -98,6 +99,38 @@ export function FormatProfileForm({
|
|||
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
|
||||
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
|
||||
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(
|
||||
(e: FormEvent) => {
|
||||
|
|
@ -115,9 +148,10 @@ export function FormatProfileForm({
|
|||
embedChapters,
|
||||
embedThumbnail,
|
||||
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 (
|
||||
|
|
@ -312,6 +346,39 @@ export function FormatProfileForm({
|
|||
</span>
|
||||
</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 */}
|
||||
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
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 { UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ interface HealthStatusProps {
|
|||
}
|
||||
|
||||
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
|
||||
const timezone = useTimezone();
|
||||
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
|
||||
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
|
||||
|
||||
|
|
@ -292,7 +294,7 @@ function YtDlpDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
|||
{/* Last Updated */}
|
||||
{!ytdlpLoading && ytdlpStatus && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
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,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TubearrLogo } from './TubearrLogo';
|
||||
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
|
||||
import { AddUrlModal } from './AddUrlModal';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', icon: Radio, label: 'Channels' },
|
||||
|
|
@ -24,6 +26,7 @@ const NAV_ITEMS = [
|
|||
|
||||
export function Sidebar() {
|
||||
const wsConnected = useDownloadProgressConnection();
|
||||
const [showAddUrl, setShowAddUrl] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
|
||||
|
|
@ -129,6 +132,38 @@ export function Sidebar() {
|
|||
))}
|
||||
</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 */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -163,6 +198,8 @@ export function Sidebar() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddUrlModal open={showAddUrl} onClose={() => setShowAddUrl(false)} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ const STATUS_STYLES: Record<string, BadgeStyle> = {
|
|||
downloading: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
|
||||
failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-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)' },
|
||||
// Queue statuses
|
||||
pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-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)' },
|
||||
// Check statuses
|
||||
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 { SkeletonActivityList } from '../components/Skeleton';
|
||||
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';
|
||||
|
||||
// ── 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 {
|
||||
return type
|
||||
.split('_')
|
||||
|
|
@ -58,6 +46,7 @@ const EVENT_TYPES = [
|
|||
// ── Component ──
|
||||
|
||||
export function ActivityPage() {
|
||||
const timezone = useTimezone();
|
||||
const [activeTab, setActiveTab] = useState<'history' | 'recent'>('history');
|
||||
const [page, setPage] = useState(1);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({
|
||||
|
|
@ -176,7 +165,7 @@ export function ActivityPage() {
|
|||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(item.createdAt)}
|
||||
{formatTimestamp(item.createdAt, timezone)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useBulkSelection } from '../hooks/useBulkSelection';
|
||||
import {
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
ChevronUp,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
Film,
|
||||
Grid3X3,
|
||||
LayoutList,
|
||||
|
|
@ -26,12 +27,13 @@ import {
|
|||
Users,
|
||||
} from 'lucide-react';
|
||||
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 { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { PlatformBadge } from '../components/PlatformBadge';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { RatingBadge, RatingPicker } from '../components/RatingBadge';
|
||||
import { QualityLabel } from '../components/QualityLabel';
|
||||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
||||
|
|
@ -42,7 +44,8 @@ import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
|||
import { Modal } from '../components/Modal';
|
||||
import { useToast } from '../components/Toast';
|
||||
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';
|
||||
|
||||
// ── Helpers ──
|
||||
|
|
@ -68,25 +71,47 @@ const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
|
|||
// ── Component ──
|
||||
|
||||
export function ChannelDetail() {
|
||||
const timezone = useTimezone();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const channelId = parseInt(id ?? '0', 10);
|
||||
|
||||
// ── Data hooks ──
|
||||
const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId);
|
||||
const { data: formatProfiles } = useFormatProfiles();
|
||||
const { data: playlistData } = useChannelPlaylists(channelId);
|
||||
const { data: contentTypeCounts } = useContentTypeCounts(channelId);
|
||||
|
||||
// ── Content type tab (URL-driven) ──
|
||||
const activeTab = searchParams.get('tab') ?? 'all';
|
||||
|
||||
// ── Content pagination state ──
|
||||
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 [contentStatusFilter, setContentStatusFilter] = useState('');
|
||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
||||
const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null);
|
||||
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
||||
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(() => ({
|
||||
page: contentPage,
|
||||
pageSize: 50,
|
||||
|
|
@ -116,6 +141,7 @@ export function ChannelDetail() {
|
|||
const toggleMonitored = useToggleMonitored(channelId);
|
||||
const refreshPlaylists = useRefreshPlaylists(channelId);
|
||||
const bulkMonitored = useBulkMonitored(channelId);
|
||||
const updateContentRating = useUpdateContentRating(channelId);
|
||||
|
||||
// ── Scan state (WebSocket-driven) ──
|
||||
const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId);
|
||||
|
|
@ -126,6 +152,10 @@ export function ChannelDetail() {
|
|||
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
||||
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
||||
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();
|
||||
|
||||
// ── Collapsible header ──
|
||||
|
|
@ -153,6 +183,18 @@ export function ChannelDetail() {
|
|||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (downloadContent.isError) {
|
||||
|
|
@ -188,6 +230,32 @@ export function ChannelDetail() {
|
|||
);
|
||||
}, [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(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setMonitoringMode.mutate({ monitoringMode: e.target.value });
|
||||
|
|
@ -568,6 +636,19 @@ export function ChannelDetail() {
|
|||
sortable: true,
|
||||
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',
|
||||
label: 'Quality',
|
||||
|
|
@ -583,7 +664,7 @@ export function ChannelDetail() {
|
|||
render: (item) => (
|
||||
<span
|
||||
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)}
|
||||
</span>
|
||||
|
|
@ -597,7 +678,7 @@ export function ChannelDetail() {
|
|||
render: (item) => (
|
||||
<span
|
||||
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)}
|
||||
</span>
|
||||
|
|
@ -626,7 +707,7 @@ export function ChannelDetail() {
|
|||
),
|
||||
},
|
||||
],
|
||||
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll],
|
||||
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
|
||||
);
|
||||
|
||||
// ── Render helpers ──
|
||||
|
|
@ -1197,6 +1278,18 @@ export function ChannelDetail() {
|
|||
</select>
|
||||
</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 */}
|
||||
<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' }}>
|
||||
|
|
@ -1263,6 +1356,164 @@ export function ChannelDetail() {
|
|||
</button>
|
||||
</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>
|
||||
|
||||
|
|
@ -1280,11 +1531,163 @@ export function ChannelDetail() {
|
|||
padding: 'var(--space-4) var(--space-5)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
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
|
||||
style={{
|
||||
fontSize: 'var(--font-size-md)',
|
||||
|
|
@ -1337,23 +1740,6 @@ export function ChannelDetail() {
|
|||
<option value="failed">Failed</option>
|
||||
<option value="ignored">Ignored</option>
|
||||
</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 */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -1424,6 +1810,7 @@ export function ChannelDetail() {
|
|||
<LayoutList size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sort & Group controls */}
|
||||
<SortGroupBar
|
||||
|
|
@ -1504,12 +1891,12 @@ export function ChannelDetail() {
|
|||
alignItems: 'center',
|
||||
gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3) var(--space-5)',
|
||||
backgroundColor: 'rgba(30, 32, 40, 0.75)',
|
||||
backgroundColor: 'var(--glass-bg)',
|
||||
backdropFilter: '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)',
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
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 { StatusBadge } from '../components/StatusBadge';
|
||||
import { QualityLabel } from '../components/QualityLabel';
|
||||
|
|
@ -9,7 +9,7 @@ import { Pagination } from '../components/Pagination';
|
|||
import { SearchBar } from '../components/SearchBar';
|
||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||
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 { formatDuration, formatFileSize } from '../utils/format';
|
||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
||||
|
|
@ -44,6 +44,7 @@ export function Library() {
|
|||
// Queries
|
||||
const { data, isLoading, error, refetch } = useLibraryContent(filters);
|
||||
const { data: channels } = useChannels();
|
||||
const requeue = useRequeueContent();
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
|
|
@ -80,6 +81,7 @@ export function Library() {
|
|||
{ value: 'downloading', label: 'Downloading' },
|
||||
{ value: 'downloaded', label: 'Downloaded' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'missing', label: 'Missing' },
|
||||
{ value: 'ignored', label: 'Ignored' },
|
||||
],
|
||||
},
|
||||
|
|
@ -260,8 +262,30 @@ export function Library() {
|
|||
</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
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { SkeletonQueueList } from '../components/Skeleton';
|
|||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||
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';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
function formatTime(iso: string | null, timezone: string): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
|
|
@ -19,7 +21,7 @@ function formatTime(iso: string | null): string {
|
|||
if (diffMs < 60_000) return 'just now';
|
||||
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m 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 ──
|
||||
|
|
@ -28,6 +30,7 @@ const STATUS_TABS: { value: QueueStatus | ''; label: string }[] = [
|
|||
{ value: '', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'downloading', label: 'Downloading' },
|
||||
{ value: 'paused', label: 'Paused' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
];
|
||||
|
|
@ -47,6 +50,7 @@ function QueueItemProgress({ item }: { item: QueueItem }) {
|
|||
// ── Component ──
|
||||
|
||||
export function Queue() {
|
||||
const timezone = useTimezone();
|
||||
const [statusFilter, setStatusFilter] = useState<QueueStatus | ''>('');
|
||||
|
||||
// Query with 5s auto-refresh
|
||||
|
|
@ -144,7 +148,7 @@ export function Queue() {
|
|||
width: '110px',
|
||||
render: (item) => (
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{formatTime(item.startedAt)}
|
||||
{formatTime(item.startedAt, timezone)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
@ -154,16 +158,46 @@ export function Queue() {
|
|||
width: '110px',
|
||||
render: (item) => (
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{formatTime(item.completedAt)}
|
||||
{formatTime(item.completedAt, timezone)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
width: '100px',
|
||||
width: '120px',
|
||||
render: (item) => (
|
||||
<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' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -179,7 +213,7 @@ export function Queue() {
|
|||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'pending' && (
|
||||
{(item.status === 'pending' || item.status === 'paused') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -187,7 +221,7 @@ export function Queue() {
|
|||
}}
|
||||
disabled={cancelMutation.isPending}
|
||||
title="Cancel"
|
||||
aria-label="Cancel pending item"
|
||||
aria-label="Cancel item"
|
||||
className="btn-icon"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
|
|
@ -289,7 +323,7 @@ export function Queue() {
|
|||
)}
|
||||
|
||||
{/* Mutation errors */}
|
||||
{(retryMutation.error || cancelMutation.error) && (
|
||||
{(retryMutation.error || cancelMutation.error || pauseMutation.error || resumeMutation.error) && (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-3)',
|
||||
|
|
@ -306,7 +340,11 @@ export function Queue() {
|
|||
? retryMutation.error.message
|
||||
: cancelMutation.error instanceof Error
|
||||
? cancelMutation.error.message
|
||||
: 'Action failed'}
|
||||
: pauseMutation.error instanceof Error
|
||||
? pauseMutation.error.message
|
||||
: resumeMutation.error instanceof Error
|
||||
? resumeMutation.error.message
|
||||
: 'Action failed'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save } from 'lucide-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, FolderTree, Server, Sun, Moon, Clock } from 'lucide-react';
|
||||
import {
|
||||
useFormatProfiles,
|
||||
useCreateFormatProfile,
|
||||
|
|
@ -20,11 +20,20 @@ import {
|
|||
type NotificationSetting,
|
||||
} from '../api/hooks/useNotifications';
|
||||
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 { Modal } from '../components/Modal';
|
||||
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
||||
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
||||
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
||||
import { MediaServerForm, type MediaServerFormValues } from '../components/MediaServerForm';
|
||||
import { SkeletonSettings } from '../components/Skeleton';
|
||||
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
||||
|
||||
|
|
@ -78,34 +87,116 @@ export function SettingsPage() {
|
|||
const [showRegenerateConfirm, setShowRegenerateConfirm] = 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 ──
|
||||
const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings();
|
||||
const updateAppSettingsMutation = useUpdateAppSettings();
|
||||
const [checkInterval, setCheckInterval] = 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);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
if (appSettings) {
|
||||
setCheckInterval(appSettings.checkInterval);
|
||||
setConcurrentDownloads(appSettings.concurrentDownloads);
|
||||
setOutputTemplate(appSettings.outputTemplate);
|
||||
setNfoEnabled(appSettings.nfoEnabled);
|
||||
setTimezone(appSettings.timezone);
|
||||
setTheme(appSettings.theme);
|
||||
}
|
||||
}, [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 =
|
||||
checkInterval !== '' &&
|
||||
concurrentDownloads !== '' &&
|
||||
appSettings != null &&
|
||||
(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 =
|
||||
checkInterval !== '' &&
|
||||
concurrentDownloads !== '' &&
|
||||
Number(checkInterval) >= 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 ──
|
||||
|
||||
|
|
@ -115,6 +206,10 @@ export function SettingsPage() {
|
|||
{
|
||||
checkInterval: Number(checkInterval),
|
||||
concurrentDownloads: Number(concurrentDownloads),
|
||||
outputTemplate,
|
||||
nfoEnabled,
|
||||
timezone,
|
||||
theme,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
|
@ -125,6 +220,67 @@ export function SettingsPage() {
|
|||
);
|
||||
}, [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 ──
|
||||
|
||||
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 ──
|
||||
|
||||
const notificationColumns = useMemo<Column<NotificationSetting>[]>(
|
||||
|
|
@ -798,6 +1066,116 @@ export function SettingsPage() {
|
|||
)}
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -836,6 +1214,212 @@ export function SettingsPage() {
|
|||
</p>
|
||||
</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 ── */}
|
||||
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||
|
|
@ -904,6 +1488,58 @@ export function SettingsPage() {
|
|||
</div>
|
||||
</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 ── */}
|
||||
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||
<div
|
||||
|
|
@ -1046,6 +1682,81 @@ export function SettingsPage() {
|
|||
)}
|
||||
</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 ── */}
|
||||
<Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}>
|
||||
<NotificationForm
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
||||
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem';
|
||||
import { RefreshCw, Server, Activity, Cpu, HardDrive, Search } from 'lucide-react';
|
||||
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp, useMissingScanStatus, useTriggerMissingScan } from '../api/hooks/useSystem';
|
||||
import { HealthStatus } from '../components/HealthStatus';
|
||||
import { SkeletonSystem } from '../components/Skeleton';
|
||||
import { formatBytes } from '../utils/format';
|
||||
import { formatBytes, formatLocalDateTime } from '../utils/format';
|
||||
import { useTimezone } from '../hooks/useTimezone';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
|
|
@ -21,10 +22,13 @@ function formatUptime(seconds: number): string {
|
|||
// ── Component ──
|
||||
|
||||
export function SystemPage() {
|
||||
const timezone = useTimezone();
|
||||
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
|
||||
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
||||
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
||||
const updateYtDlp = useUpdateYtDlp();
|
||||
const { data: missingScanData } = useMissingScanStatus();
|
||||
const triggerMissingScan = useTriggerMissingScan();
|
||||
|
||||
const isLoading = healthLoading || statusLoading;
|
||||
|
||||
|
|
@ -92,6 +96,102 @@ export function SystemPage() {
|
|||
) : null}
|
||||
</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 ── */}
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
||||
|
|
|
|||
|
|
@ -60,12 +60,12 @@ a:hover {
|
|||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-selected);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: var(--border-light);
|
||||
}
|
||||
|
||||
/* ── Buttons base ── */
|
||||
|
|
@ -158,7 +158,7 @@ tr:hover {
|
|||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-input) 25%,
|
||||
rgba(255, 255, 255, 0.04) 50%,
|
||||
var(--bg-hover) 50%,
|
||||
var(--bg-input) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
|
|
|
|||
|
|
@ -97,3 +97,57 @@
|
|||
--glass-bg: rgba(20, 22, 30, 0.6);
|
||||
--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
|
||||
* 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). */
|
||||
|
|
@ -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`;
|
||||
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 { NotificationService } from './services/notification';
|
||||
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 { YouTubeSource } from './sources/youtube';
|
||||
import { SoundCloudSource } from './sources/soundcloud';
|
||||
|
|
@ -165,6 +168,43 @@ async function main(): Promise<void> {
|
|||
);
|
||||
(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
|
||||
const shutdown = async (signal: string) => {
|
||||
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 { collectRoutes } from './routes/collect';
|
||||
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 type { SchedulerService } from '../services/scheduler';
|
||||
import type { DownloadService } from '../services/download';
|
||||
import type { QueueService } from '../services/queue';
|
||||
import type { HealthService } from '../services/health';
|
||||
import type { DownloadEventBus } from '../services/event-bus';
|
||||
import type { MissingFileScanner } from '../services/missing-file-scanner';
|
||||
import type { ViteDevServer } from 'vite';
|
||||
|
||||
// Extend Fastify's type system so routes can access the database and scheduler
|
||||
|
|
@ -38,6 +42,7 @@ declare module 'fastify' {
|
|||
downloadService: DownloadService | null;
|
||||
queueService: QueueService | 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)
|
||||
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
|
||||
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(collectRoutes);
|
||||
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)
|
||||
if (opts.eventBus) {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,13 @@ async function authPluginHandler(fastify: FastifyInstance): Promise<void> {
|
|||
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
|
||||
if (isSameOriginRequest(request)) {
|
||||
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 },
|
||||
monitoringEnabled: { type: 'boolean' as const },
|
||||
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,
|
||||
};
|
||||
|
|
@ -253,7 +256,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
|||
|
||||
fastify.put<{
|
||||
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',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import {
|
|||
getAllContentItems,
|
||||
getContentByChannelId,
|
||||
getChannelContentPaginated,
|
||||
getContentCountsByType,
|
||||
setMonitored,
|
||||
bulkSetMonitored,
|
||||
updateContentItem,
|
||||
} 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';
|
||||
|
||||
// ── JSON Schemas for Fastify Validation ──
|
||||
|
|
@ -31,6 +33,15 @@ const toggleMonitoredBodySchema = {
|
|||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const updateRatingBodySchema = {
|
||||
type: 'object' as const,
|
||||
required: ['contentRating'],
|
||||
properties: {
|
||||
contentRating: { type: 'string' as const, nullable: true },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── Route Plugin ──
|
||||
|
||||
/**
|
||||
|
|
@ -40,6 +51,7 @@ const toggleMonitoredBodySchema = {
|
|||
* GET /api/v1/content — paginated content listing with optional filters
|
||||
* 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/rating — update content item rating
|
||||
* GET /api/v1/channel/:id/content — content items for a specific channel
|
||||
*/
|
||||
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 ──
|
||||
|
||||
// ── 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<{
|
||||
Params: { id: string };
|
||||
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.
|
||||
*
|
||||
* Registers:
|
||||
* GET /api/v1/queue — list queue items (optional ?status= filter)
|
||||
* GET /api/v1/queue/:id — get a single queue item
|
||||
* POST /api/v1/queue — enqueue a content item for download
|
||||
* DELETE /api/v1/queue/:id — cancel a queue item
|
||||
* POST /api/v1/queue/:id/retry — retry a failed queue item
|
||||
* GET /api/v1/queue — list queue items (optional ?status= filter)
|
||||
* GET /api/v1/queue/:id — get a single queue item
|
||||
* POST /api/v1/queue — enqueue a content item for download
|
||||
* DELETE /api/v1/queue/:id — cancel a 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> {
|
||||
// ── GET /api/v1/queue ──
|
||||
|
|
@ -35,6 +37,7 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
|||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'paused',
|
||||
];
|
||||
if (!validStatuses.includes(status as QueueStatus)) {
|
||||
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 { systemConfig } from '../../db/schema/index';
|
||||
import { API_KEY_DB_KEY } from '../middleware/auth';
|
||||
import { getContentItemById, updateContentItem } from '../../db/repositories/content-repository';
|
||||
import { parseIdParam } from './helpers';
|
||||
import {
|
||||
getAppSettings,
|
||||
getAppSetting,
|
||||
setAppSetting,
|
||||
APP_CHECK_INTERVAL,
|
||||
APP_CONCURRENT_DOWNLOADS,
|
||||
APP_OUTPUT_TEMPLATE,
|
||||
APP_NFO_ENABLED,
|
||||
APP_TIMEZONE,
|
||||
APP_THEME,
|
||||
YTDLP_LAST_UPDATED,
|
||||
} from '../../db/repositories/system-config-repository';
|
||||
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) => {
|
||||
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 = {
|
||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 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;
|
||||
|
|
@ -131,7 +141,7 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
|||
*/
|
||||
fastify.put('/api/v1/system/settings', async (request, reply) => {
|
||||
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
|
||||
if (body.checkInterval !== undefined) {
|
||||
|
|
@ -172,12 +182,56 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
|||
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
|
||||
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 = {
|
||||
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 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;
|
||||
|
|
@ -222,4 +276,110 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
|||
};
|
||||
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 { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
|
||||
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 type { DownloadEventBus } from './event-bus';
|
||||
import type { RateLimiter } from './rate-limiter';
|
||||
|
|
@ -49,14 +51,20 @@ export class DownloadService {
|
|||
*
|
||||
* 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')
|
||||
*/
|
||||
async downloadItem(
|
||||
contentItem: ContentItem,
|
||||
channel: Channel,
|
||||
formatProfile?: FormatProfile
|
||||
channel: Channel | null,
|
||||
formatProfile?: FormatProfile,
|
||||
adhocOverrides?: { platform?: Platform; channelName?: string }
|
||||
): 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
|
||||
console.log(`${logPrefix} status=downloading`);
|
||||
|
|
@ -64,14 +72,20 @@ export class DownloadService {
|
|||
|
||||
try {
|
||||
// Acquire rate limiter for platform
|
||||
await this.rateLimiter.acquire(channel.platform as Platform);
|
||||
await this.rateLimiter.acquire(platform as Platform);
|
||||
|
||||
// Build yt-dlp args
|
||||
const template = formatProfile?.outputTemplate ?? undefined;
|
||||
const outputTemplate = this.fileOrganizer.buildOutputPath(
|
||||
channel.platform,
|
||||
channel.name,
|
||||
platform,
|
||||
channelName,
|
||||
contentItem.title,
|
||||
this.guessExtension(contentItem.contentType, formatProfile)
|
||||
this.guessExtension(contentItem.contentType, formatProfile),
|
||||
template,
|
||||
{
|
||||
contentType: contentItem.contentType,
|
||||
id: contentItem.platformContentId,
|
||||
}
|
||||
);
|
||||
const args = this.buildYtDlpArgs(
|
||||
contentItem,
|
||||
|
|
@ -123,7 +137,10 @@ export class DownloadService {
|
|||
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
|
||||
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
||||
|
|
@ -135,7 +152,7 @@ export class DownloadService {
|
|||
return updated!;
|
||||
} catch (err: unknown) {
|
||||
// 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
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -255,7 +272,7 @@ export class DownloadService {
|
|||
*/
|
||||
private buildYtDlpArgs(
|
||||
contentItem: ContentItem,
|
||||
channel: Channel,
|
||||
channel: Channel | null,
|
||||
formatProfile: FormatProfile | undefined,
|
||||
outputTemplate: string
|
||||
): string[] {
|
||||
|
|
@ -293,9 +310,10 @@ export class DownloadService {
|
|||
args.push('--no-playlist');
|
||||
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(
|
||||
channel.platform as Platform
|
||||
cookiePlatform as Platform
|
||||
);
|
||||
if (cookiePath) {
|
||||
args.push('--cookies', cookiePath);
|
||||
|
|
@ -434,6 +452,39 @@ export class DownloadService {
|
|||
}
|
||||
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 ──
|
||||
|
|
|
|||
|
|
@ -20,32 +20,156 @@ const MAX_FILENAME_LENGTH = 200;
|
|||
/** Maximum attempts to find a unique filename. */
|
||||
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 ──
|
||||
|
||||
/**
|
||||
* Builds structured output paths from content metadata and sanitizes
|
||||
* filenames for cross-platform safety.
|
||||
*
|
||||
* Path template: `{mediaPath}/{platform}/{channelName}/{title}.{ext}`
|
||||
* filenames for cross-platform safety. Supports configurable path
|
||||
* templates with variables like {platform}, {channel}, {title}, {ext}.
|
||||
*/
|
||||
export class FileOrganizer {
|
||||
constructor(private readonly mediaPath: string) {}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
platform: string,
|
||||
channelName: string,
|
||||
title: string,
|
||||
ext: string
|
||||
ext: string,
|
||||
template?: string,
|
||||
extra?: { contentType?: string; id?: string }
|
||||
): string {
|
||||
const safeName = this.sanitizeFilename(channelName);
|
||||
const safeTitle = this.sanitizeFilename(title);
|
||||
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;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
paused: number;
|
||||
}
|
||||
|
||||
// ── QueueService ──
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* pending → downloading → completed | failed
|
||||
* failed → pending (retry) or failed (max attempts exhausted)
|
||||
* pending | failed → cancelled
|
||||
* pending | downloading → paused
|
||||
* paused → pending (resume)
|
||||
*
|
||||
* Concurrency is managed via an in-memory counter — Node's single-threaded
|
||||
* event loop ensures processNext() is not re-entrant within a single tick.
|
||||
|
|
@ -56,6 +59,8 @@ export class QueueService {
|
|||
private concurrency: number;
|
||||
private readonly onDownloadComplete?: (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(
|
||||
private readonly db: Db,
|
||||
|
|
@ -231,6 +236,76 @@ export class QueueService {
|
|||
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.
|
||||
* Resets them to 'pending' so they'll be picked up again.
|
||||
|
|
@ -281,6 +356,15 @@ export class QueueService {
|
|||
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 ──
|
||||
|
||||
/**
|
||||
|
|
@ -289,6 +373,8 @@ export class QueueService {
|
|||
*/
|
||||
private async processItem(queueItem: QueueItem): Promise<void> {
|
||||
const logPrefix = `[queue] process queueId=${queueItem.id} contentId=${queueItem.contentItemId}`;
|
||||
const abortController = new AbortController();
|
||||
this.activeAbortControllers.set(queueItem.id, abortController);
|
||||
|
||||
try {
|
||||
// Transition to downloading
|
||||
|
|
@ -304,22 +390,33 @@ export class QueueService {
|
|||
throw new Error(`Content item ${queueItem.contentItemId} not found`);
|
||||
}
|
||||
|
||||
const channel = await getChannelById(this.db, contentItem.channelId);
|
||||
if (!channel) {
|
||||
const channel = contentItem.channelId
|
||||
? await getChannelById(this.db, contentItem.channelId)
|
||||
: null;
|
||||
if (contentItem.channelId && !channel) {
|
||||
throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`);
|
||||
}
|
||||
|
||||
// Resolve format profile: channel-specific > default > undefined
|
||||
let formatProfile = undefined;
|
||||
if (channel.formatProfileId) {
|
||||
if (channel?.formatProfileId) {
|
||||
formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined;
|
||||
}
|
||||
if (!formatProfile) {
|
||||
formatProfile = await getDefaultFormatProfile(this.db) ?? undefined;
|
||||
}
|
||||
|
||||
// Execute download
|
||||
await this.downloadService.downloadItem(contentItem, channel, formatProfile);
|
||||
// Execute download — ad-hoc items (no channel) pass null with platform/channelName overrides
|
||||
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
|
||||
await updateQueueItemStatus(this.db, queueItem.id, 'completed', {
|
||||
|
|
@ -329,7 +426,7 @@ export class QueueService {
|
|||
// Record downloaded history event
|
||||
await createHistoryEvent(this.db, {
|
||||
contentItemId: queueItem.contentItemId,
|
||||
channelId: channel.id,
|
||||
channelId: channel?.id ?? null,
|
||||
eventType: 'downloaded',
|
||||
status: 'completed',
|
||||
details: {
|
||||
|
|
@ -346,8 +443,8 @@ export class QueueService {
|
|||
try {
|
||||
this.onDownloadComplete({
|
||||
contentTitle: contentItem.title,
|
||||
channelName: channel.name,
|
||||
platform: channel.platform,
|
||||
channelName: channel?.name ?? 'Ad-hoc',
|
||||
platform: channel?.platform ?? 'generic',
|
||||
url: contentItem.url,
|
||||
filePath: contentItem.filePath ?? undefined,
|
||||
});
|
||||
|
|
@ -358,10 +455,15 @@ export class QueueService {
|
|||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
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';
|
||||
// If aborted due to pause, don't treat as a failure — status is already set by pauseItem
|
||||
if (abortController.signal.aborted) {
|
||||
console.log(`${logPrefix} aborted (paused)`);
|
||||
// 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, {
|
||||
attempts: newAttempts,
|
||||
|
|
@ -421,8 +523,10 @@ export class QueueService {
|
|||
`[queue] notification callback error: ${notifyErr instanceof Error ? notifyErr.message : String(notifyErr)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.activeAbortControllers.delete(queueItem.id);
|
||||
this.activeCount--;
|
||||
this.processNext();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { PlatformRegistry, PlatformSource, FetchRecentContentOptions } from
|
|||
import type { RateLimiter } from './rate-limiter';
|
||||
import { YtDlpError } from '../sources/yt-dlp';
|
||||
import type { EventBus } from './event-bus';
|
||||
import { matchesKeywordFilter } from './keyword-filter';
|
||||
import {
|
||||
getEnabledChannels,
|
||||
updateChannel,
|
||||
|
|
@ -236,9 +237,19 @@ export class SchedulerService {
|
|||
(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)
|
||||
let insertedCount = 0;
|
||||
for (const item of newItems) {
|
||||
for (const item of filteredItems) {
|
||||
// Check if scan was cancelled
|
||||
if (effectiveSignal.aborted) {
|
||||
console.log(
|
||||
|
|
@ -310,7 +321,7 @@ export class SchedulerService {
|
|||
// This runs after the scan result is returned — enrichment updates DB records
|
||||
// and triggers a final cache invalidation when done.
|
||||
if (insertedCount > 0 && !effectiveSignal.aborted) {
|
||||
this.enrichNewItems(channel, newItems, existingIds, rateLimitDelay, source, effectiveSignal)
|
||||
this.enrichNewItems(channel, filteredItems, existingIds, rateLimitDelay, source, effectiveSignal)
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`[scheduler] Background enrichment failed for channel ${channel.id}:`,
|
||||
|
|
|
|||
|
|
@ -70,10 +70,21 @@ export interface ContentCounts {
|
|||
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). */
|
||||
export interface AppSettingsResponse {
|
||||
checkInterval: number;
|
||||
concurrentDownloads: number;
|
||||
outputTemplate: string;
|
||||
nfoEnabled: boolean;
|
||||
timezone: string;
|
||||
theme: 'dark' | 'light';
|
||||
}
|
||||
|
||||
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const ContentStatus = {
|
|||
Downloaded: 'downloaded',
|
||||
Failed: 'failed',
|
||||
Ignored: 'ignored',
|
||||
Missing: 'missing',
|
||||
} as const;
|
||||
export type ContentStatus = (typeof ContentStatus)[keyof typeof ContentStatus];
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ export const QueueStatus = {
|
|||
Completed: 'completed',
|
||||
Failed: 'failed',
|
||||
Cancelled: 'cancelled',
|
||||
Paused: 'paused',
|
||||
} as const;
|
||||
export type QueueStatus = (typeof QueueStatus)[keyof typeof QueueStatus];
|
||||
|
||||
|
|
@ -77,6 +79,9 @@ export interface Channel {
|
|||
bannerUrl: string | null;
|
||||
description: string | null;
|
||||
subscriberCount: number | null;
|
||||
contentRating: string | null;
|
||||
includeKeywords: string | null;
|
||||
excludeKeywords: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastCheckedAt: string | null;
|
||||
|
|
@ -85,7 +90,7 @@ export interface Channel {
|
|||
|
||||
export interface ContentItem {
|
||||
id: number;
|
||||
channelId: number;
|
||||
channelId: number | null;
|
||||
title: string;
|
||||
platformContentId: string;
|
||||
url: string;
|
||||
|
|
@ -100,6 +105,7 @@ export interface ContentItem {
|
|||
publishedAt: string | null;
|
||||
downloadedAt: string | null;
|
||||
monitored: boolean;
|
||||
contentRating: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -144,6 +150,7 @@ export interface FormatProfile {
|
|||
embedChapters: boolean;
|
||||
embedThumbnail: boolean;
|
||||
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;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -193,6 +200,24 @@ export interface SystemConfig {
|
|||
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 {
|
||||
id: number;
|
||||
channelId: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue