Compare commits

..

31 commits

Author SHA1 Message Date
jlightner
f37cfde0a0 chore: auto-commit after complete-milestone
All checks were successful
CI / test (push) Successful in 19s
GSD-Unit: M004
2026-04-04 07:54:04 +00:00
jlightner
54e9041058 feat: Added RSS 2.0 podcast feed at /api/v1/feed/rss and media streamin…
- "src/server/routes/feed.ts"
- "src/__tests__/feed-api.test.ts"
- "src/server/middleware/auth.ts"
- "src/server/index.ts"

GSD-Task: S08/T04
2026-04-04 07:38:34 +00:00
jlightner
c012425ceb fix: Created useTheme hook for App-level theme application, fixed hardc…
- "src/frontend/src/hooks/useTheme.ts"
- "src/frontend/src/App.tsx"
- "src/frontend/src/styles/global.css"
- "src/frontend/src/pages/ChannelDetail.tsx"

GSD-Task: S08/T03
2026-04-04 07:31:23 +00:00
jlightner
87cbfe87ee feat: Created useTimezone hook and timezone-aware format utilities, wir…
- "src/frontend/src/hooks/useTimezone.ts"
- "src/frontend/src/utils/format.ts"
- "src/frontend/src/pages/Activity.tsx"
- "src/frontend/src/pages/Queue.tsx"
- "src/frontend/src/pages/ChannelDetail.tsx"
- "src/frontend/src/pages/System.tsx"
- "src/frontend/src/components/HealthStatus.tsx"

GSD-Task: S08/T02
2026-04-04 07:26:04 +00:00
jlightner
98c3d73c69 feat: Added timezone selector and dark/light theme toggle to Settings p…
- "src/server/routes/system.ts"
- "src/db/repositories/system-config-repository.ts"
- "src/types/api.ts"
- "src/frontend/src/pages/Settings.tsx"
- "src/frontend/src/styles/theme.css"
- "src/frontend/index.html"

GSD-Task: S08/T01
2026-04-04 07:19:15 +00:00
jlightner
daf892edad feat: Add pause/resume buttons, paused status badge, and Paused filter…
- "src/frontend/src/pages/Queue.tsx"
- "src/frontend/src/api/hooks/useQueue.ts"
- "src/frontend/src/components/StatusBadge.tsx"

GSD-Task: S07/T04
2026-04-04 07:13:13 +00:00
jlightner
bd9e07f878 feat: Added Videos/Streams/Audio tab bar with count badges to channel d…
- "src/frontend/src/pages/ChannelDetail.tsx"
- "src/frontend/src/api/hooks/useContent.ts"

GSD-Task: S07/T02
2026-04-04 06:48:10 +00:00
jlightner
69ec5841e7 test: Added getContentCountsByType repository function and GET /api/v1/…
- "src/db/repositories/content-repository.ts"
- "src/server/routes/content.ts"
- "src/types/api.ts"
- "src/__tests__/content-api.test.ts"

GSD-Task: S07/T01
2026-04-04 06:44:04 +00:00
jlightner
e6711e91a5 feat: Added missing status badge, Library filter/re-download button, an…
- "src/frontend/src/components/StatusBadge.tsx"
- "src/frontend/src/pages/Library.tsx"
- "src/frontend/src/pages/System.tsx"
- "src/frontend/src/api/hooks/useLibrary.ts"
- "src/frontend/src/api/hooks/useSystem.ts"

GSD-Task: S06/T03
2026-04-04 06:39:17 +00:00
jlightner
a11c4c56c5 test: Added missing-scan API (trigger + status) and content requeue end…
- "src/server/routes/system.ts"
- "src/server/index.ts"
- "src/index.ts"
- "src/__tests__/missing-scan-api.test.ts"

GSD-Task: S06/T02
2026-04-04 06:35:58 +00:00
jlightner
61da729fa4 feat: Add MissingFileScanner service with cursor-based batched filesyst…
- "src/services/missing-file-scanner.ts"
- "src/__tests__/missing-file-scanner.test.ts"
- "src/types/index.ts"
- "src/db/schema/content.ts"

GSD-Task: S06/T01
2026-04-04 06:31:11 +00:00
jlightner
c0ac8cadd5 feat: Added RatingBadge/RatingPicker components, channel and content it…
- "src/frontend/src/components/RatingBadge.tsx"
- "src/frontend/src/pages/ChannelDetail.tsx"
- "src/frontend/src/pages/Settings.tsx"
- "src/server/routes/content.ts"
- "src/server/routes/channel.ts"
- "src/server/routes/system.ts"
- "src/types/api.ts"

GSD-Task: S05/T04
2026-04-04 06:24:14 +00:00
jlightner
b4d730d42f feat: Wired NFO generation into DownloadService with feature flag check…
- "src/services/download.ts"
- "src/__tests__/download.test.ts"

GSD-Task: S05/T03
2026-04-04 06:16:03 +00:00
jlightner
7f6f3dcccf feat: Built NfoGenerator service producing Kodi-compatible NFO XML with…
- "src/services/nfo-generator.ts"
- "src/__tests__/nfo-generator.test.ts"
- "src/types/index.ts"
- "src/db/repositories/channel-repository.ts"
- "src/__tests__/sources.test.ts"

GSD-Task: S05/T02
2026-04-04 06:12:35 +00:00
jlightner
e0b6424932 chore: Added nullable contentRating columns to channels and content_ite…
- "src/db/schema/channels.ts"
- "src/db/schema/content.ts"
- "src/types/index.ts"
- "src/db/repositories/system-config-repository.ts"
- "src/db/repositories/content-repository.ts"
- "src/__tests__/scheduler.test.ts"
- "drizzle/0017_wild_havok.sql"

GSD-Task: S05/T01
2026-04-04 06:08:16 +00:00
jlightner
01f4a2d38a test: Built Media Servers settings section with full CRUD, connection t…
- "src/frontend/src/api/hooks/useMediaServers.ts"
- "src/frontend/src/components/MediaServerForm.tsx"
- "src/frontend/src/pages/Settings.tsx"

GSD-Task: S04/T04
2026-04-04 06:02:39 +00:00
jlightner
9ef0323480 test: Built media server CRUD routes, connection test/sections endpoint…
- "src/server/routes/media-server.ts"
- "src/__tests__/media-server-api.test.ts"
- "src/index.ts"
- "src/server/index.ts"

GSD-Task: S04/T03
2026-04-04 05:57:39 +00:00
jlightner
73c232a845 test: Built stateless MediaServerService with scan triggering, connecti…
- "src/services/media-server.ts"
- "src/__tests__/media-server.test.ts"

GSD-Task: S04/T02
2026-04-04 05:53:30 +00:00
jlightner
6aa7e21b90 feat: Added media_servers table, MediaServer type, and CRUD repository…
- "src/db/schema/media-servers.ts"
- "src/db/repositories/media-server-repository.ts"
- "src/types/index.ts"
- "drizzle/0016_right_galactus.sql"

GSD-Task: S04/T01
2026-04-04 05:50:33 +00:00
jlightner
9e7d98c7c7 feat: Add collapsible keyword filter UI to channel detail with include/…
- "src/frontend/src/pages/ChannelDetail.tsx"
- "src/server/routes/channel.ts"
- "src/frontend/src/api/hooks/useChannels.ts"

GSD-Task: S03/T04
2026-04-04 05:46:46 +00:00
jlightner
05045828d8 feat: Wire keyword filter into scheduler scan flow — exclude/include pa…
- "src/services/scheduler.ts"
- "src/__tests__/scheduler.test.ts"
- "src/db/repositories/channel-repository.ts"

GSD-Task: S03/T03
2026-04-04 05:41:55 +00:00
jlightner
cc031a78a9 test: Implement matchesKeywordFilter engine with pipe-separated pattern…
- "src/services/keyword-filter.ts"
- "src/__tests__/keyword-filter.test.ts"

GSD-Task: S03/T02
2026-04-04 05:38:37 +00:00
jlightner
8d133024a5 feat: Add includeKeywords and excludeKeywords nullable text columns to…
- "src/db/schema/channels.ts"
- "src/types/index.ts"
- "src/db/repositories/channel-repository.ts"
- "drizzle/0015_perfect_lethal_legion.sql"
- "src/__tests__/sources.test.ts"

GSD-Task: S03/T01
2026-04-04 05:35:13 +00:00
jlightner
3bfdb7b634 feat: Add File Organization settings section with output template input…
- "src/frontend/src/pages/Settings.tsx"
- "src/frontend/src/components/FormatProfileForm.tsx"
- "src/frontend/src/api/hooks/useFormatProfiles.ts"
- "src/types/api.ts"
- "src/server/routes/system.ts"

GSD-Task: S02/T04
2026-04-04 05:30:34 +00:00
jlightner
fb731377bd feat: Wire FormatProfile.outputTemplate into DownloadService with conte…
- "src/services/download.ts"
- "src/services/file-organizer.ts"

GSD-Task: S02/T03
2026-04-04 05:25:41 +00:00
jlightner
71175198bd test: Add resolveTemplate and validateTemplate methods to FileOrganizer…
- "src/services/file-organizer.ts"
- "src/__tests__/file-organizer.test.ts"

GSD-Task: S02/T02
2026-04-04 05:23:22 +00:00
jlightner
e6371ba196 chore: Add outputTemplate column to format_profiles schema and app.outp…
- "src/db/schema/content.ts"
- "src/types/index.ts"
- "src/db/repositories/system-config-repository.ts"
- "src/db/repositories/format-profile-repository.ts"
- "drizzle/0014_adorable_miek.sql"

GSD-Task: S02/T01
2026-04-04 05:20:18 +00:00
jlightner
61105a74b0 feat: Created AddUrlModal with two-step preview/confirm flow, useAdhocD…
- "src/frontend/src/components/AddUrlModal.tsx"
- "src/frontend/src/api/hooks/useAdhocDownload.ts"
- "src/frontend/src/components/Sidebar.tsx"

GSD-Task: S01/T04
2026-04-04 05:15:28 +00:00
jlightner
22077e0eb1 feat: Add POST /api/v1/download/url/confirm endpoint for ad-hoc downloa…
- "src/server/routes/adhoc-download.ts"
- "src/services/download.ts"
- "src/services/queue.ts"
- "src/__tests__/adhoc-download-api.test.ts"

GSD-Task: S01/T03
2026-04-04 05:12:11 +00:00
jlightner
373a2ee649 test: Created POST /api/v1/download/url/preview endpoint that resolves…
- "src/server/routes/adhoc-download.ts"
- "src/__tests__/adhoc-download-api.test.ts"
- "src/server/index.ts"
- "drizzle/0013_flat_lady_deathstrike.sql"

GSD-Task: S01/T02
2026-04-04 05:07:24 +00:00
jlightner
8150b1f6cf chore: Made content_items.channelId nullable via SQLite table recreatio…
- "src/db/schema/content.ts"
- "src/types/index.ts"
- "src/db/repositories/content-repository.ts"
- "src/services/queue.ts"
- "drizzle/0012_adhoc_nullable_channel.sql"
- "drizzle/meta/_journal.json"

GSD-Task: S01/T01
2026-04-04 05:03:40 +00:00
89 changed files with 14083 additions and 164 deletions

18
.mcp.json Normal file
View 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"
}
}
}
}

View 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'));

View 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;

View file

@ -0,0 +1 @@
ALTER TABLE `format_profiles` ADD `output_template` text;

View file

@ -0,0 +1,2 @@
ALTER TABLE `channels` ADD `include_keywords` text;--> statement-breakpoint
ALTER TABLE `channels` ADD `exclude_keywords` text;

View 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
);

View file

@ -0,0 +1,2 @@
ALTER TABLE `channels` ADD `content_rating` text;--> statement-breakpoint
ALTER TABLE `content_items` ADD `content_rating` text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -85,6 +85,48 @@
"when": 1775253600000, "when": 1775253600000,
"tag": "0011_add_youtube_enhancements", "tag": "0011_add_youtube_enhancements",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1775520000000,
"tag": "0012_adhoc_nullable_channel",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1775279021003,
"tag": "0013_flat_lady_deathstrike",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775279888856,
"tag": "0014_adorable_miek",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1775280800944,
"tag": "0015_perfect_lethal_legion",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1775281783887,
"tag": "0016_right_galactus",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1775282773898,
"tag": "0017_wild_havok",
"breakpoints": true
} }
] ]
} }

View 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;
});
});

View file

@ -193,7 +193,7 @@ describe('Channel API', () => {
expect(body.name).toBe('Beat Artist'); expect(body.name).toBe('Beat Artist');
expect(body.platform).toBe('soundcloud'); expect(body.platform).toBe('soundcloud');
expect(body.platformId).toBe('beat-artist'); expect(body.platformId).toBe('beat-artist');
expect(body.monitoringEnabled).toBe(true); // default expect(body.monitoringEnabled).toBe(false); // default (monitoringMode defaults to 'none')
expect(body.checkInterval).toBe(360); // default expect(body.checkInterval).toBe(360); // default
}); });
@ -202,7 +202,7 @@ describe('Channel API', () => {
method: 'POST', method: 'POST',
url: '/api/v1/channel', url: '/api/v1/channel',
headers: { 'x-api-key': apiKey }, headers: { 'x-api-key': apiKey },
payload: { url: 'https://www.example.com/not-a-platform' }, payload: { url: 'ftp://files.example.com/not-a-platform' },
}); });
expect(res.statusCode).toBe(422); expect(res.statusCode).toBe(422);
@ -319,7 +319,7 @@ describe('Channel API', () => {
expect(body.monitoringEnabled).toBe(false); expect(body.monitoringEnabled).toBe(false);
}); });
it('defaults monitoringMode to all when not specified', async () => { it('defaults monitoringMode to none when not specified', async () => {
execYtDlpMock.mockResolvedValueOnce({ execYtDlpMock.mockResolvedValueOnce({
stdout: JSON.stringify({ stdout: JSON.stringify({
channel: 'Default Mode Channel', channel: 'Default Mode Channel',
@ -343,8 +343,8 @@ describe('Channel API', () => {
expect(res.statusCode).toBe(201); expect(res.statusCode).toBe(201);
const body = res.json(); const body = res.json();
expect(body.monitoringMode).toBe('all'); expect(body.monitoringMode).toBe('none');
expect(body.monitoringEnabled).toBe(true); expect(body.monitoringEnabled).toBe(false);
}); });
}); });

View file

@ -260,6 +260,20 @@ describe('content-api', () => {
expect(body.data.every((item: { channelId: number }) => item.channelId === channelA.id)).toBe(true); expect(body.data.every((item: { channelId: number }) => item.channelId === channelA.id)).toBe(true);
}); });
it('filters by contentType when paginated', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelA.id}/content?contentType=video&page=1`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(2);
expect(body.data.every((item: { contentType: string }) => item.contentType === 'video')).toBe(true);
expect(body.pagination.totalItems).toBe(2);
});
it('returns empty array for channel with no content', async () => { it('returns empty array for channel with no content', async () => {
const noContentChannel = await createChannel(db, { const noContentChannel = await createChannel(db, {
name: 'Empty Channel', name: 'Empty Channel',
@ -306,4 +320,89 @@ describe('content-api', () => {
expect(res.statusCode).toBe(401); expect(res.statusCode).toBe(401);
}); });
}); });
// ── GET /api/v1/channel/:id/content-counts ──
describe('GET /api/v1/channel/:id/content-counts', () => {
it('returns per-type counts for a channel with mixed content', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelA.id}/content-counts`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data).toEqual({
video: 2,
audio: 0,
livestream: 1,
});
});
it('returns per-type counts for audio-only channel', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelB.id}/content-counts`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toEqual({
video: 0,
audio: 2,
livestream: 0,
});
});
it('returns all zeros for channel with no content', async () => {
// Create a fresh empty channel
const emptyChannel = await createChannel(db, {
name: 'Counts Empty',
platform: 'youtube',
platformId: 'UC_counts_empty',
url: 'https://www.youtube.com/channel/UC_counts_empty',
monitoringEnabled: true,
checkInterval: 360,
imageUrl: null,
metadata: null,
formatProfileId: null,
});
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${emptyChannel.id}/content-counts`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toEqual({
video: 0,
audio: 0,
livestream: 0,
});
});
it('returns 400 for invalid channel ID', async () => {
const res = await server.inject({
method: 'GET',
url: '/api/v1/channel/abc/content-counts',
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(400);
});
it('returns 401 without API key', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelA.id}/content-counts`,
});
expect(res.statusCode).toBe(401);
});
});
}); });

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { initDatabaseAsync, closeDatabase } from '../db/index'; import { initDatabaseAsync, closeDatabase } from '../db/index';
@ -39,6 +39,16 @@ vi.mock('node:fs/promises', async (importOriginal) => {
}; };
}); });
// Mock getAppSetting for NFO feature flag
const getAppSettingMock = vi.fn();
vi.mock('../db/repositories/system-config-repository', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
...actual,
getAppSetting: (...args: unknown[]) => getAppSettingMock(...args),
};
});
// ── Test Helpers ── // ── Test Helpers ──
let tmpDir: string; let tmpDir: string;
@ -334,7 +344,7 @@ describe('DownloadService', () => {
containerFormat: 'mkv', containerFormat: 'mkv',
isDefault: false, isDefault: false,
subtitleLanguages: null, subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
@ -388,7 +398,7 @@ describe('DownloadService', () => {
containerFormat: null, containerFormat: null,
isDefault: false, isDefault: false,
subtitleLanguages: null, subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
@ -642,7 +652,7 @@ describe('DownloadService', () => {
containerFormat: null, containerFormat: null,
isDefault: false, isDefault: false,
subtitleLanguages: null, subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
@ -686,7 +696,7 @@ describe('DownloadService', () => {
containerFormat: 'mkv', containerFormat: 'mkv',
isDefault: false, isDefault: false,
subtitleLanguages: null, subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
@ -738,7 +748,7 @@ describe('DownloadService', () => {
containerFormat: null, containerFormat: null,
isDefault: false, isDefault: false,
subtitleLanguages: null, subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
@ -761,4 +771,107 @@ describe('DownloadService', () => {
expect(args).not.toContain('--audio-quality'); expect(args).not.toContain('--audio-quality');
}); });
}); });
describe('downloadItem — NFO sidecar generation', () => {
function setupSuccessfulDownload(deps: ReturnType<typeof createMockDeps>) {
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
writeFileSync(outputPath, 'fake video data');
execYtDlpMock.mockResolvedValueOnce({
stdout: outputPath,
stderr: '',
exitCode: 0,
});
statMock.mockResolvedValueOnce({ size: 10_000_000 });
return outputPath;
}
it('writes .nfo sidecar when app.nfo_enabled is "true"', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
const outputPath = setupSuccessfulDownload(deps);
getAppSettingMock.mockResolvedValueOnce('true');
await service.downloadItem(testContentItem, testChannel);
// NFO file should exist alongside the media file
const nfoPath = outputPath.replace(/\.mp4$/, '.nfo');
expect(existsSync(nfoPath)).toBe(true);
// Validate NFO content
const nfoContent = readFileSync(nfoPath, 'utf-8');
expect(nfoContent).toContain('<?xml version="1.0"');
expect(nfoContent).toContain('<episodedetails>');
expect(nfoContent).toContain('<title>Test Video Title</title>');
expect(nfoContent).toContain('<studio>Test Channel</studio>');
});
it('does not write .nfo when app.nfo_enabled is not "true"', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
const outputPath = setupSuccessfulDownload(deps);
getAppSettingMock.mockResolvedValueOnce(null); // Not set
await service.downloadItem(testContentItem, testChannel);
const nfoPath = outputPath.replace(/\.mp4$/, '.nfo');
expect(existsSync(nfoPath)).toBe(false);
});
it('does not write .nfo when app.nfo_enabled is "false"', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
const outputPath = setupSuccessfulDownload(deps);
getAppSettingMock.mockResolvedValueOnce('false');
await service.downloadItem(testContentItem, testChannel);
const nfoPath = outputPath.replace(/\.mp4$/, '.nfo');
expect(existsSync(nfoPath)).toBe(false);
});
it('does not fail the download when NFO generation throws', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupSuccessfulDownload(deps);
getAppSettingMock.mockRejectedValueOnce(new Error('DB read failed'));
// Download should still succeed
const result = await service.downloadItem(testContentItem, testChannel);
expect(result.status).toBe('downloaded');
});
it('still completes download successfully even with NFO enabled', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupSuccessfulDownload(deps);
getAppSettingMock.mockResolvedValueOnce('true');
const result = await service.downloadItem(testContentItem, testChannel);
expect(result.status).toBe('downloaded');
expect(result.filePath).toBeDefined();
});
});
}); });

View 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('&lt;tags&gt;');
expect(res.body).toContain('&amp;');
expect(res.body).toContain('&quot;quotes&quot;');
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);
});
});
});

View file

@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { FileOrganizer } from '../services/file-organizer'; import { FileOrganizer, DEFAULT_OUTPUT_TEMPLATE, TEMPLATE_VARIABLES } from '../services/file-organizer';
let tmpDir: string; let tmpDir: string;
@ -213,4 +213,230 @@ describe('FileOrganizer', () => {
expect(result).not.toMatch(/\\{2,}/); expect(result).not.toMatch(/\\{2,}/);
}); });
}); });
describe('resolveTemplate', () => {
it('replaces all known variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{channel}/{title}.{ext}', {
platform: 'youtube',
channel: 'TechChannel',
title: 'My Video',
ext: 'mp4',
});
expect(result).toBe('youtube/TechChannel/My Video.mp4');
});
it('handles date/year/month variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{year}/{month}/{title}.{ext}', {
platform: 'youtube',
year: '2026',
month: '04',
title: 'April Video',
ext: 'mkv',
});
expect(result).toBe('youtube/2026/04/April Video.mkv');
});
it('handles contentType and id variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{contentType}/{id}.{ext}', {
contentType: 'video',
id: 'abc-123',
ext: 'mp4',
});
expect(result).toBe('video/abc-123.mp4');
});
it('sanitizes variable values (strips forbidden chars)', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{channel}/{title}.{ext}', {
channel: 'Bad:Channel*Name',
title: 'Title "With" <Special>',
ext: 'mp4',
});
expect(result).toBe('BadChannelName/Title With Special.mp4');
});
it('resolves missing known variables to empty string', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{channel}/{title}.{ext}', {
platform: 'youtube',
ext: 'mp4',
// channel and title missing
});
// Missing vars resolve to empty → sanitizeFilename('') → '_unnamed'
expect(result).toBe('youtube/_unnamed/_unnamed.mp4');
});
it('leaves unknown variables untouched', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{platform}/{unknown}/{title}.{ext}', {
platform: 'youtube',
title: 'Video',
ext: 'mp4',
});
expect(result).toBe('youtube/{unknown}/Video.mp4');
});
it('handles template with no variables', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('static/path/file.mp4', {});
expect(result).toBe('static/path/file.mp4');
});
it('handles special characters in variable values', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{channel}/{title}.{ext}', {
channel: '🎵 Music Channel 🎶',
title: 'Ünîcödé Söng',
ext: 'flac',
});
expect(result).toBe('🎵 Music Channel 🎶/Ünîcödé Söng.flac');
});
it('does not sanitize the ext variable', () => {
const fo = new FileOrganizer('/media');
const result = fo.resolveTemplate('{title}.{ext}', {
title: 'Video',
ext: 'mp4',
});
// ext should be raw, not run through sanitizeFilename
expect(result).toBe('Video.mp4');
});
});
describe('validateTemplate', () => {
it('accepts the default template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate(DEFAULT_OUTPUT_TEMPLATE);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('accepts a complex valid template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/{channel}/{year}/{month}/{title}.{ext}');
expect(result.valid).toBe(true);
});
it('rejects template without {ext}', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/{channel}/{title}');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Template must contain {ext} for the file extension');
});
it('rejects empty template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Template must not be empty');
});
it('rejects whitespace-only template', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate(' ');
expect(result.valid).toBe(false);
});
it('flags unknown variable names', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/{bogus}/{title}.{ext}');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Unknown template variable: {bogus}');
});
it('flags illegal filesystem characters', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{platform}/<bad>/{title}.{ext}');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Template contains illegal filesystem characters');
});
it('accumulates multiple errors', () => {
const fo = new FileOrganizer('/media');
// Missing {ext} AND unknown variable
const result = fo.validateTemplate('{platform}/{bogus}/{title}');
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThanOrEqual(2);
});
it('accepts template with only {ext}', () => {
const fo = new FileOrganizer('/media');
const result = fo.validateTemplate('{title}.{ext}');
expect(result.valid).toBe(true);
});
});
describe('buildOutputPath with template', () => {
it('default template produces identical paths to legacy behavior', () => {
const fo = new FileOrganizer('/media');
// Legacy (no template)
const legacy = fo.buildOutputPath('youtube', 'TechChannel', 'My Video', 'mp4');
// Template (explicit default)
const templated = fo.buildOutputPath('youtube', 'TechChannel', 'My Video', 'mp4', DEFAULT_OUTPUT_TEMPLATE);
expect(templated).toBe(legacy);
});
it('custom template changes directory structure', () => {
const fo = new FileOrganizer('/media');
const result = fo.buildOutputPath(
'youtube',
'TechChannel',
'My Video',
'mp4',
'{platform}/{title}.{ext}'
);
// Should be /media/youtube/My Video.mp4 — no channel directory
expect(result).toContain('youtube');
expect(result).toContain('My Video.mp4');
expect(result).not.toContain('TechChannel');
});
it('template with year/month creates date-based directories', () => {
const fo = new FileOrganizer('/media');
const result = fo.buildOutputPath(
'youtube',
'TechChannel',
'My Video',
'mp4',
'{platform}/{channel}/{year}/{title}.{ext}'
);
const year = String(new Date().getFullYear());
expect(result).toContain(year);
expect(result).toContain('TechChannel');
expect(result).toContain('My Video.mp4');
});
it('still sanitizes values when using custom template', () => {
const fo = new FileOrganizer('/media');
const result = fo.buildOutputPath(
'youtube',
'Bad:Channel*Name',
'Title "With" <Special>',
'mkv',
'{platform}/{channel}/{title}.{ext}'
);
expect(result).not.toContain(':');
expect(result).not.toContain('*');
expect(result).not.toContain('"');
expect(result).toContain('BadChannelName');
expect(result).toContain('Title With Special.mkv');
});
});
describe('constants and types', () => {
it('DEFAULT_OUTPUT_TEMPLATE matches legacy layout', () => {
expect(DEFAULT_OUTPUT_TEMPLATE).toBe('{platform}/{channel}/{title}.{ext}');
});
it('TEMPLATE_VARIABLES includes all expected variables', () => {
const expected = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'];
for (const v of expected) {
expect(TEMPLATE_VARIABLES).toContain(v);
}
});
});
}); });

View 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);
});
});
});

View 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);
});
});
});

View 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');
});
});
});

View 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);
});
});

View 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);
}
});
});
});

View 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 &amp; Jerry &lt;&quot;Special&quot;&gt;</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');
});
});
});

View file

@ -145,7 +145,7 @@ describe('Platform Settings API', () => {
// Defaults // Defaults
expect(body.grabAllEnabled).toBe(false); expect(body.grabAllEnabled).toBe(false);
expect(body.grabAllOrder).toBe('newest'); expect(body.grabAllOrder).toBe('newest');
expect(body.scanLimit).toBe(100); expect(body.scanLimit).toBe(500);
expect(body.rateLimitDelay).toBe(1000); expect(body.rateLimitDelay).toBe(1000);
}); });

View file

@ -518,6 +518,129 @@ describe('QueueService', () => {
}); });
}); });
// ── Pause ──
describe('pauseItem', () => {
it('pauses a pending item', async () => {
const qs = new QueueService(db, mockDownloadService as any, 0);
qs.stop();
await qs.enqueue(contentItems[0].id);
const paused = await qs.pauseItem(1);
expect(paused.status).toBe('paused');
});
it('pauses a downloading item by aborting the active download', async () => {
// Use a deferred so we can control when the download completes
let rejectFn: (err: Error) => void;
mockDownloadService.downloadItem.mockImplementationOnce(() => {
return new Promise<void>((_resolve, reject) => {
rejectFn = reject;
});
});
const qs = new QueueService(db, mockDownloadService as any, 1);
await qs.enqueue(contentItems[0].id);
await tick(50); // Let it transition to downloading
// Item should be downloading
let item = await getQueueItemById(db, 1);
expect(item!.status).toBe('downloading');
// Pause it — this should abort the download
const paused = await qs.pauseItem(1);
expect(paused.status).toBe('paused');
// Simulate the abort rejection (in real code, the AbortController signal kills yt-dlp)
rejectFn!(new Error('aborted'));
await tick(50);
// Item should remain paused (not retried as failed)
item = await getQueueItemById(db, 1);
expect(item!.status).toBe('paused');
});
it('throws for completed item', async () => {
const qs = new QueueService(db, mockDownloadService as any, 0);
qs.stop();
await qs.enqueue(contentItems[0].id);
await updateQueueItemStatus(db, 1, 'completed');
await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/);
});
it('throws for cancelled item', async () => {
const qs = new QueueService(db, mockDownloadService as any, 0);
qs.stop();
await qs.enqueue(contentItems[0].id);
await updateQueueItemStatus(db, 1, 'cancelled');
await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/);
});
it('throws for non-existent item', async () => {
const qs = new QueueService(db, mockDownloadService as any, 0);
await expect(qs.pauseItem(99999)).rejects.toThrow(/not found/);
});
});
// ── Resume ──
describe('resumeItem', () => {
it('resumes a paused item back to pending', async () => {
const qs = new QueueService(db, mockDownloadService as any, 0);
qs.stop();
await qs.enqueue(contentItems[0].id);
await qs.pauseItem(1);
const resumed = await qs.resumeItem(1);
expect(resumed.status).toBe('pending');
expect(resumed.error).toBeNull();
// Content status should be reset to queued
const contentItem = await getContentItemById(db, contentItems[0].id);
expect(contentItem!.status).toBe('queued');
});
it('throws for non-paused item', async () => {
const qs = new QueueService(db, mockDownloadService as any, 0);
qs.stop();
await qs.enqueue(contentItems[0].id);
await expect(qs.resumeItem(1)).rejects.toThrow(/expected 'paused'/);
});
it('throws for non-existent item', async () => {
const qs = new QueueService(db, mockDownloadService as any, 0);
await expect(qs.resumeItem(99999)).rejects.toThrow(/not found/);
});
it('triggers processNext after resume', async () => {
// After resuming, the item should get picked up and processed
const qs = new QueueService(db, mockDownloadService as any, 1);
// Enqueue and pause
qs.stop();
await qs.enqueue(contentItems[0].id);
await qs.pauseItem(1);
// Resume — processNext should fire and download
qs.start();
await qs.resumeItem(1);
await tick(100);
const item = await getQueueItemById(db, 1);
expect(item!.status).toBe('completed');
expect(mockDownloadService.downloadItem).toHaveBeenCalledOnce();
});
});
// ── getState ── // ── getState ──
describe('getState', () => { describe('getState', () => {
@ -539,6 +662,7 @@ describe('QueueService', () => {
expect(state.failed).toBe(1); expect(state.failed).toBe(1);
expect(state.downloading).toBe(0); expect(state.downloading).toBe(0);
expect(state.cancelled).toBe(0); expect(state.cancelled).toBe(0);
expect(state.paused).toBe(0);
}); });
it('returns all zeros when queue is empty', async () => { it('returns all zeros when queue is empty', async () => {
@ -551,6 +675,7 @@ describe('QueueService', () => {
completed: 0, completed: 0,
failed: 0, failed: 0,
cancelled: 0, cancelled: 0,
paused: 0,
}); });
}); });
}); });

View file

@ -179,14 +179,12 @@ describe('Scan API', () => {
headers: { 'x-api-key': apiKey }, headers: { 'x-api-key': apiKey },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(202);
const body = res.json(); const body = res.json();
expect(body).toMatchObject({ expect(body).toMatchObject({
channelId: channel.id, channelId: channel.id,
channelName: channel.name, channelName: channel.name,
status: 'success', status: 'started',
newItems: 3,
totalFetched: 3,
}); });
}); });
@ -293,7 +291,7 @@ describe('Scan API', () => {
errors: expect.any(Number), errors: expect.any(Number),
}); });
// At least our two channels' new items should be counted // At least our two channels' new items should be counted
expect(body.summary.newItems).toBeGreaterThanOrEqual(3); expect(body.summary.newItems).toBeGreaterThanOrEqual(2);
}); });
it('returns 503 when scheduler is null', async () => { it('returns 503 when scheduler is null', async () => {

View file

@ -610,6 +610,7 @@ describe('SchedulerService', () => {
qualityMetadata: null, qualityMetadata: null,
status: 'monitored', status: 'monitored',
monitored: false, monitored: false,
contentRating: null,
publishedAt: null, publishedAt: null,
downloadedAt: null, downloadedAt: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@ -659,6 +660,180 @@ describe('SchedulerService', () => {
scheduler.stop(); scheduler.stop();
}); });
// ── Keyword filter tests ──
it('excludes items matching excludeKeywords pattern', async () => {
const channel = await insertTestChannel({ excludeKeywords: 'shorts|#shorts' });
const scheduler = new SchedulerService(db, registry, rateLimiter);
const items: PlatformContentMetadata[] = [
{
platformContentId: `kf_exc_${channel.id}_1`,
title: 'Great Video About Coding',
url: 'https://www.youtube.com/watch?v=1',
contentType: 'video',
duration: 600,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_exc_${channel.id}_2`,
title: 'Quick shorts compilation',
url: 'https://www.youtube.com/watch?v=2',
contentType: 'video',
duration: 30,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_exc_${channel.id}_3`,
title: 'My Day #shorts vlog',
url: 'https://www.youtube.com/watch?v=3',
contentType: 'video',
duration: 15,
thumbnailUrl: null,
publishedAt: null,
},
];
mockFetchRecentContent.mockResolvedValueOnce(items);
const result = await scheduler.checkChannel(channel);
// Only the first item should pass the filter
expect(result.newItems).toBe(1);
expect(result.totalFetched).toBe(3);
const content = await getContentByChannelId(db, channel.id);
const inserted = content.filter(c =>
c.platformContentId.startsWith(`kf_exc_${channel.id}`)
);
expect(inserted.length).toBe(1);
expect(inserted[0].title).toBe('Great Video About Coding');
scheduler.stop();
});
it('includes only items matching includeKeywords pattern', async () => {
const channel = await insertTestChannel({ includeKeywords: 'tutorial|guide' });
const scheduler = new SchedulerService(db, registry, rateLimiter);
const items: PlatformContentMetadata[] = [
{
platformContentId: `kf_inc_${channel.id}_1`,
title: 'Python Tutorial for Beginners',
url: 'https://www.youtube.com/watch?v=1',
contentType: 'video',
duration: 1800,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_inc_${channel.id}_2`,
title: 'Random Vlog Day 5',
url: 'https://www.youtube.com/watch?v=2',
contentType: 'video',
duration: 300,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_inc_${channel.id}_3`,
title: 'Ultimate Guide to Docker',
url: 'https://www.youtube.com/watch?v=3',
contentType: 'video',
duration: 2400,
thumbnailUrl: null,
publishedAt: null,
},
];
mockFetchRecentContent.mockResolvedValueOnce(items);
const result = await scheduler.checkChannel(channel);
expect(result.newItems).toBe(2);
const content = await getContentByChannelId(db, channel.id);
const inserted = content.filter(c =>
c.platformContentId.startsWith(`kf_inc_${channel.id}`)
);
expect(inserted.length).toBe(2);
const titles = inserted.map(c => c.title);
expect(titles).toContain('Python Tutorial for Beginners');
expect(titles).toContain('Ultimate Guide to Docker');
scheduler.stop();
});
it('applies both include and exclude patterns together', async () => {
const channel = await insertTestChannel({
includeKeywords: 'tutorial',
excludeKeywords: 'shorts',
});
const scheduler = new SchedulerService(db, registry, rateLimiter);
const items: PlatformContentMetadata[] = [
{
platformContentId: `kf_both_${channel.id}_1`,
title: 'Tutorial: Getting Started',
url: 'https://www.youtube.com/watch?v=1',
contentType: 'video',
duration: 1800,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_both_${channel.id}_2`,
title: 'Tutorial shorts recap',
url: 'https://www.youtube.com/watch?v=2',
contentType: 'video',
duration: 30,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_both_${channel.id}_3`,
title: 'Random Gaming Stream',
url: 'https://www.youtube.com/watch?v=3',
contentType: 'video',
duration: 7200,
thumbnailUrl: null,
publishedAt: null,
},
];
mockFetchRecentContent.mockResolvedValueOnce(items);
const result = await scheduler.checkChannel(channel);
// Item 1: matches include, no exclude match → pass
// Item 2: matches include AND exclude → excluded (exclude wins)
// Item 3: doesn't match include → excluded
expect(result.newItems).toBe(1);
const content = await getContentByChannelId(db, channel.id);
const inserted = content.filter(c =>
c.platformContentId.startsWith(`kf_both_${channel.id}`)
);
expect(inserted.length).toBe(1);
expect(inserted[0].title).toBe('Tutorial: Getting Started');
scheduler.stop();
});
it('does not filter when no keywords are set', async () => {
const channel = await insertTestChannel({
includeKeywords: null,
excludeKeywords: null,
});
const scheduler = new SchedulerService(db, registry, rateLimiter);
mockFetchRecentContent.mockResolvedValueOnce(
makeCannedContent(4, `kf_none_${channel.id}`)
);
const result = await scheduler.checkChannel(channel);
expect(result.newItems).toBe(4);
scheduler.stop();
});
// ── monitoringMode-aware item creation tests ── // ── monitoringMode-aware item creation tests ──
it("creates items with monitored=false when channel monitoringMode is 'none'", async () => { it("creates items with monitored=false when channel monitoringMode is 'none'", async () => {

View file

@ -138,6 +138,9 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
bannerUrl: null, bannerUrl: null,
description: null, description: null,
subscriberCount: null, subscriberCount: null,
includeKeywords: null,
excludeKeywords: null,
contentRating: null,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z',
lastCheckedAt: null, lastCheckedAt: null,
@ -334,7 +337,7 @@ describe('YouTubeSource', () => {
expect(mockExecYtDlp).toHaveBeenNthCalledWith( expect(mockExecYtDlp).toHaveBeenNthCalledWith(
1, 1,
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url], ['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
{ timeout: 60_000 } { timeout: 90_000 }
); );
// Verify Phase 2 calls use --dump-json --no-playlist per video // Verify Phase 2 calls use --dump-json --no-playlist per video
@ -443,7 +446,7 @@ describe('YouTubeSource', () => {
expect(mockExecYtDlp).toHaveBeenCalledWith( expect(mockExecYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '--dump-json', '--playlist-items', '1:10', channel.url], ['--flat-playlist', '--dump-json', '--playlist-items', '1:10', channel.url],
{ timeout: 60_000 } { timeout: 90_000 }
); );
}); });
@ -456,7 +459,7 @@ describe('YouTubeSource', () => {
expect(mockExecYtDlp).toHaveBeenCalledWith( expect(mockExecYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url], ['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
{ timeout: 60_000 } { timeout: 90_000 }
); );
}); });
}); });

View file

@ -148,7 +148,7 @@ function makeProfile(overrides: Partial<FormatProfile> = {}): FormatProfile {
containerFormat: 'mp4', containerFormat: 'mp4',
isDefault: false, isDefault: false,
subtitleLanguages: null, subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
...overrides, ...overrides,

View file

@ -9,17 +9,20 @@ import type { Channel, Platform, MonitoringMode } from '../../types/index';
/** Fields needed to create a new channel (auto-generated fields excluded). */ /** Fields needed to create a new channel (auto-generated fields excluded). */
export type CreateChannelData = Omit< export type CreateChannelData = Omit<
Channel, Channel,
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' 'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords' | 'contentRating'
> & { > & {
monitoringMode?: Channel['monitoringMode']; monitoringMode?: Channel['monitoringMode'];
bannerUrl?: string | null; bannerUrl?: string | null;
description?: string | null; description?: string | null;
subscriberCount?: number | null; subscriberCount?: number | null;
includeKeywords?: string | null;
excludeKeywords?: string | null;
contentRating?: string | null;
}; };
/** Fields that can be updated on an existing channel. */ /** Fields that can be updated on an existing channel. */
export type UpdateChannelData = Partial< export type UpdateChannelData = Partial<
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount'> Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords'>
>; >;
type Db = LibSQLDatabase<typeof schema>; type Db = LibSQLDatabase<typeof schema>;
@ -47,6 +50,8 @@ export async function createChannel(
bannerUrl: data.bannerUrl ?? null, bannerUrl: data.bannerUrl ?? null,
description: data.description ?? null, description: data.description ?? null,
subscriberCount: data.subscriberCount ?? null, subscriberCount: data.subscriberCount ?? null,
includeKeywords: data.includeKeywords ?? null,
excludeKeywords: data.excludeKeywords ?? null,
}) })
.returning(); .returning();
@ -200,5 +205,8 @@ function mapRow(row: typeof channels.$inferSelect): Channel {
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
lastCheckedAt: row.lastCheckedAt, lastCheckedAt: row.lastCheckedAt,
lastCheckStatus: row.lastCheckStatus as Channel['lastCheckStatus'], lastCheckStatus: row.lastCheckStatus as Channel['lastCheckStatus'],
contentRating: row.contentRating ?? null,
includeKeywords: row.includeKeywords ?? null,
excludeKeywords: row.excludeKeywords ?? null,
}; };
} }

View file

@ -3,13 +3,13 @@ import { type LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '../schema/index'; import type * as schema from '../schema/index';
import { contentItems } from '../schema/index'; import { contentItems } from '../schema/index';
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index'; import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
import type { ContentCounts } from '../../types/api'; import type { ContentCounts, ContentTypeCounts } from '../../types/api';
// ── Types ── // ── Types ──
/** Fields needed to create a new content item. */ /** Fields needed to create a new content item. */
export interface CreateContentItemData { export interface CreateContentItemData {
channelId: number; channelId: number | null;
title: string; title: string;
platformContentId: string; platformContentId: string;
url: string; url: string;
@ -29,6 +29,7 @@ export interface UpdateContentItemData {
qualityMetadata?: QualityInfo | null; qualityMetadata?: QualityInfo | null;
status?: ContentStatus; status?: ContentStatus;
downloadedAt?: string | null; downloadedAt?: string | null;
contentRating?: string | null;
} }
type Db = LibSQLDatabase<typeof schema>; type Db = LibSQLDatabase<typeof schema>;
@ -43,16 +44,22 @@ export async function createContentItem(
db: Db, db: Db,
data: CreateContentItemData data: CreateContentItemData
): Promise<ContentItem | null> { ): Promise<ContentItem | null> {
// Check for existing item first — dedup by (channelId, platformContentId) // Check for existing item — dedup by (channelId, platformContentId) for channel items,
const existing = await db // or by platformContentId alone for ad-hoc items (channelId=null)
.select({ id: contentItems.id }) const dedupConditions = data.channelId !== null
.from(contentItems) ? and(
.where(
and(
eq(contentItems.channelId, data.channelId), eq(contentItems.channelId, data.channelId),
eq(contentItems.platformContentId, data.platformContentId) eq(contentItems.platformContentId, data.platformContentId)
) )
) : and(
sql`${contentItems.channelId} IS NULL`,
eq(contentItems.platformContentId, data.platformContentId)
);
const existing = await db
.select({ id: contentItems.id })
.from(contentItems)
.where(dedupConditions)
.limit(1); .limit(1);
if (existing.length > 0) { if (existing.length > 0) {
@ -167,21 +174,26 @@ function resolveSortColumn(sortBy?: string) {
} }
} }
/** Check if a specific content item exists for a channel. Returns the item or null. */ /** Check if a specific content item exists for a channel (or ad-hoc if channelId is null). Returns the item or null. */
export async function getContentByPlatformContentId( export async function getContentByPlatformContentId(
db: Db, db: Db,
channelId: number, channelId: number | null,
platformContentId: string platformContentId: string
): Promise<ContentItem | null> { ): Promise<ContentItem | null> {
const rows = await db const conditions = channelId !== null
.select() ? and(
.from(contentItems)
.where(
and(
eq(contentItems.channelId, channelId), eq(contentItems.channelId, channelId),
eq(contentItems.platformContentId, platformContentId) eq(contentItems.platformContentId, platformContentId)
) )
) : and(
sql`${contentItems.channelId} IS NULL`,
eq(contentItems.platformContentId, platformContentId)
);
const rows = await db
.select()
.from(contentItems)
.where(conditions)
.limit(1); .limit(1);
return rows.length > 0 ? mapRow(rows[0]) : null; return rows.length > 0 ? mapRow(rows[0]) : null;
@ -361,6 +373,35 @@ function buildContentFilterConditions(filters?: ContentItemFilters) {
return conditions; return conditions;
} }
// ── Content Counts by Type ──
/**
* Get content counts grouped by content type for a single channel.
* Returns { video, audio, livestream } with zero-defaults for missing types.
*/
export async function getContentCountsByType(
db: Db,
channelId: number
): Promise<ContentTypeCounts> {
const rows = await db
.select({
contentType: contentItems.contentType,
count: sql<number>`count(*)`,
})
.from(contentItems)
.where(eq(contentItems.channelId, channelId))
.groupBy(contentItems.contentType);
const counts: ContentTypeCounts = { video: 0, audio: 0, livestream: 0 };
for (const row of rows) {
const ct = row.contentType as keyof ContentTypeCounts;
if (ct in counts) {
counts[ct] = Number(row.count);
}
}
return counts;
}
// ── Content Counts by Channel ── // ── Content Counts by Channel ──
/** /**
@ -386,7 +427,8 @@ export async function getContentCountsByChannelIds(
const map = new Map<number, ContentCounts>(); const map = new Map<number, ContentCounts>();
for (const row of rows) { for (const row of rows) {
map.set(row.channelId, { // channelId is always non-null here — we're filtering by specific channelIds via inArray
map.set(row.channelId!, {
total: Number(row.total), total: Number(row.total),
monitored: Number(row.monitored), monitored: Number(row.monitored),
downloaded: Number(row.downloaded), downloaded: Number(row.downloaded),
@ -444,6 +486,7 @@ function mapRow(row: typeof contentItems.$inferSelect): ContentItem {
publishedAt: row.publishedAt ?? null, publishedAt: row.publishedAt ?? null,
downloadedAt: row.downloadedAt ?? null, downloadedAt: row.downloadedAt ?? null,
monitored: row.monitored, monitored: row.monitored,
contentRating: row.contentRating ?? null,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };

View file

@ -192,6 +192,7 @@ function mapRow(row: typeof formatProfiles.$inferSelect): FormatProfile {
embedChapters: row.embedChapters, embedChapters: row.embedChapters,
embedThumbnail: row.embedThumbnail, embedThumbnail: row.embedThumbnail,
sponsorBlockRemove: row.sponsorBlockRemove ?? null, sponsorBlockRemove: row.sponsorBlockRemove ?? null,
outputTemplate: row.outputTemplate ?? null,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };

View 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,
};
}

View file

@ -192,6 +192,7 @@ export async function countQueueItemsByStatus(
completed: 0, completed: 0,
failed: 0, failed: 0,
cancelled: 0, cancelled: 0,
paused: 0,
}; };
for (const row of rows) { for (const row of rows) {

View file

@ -10,6 +10,10 @@ type Db = LibSQLDatabase<typeof schema>;
export const APP_CHECK_INTERVAL = 'app.check_interval'; export const APP_CHECK_INTERVAL = 'app.check_interval';
export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads'; export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads';
export const APP_OUTPUT_TEMPLATE = 'app.output_template';
export const APP_NFO_ENABLED = 'app.nfo_enabled';
export const APP_TIMEZONE = 'app.timezone';
export const APP_THEME = 'app.theme';
export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated'; export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated';
// ── Read / Write ── // ── Read / Write ──
@ -86,6 +90,7 @@ export async function seedAppDefaults(db: Db): Promise<void> {
const defaults: Array<{ key: string; value: string }> = [ const defaults: Array<{ key: string; value: string }> = [
{ key: APP_CHECK_INTERVAL, value: appConfig.scheduler.defaultCheckInterval.toString() }, { key: APP_CHECK_INTERVAL, value: appConfig.scheduler.defaultCheckInterval.toString() },
{ key: APP_CONCURRENT_DOWNLOADS, value: appConfig.concurrentDownloads.toString() }, { key: APP_CONCURRENT_DOWNLOADS, value: appConfig.concurrentDownloads.toString() },
{ key: APP_OUTPUT_TEMPLATE, value: '{platform}/{channel}/{title}.{ext}' },
]; ];
for (const { key, value } of defaults) { for (const { key, value } of defaults) {

View file

@ -31,4 +31,7 @@ export const channels = sqliteTable('channels', {
bannerUrl: text('banner_url'), bannerUrl: text('banner_url'),
description: text('description'), description: text('description'),
subscriberCount: integer('subscriber_count'), subscriberCount: integer('subscriber_count'),
includeKeywords: text('include_keywords'), // nullable — pipe-separated patterns for auto-enqueue filtering
excludeKeywords: text('exclude_keywords'), // nullable — pipe-separated patterns for auto-enqueue filtering
contentRating: text('content_rating'), // nullable — default rating for all content from this channel (e.g. 'TV-PG', 'TV-MA')
}); });

View file

@ -6,7 +6,6 @@ import { channels } from './channels';
export const contentItems = sqliteTable('content_items', { export const contentItems = sqliteTable('content_items', {
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
channelId: integer('channel_id') channelId: integer('channel_id')
.notNull()
.references(() => channels.id, { onDelete: 'cascade' }), .references(() => channels.id, { onDelete: 'cascade' }),
title: text('title').notNull(), title: text('title').notNull(),
platformContentId: text('platform_content_id').notNull(), platformContentId: text('platform_content_id').notNull(),
@ -17,11 +16,12 @@ export const contentItems = sqliteTable('content_items', {
fileSize: integer('file_size'), // bytes fileSize: integer('file_size'), // bytes
format: text('format'), // container format e.g. 'mp4', 'webm', 'mp3' format: text('format'), // container format e.g. 'mp4', 'webm', 'mp3'
qualityMetadata: text('quality_metadata', { mode: 'json' }), // actual quality info post-download qualityMetadata: text('quality_metadata', { mode: 'json' }), // actual quality info post-download
status: text('status').notNull().default('monitored'), // monitored|queued|downloading|downloaded|failed|ignored status: text('status').notNull().default('monitored'), // monitored|queued|downloading|downloaded|failed|ignored|missing
thumbnailUrl: text('thumbnail_url'), thumbnailUrl: text('thumbnail_url'),
publishedAt: text('published_at'), // ISO datetime from platform (nullable) publishedAt: text('published_at'), // ISO datetime from platform (nullable)
downloadedAt: text('downloaded_at'), // ISO datetime when download completed (nullable) downloadedAt: text('downloaded_at'), // ISO datetime when download completed (nullable)
monitored: integer('monitored', { mode: 'boolean' }).notNull().default(true), // per-item monitoring toggle monitored: integer('monitored', { mode: 'boolean' }).notNull().default(true), // per-item monitoring toggle
contentRating: text('content_rating'), // nullable — per-item rating override (e.g. 'TV-PG', 'TV-MA')
createdAt: text('created_at') createdAt: text('created_at')
.notNull() .notNull()
.default(sql`(datetime('now'))`), .default(sql`(datetime('now'))`),
@ -44,6 +44,7 @@ export const formatProfiles = sqliteTable('format_profiles', {
embedChapters: integer('embed_chapters', { mode: 'boolean' }).notNull().default(false), embedChapters: integer('embed_chapters', { mode: 'boolean' }).notNull().default(false),
embedThumbnail: integer('embed_thumbnail', { mode: 'boolean' }).notNull().default(false), embedThumbnail: integer('embed_thumbnail', { mode: 'boolean' }).notNull().default(false),
sponsorBlockRemove: text('sponsor_block_remove'), // comma-separated categories: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler' sponsorBlockRemove: text('sponsor_block_remove'), // comma-separated categories: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
outputTemplate: text('output_template'), // per-profile path template override e.g. '{platform}/{channel}/{title}.{ext}'
createdAt: text('created_at') createdAt: text('created_at')
.notNull() .notNull()
.default(sql`(datetime('now'))`), .default(sql`(datetime('now'))`),

View file

@ -6,3 +6,4 @@ export { downloadHistory } from './history';
export { notificationSettings } from './notifications'; export { notificationSettings } from './notifications';
export { platformSettings } from './platform-settings'; export { platformSettings } from './platform-settings';
export { playlists, contentPlaylist } from './playlists'; export { playlists, contentPlaylist } from './playlists';
export { mediaServers } from './media-servers';

View 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'))`),
});

View file

@ -7,6 +7,13 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head> </head>
<body> <body>
<script>
// Apply saved theme instantly to prevent flash of wrong theme
(function() {
var t = localStorage.getItem('tubearr-theme');
if (t === 'light') document.documentElement.dataset.theme = 'light';
})();
</script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View file

@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { useTheme } from './hooks/useTheme';
import { Channels } from './pages/Channels'; import { Channels } from './pages/Channels';
import { ChannelDetail } from './pages/ChannelDetail'; import { ChannelDetail } from './pages/ChannelDetail';
import { Library } from './pages/Library'; import { Library } from './pages/Library';
@ -10,6 +11,8 @@ import { SettingsPage } from './pages/Settings';
import { SystemPage } from './pages/System'; import { SystemPage } from './pages/System';
function AuthenticatedLayout() { function AuthenticatedLayout() {
// Apply theme from settings to documentElement at the app root
useTheme();
return ( return (
<div style={{ display: 'flex', minHeight: '100vh' }}> <div style={{ display: 'flex', minHeight: '100vh' }}>
<Sidebar /> <Sidebar />

View 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),
});
}

View file

@ -59,7 +59,7 @@ export function useUpdateChannel(id: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null }) => mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; contentRating?: string | null }) =>
apiClient.put<Channel>(`/api/v1/channel/${id}`, data), apiClient.put<Channel>(`/api/v1/channel/${id}`, data),
onSuccess: (updated) => { onSuccess: (updated) => {
queryClient.setQueryData(channelKeys.detail(id), updated); queryClient.setQueryData(channelKeys.detail(id), updated);

View file

@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tansta
import { apiClient } from '../client'; import { apiClient } from '../client';
import { queueKeys } from './useQueue'; import { queueKeys } from './useQueue';
import type { ContentItem } from '@shared/types/index'; import type { ContentItem } from '@shared/types/index';
import type { ApiResponse, PaginatedResponse } from '@shared/types/api'; import type { ApiResponse, PaginatedResponse, ContentTypeCounts } from '@shared/types/api';
// ── Collect Types ── // ── Collect Types ──
@ -31,6 +31,8 @@ export const contentKeys = {
byChannel: (channelId: number) => ['content', 'channel', channelId] as const, byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) => byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
['content', 'channel', channelId, 'paginated', filters] as const, ['content', 'channel', channelId, 'paginated', filters] as const,
countsByType: (channelId: number) =>
['content', 'channel', channelId, 'counts-by-type'] as const,
}; };
// ── Queries ── // ── Queries ──
@ -59,6 +61,20 @@ export function useChannelContentPaginated(channelId: number, filters: ChannelCo
}); });
} }
/** Fetch content counts grouped by content type for a channel. */
export function useContentTypeCounts(channelId: number) {
return useQuery({
queryKey: contentKeys.countsByType(channelId),
queryFn: async () => {
const response = await apiClient.get<ApiResponse<ContentTypeCounts>>(
`/api/v1/channel/${channelId}/content-counts`,
);
return response.data;
},
enabled: channelId > 0,
});
}
// ── Mutations ── // ── Mutations ──
/** Enqueue a content item for download. Returns 202 with queue item. */ /** Enqueue a content item for download. Returns 202 with queue item. */
@ -138,3 +154,16 @@ export function useCollectAllMonitored() {
}, },
}); });
} }
/** Update content item rating. */
export function useUpdateContentRating(channelId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contentId, contentRating }: { contentId: number; contentRating: string | null }) =>
apiClient.patch<ApiResponse<ContentItem>>(`/api/v1/content/${contentId}/rating`, { contentRating }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['content', 'channel', channelId] });
},
});
}

View file

@ -29,6 +29,7 @@ interface CreateFormatProfileInput {
isDefault?: boolean; isDefault?: boolean;
subtitleLanguages?: string | null; subtitleLanguages?: string | null;
embedSubtitles?: boolean; embedSubtitles?: boolean;
outputTemplate?: string | null;
} }
interface UpdateFormatProfileInput { interface UpdateFormatProfileInput {
@ -40,6 +41,7 @@ interface UpdateFormatProfileInput {
isDefault?: boolean; isDefault?: boolean;
subtitleLanguages?: string | null; subtitleLanguages?: string | null;
embedSubtitles?: boolean; embedSubtitles?: boolean;
outputTemplate?: string | null;
} }
// ── Mutations ── // ── Mutations ──

View file

@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client'; import { apiClient } from '../client';
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index'; import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
import type { PaginatedResponse } from '@shared/types/api'; import type { PaginatedResponse } from '@shared/types/api';
@ -44,3 +44,17 @@ export function useLibraryContent(filters: LibraryFilters = {}) {
}, },
}); });
} }
// ── Mutations ──
/** Re-download a missing content item. Invalidates library queries on success. */
export function useRequeueContent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (contentId: number) =>
apiClient.post(`/api/v1/content/${contentId}/requeue`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}

View 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`),
});
}

View file

@ -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 });
},
});
}

View file

@ -10,6 +10,7 @@ export const systemKeys = {
apiKey: ['system', 'apikey'] as const, apiKey: ['system', 'apikey'] as const,
appSettings: ['system', 'appSettings'] as const, appSettings: ['system', 'appSettings'] as const,
ytdlpStatus: ['system', 'ytdlpStatus'] as const, ytdlpStatus: ['system', 'ytdlpStatus'] as const,
missingScanStatus: ['system', 'missingScanStatus'] as const,
}; };
// ── Queries ── // ── Queries ──
@ -91,3 +92,44 @@ export function useUpdateYtDlp() {
}, },
}); });
} }
// ── Missing File Scan ──
interface ScanResult {
checked: number;
missing: number;
duration: number;
}
interface MissingScanStatusResponse {
lastRun: string;
result: ScanResult;
}
interface MissingScanTriggerResponse {
success: boolean;
data: ScanResult;
}
/** Fetch last missing file scan status. Does not auto-refresh. */
export function useMissingScanStatus() {
return useQuery({
queryKey: systemKeys.missingScanStatus,
queryFn: () =>
apiClient.get<{ success: boolean; data: MissingScanStatusResponse | null }>(
'/api/v1/system/missing-scan/status',
),
});
}
/** Trigger an on-demand missing file scan. Invalidates scan status on success. */
export function useTriggerMissingScan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () =>
apiClient.post<MissingScanTriggerResponse>('/api/v1/system/missing-scan'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: systemKeys.missingScanStatus });
},
});
}

View 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>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useCallback, type FormEvent } from 'react'; import { useState, useCallback, useMemo, type FormEvent } from 'react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import type { FormatProfile } from '@shared/types/index'; import type { FormatProfile } from '@shared/types/index';
@ -23,6 +23,7 @@ export interface FormatProfileFormValues {
embedChapters: boolean; embedChapters: boolean;
embedThumbnail: boolean; embedThumbnail: boolean;
sponsorBlockRemove: string | null; sponsorBlockRemove: string | null;
outputTemplate: string | null;
} }
interface FormatProfileFormProps { interface FormatProfileFormProps {
@ -98,6 +99,38 @@ export function FormatProfileForm({
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false); const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false); const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
const [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? ''); const [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? '');
const [outputTemplate, setOutputTemplate] = useState(profile?.outputTemplate ?? '');
const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const;
const templatePreview = useMemo(() => {
if (!outputTemplate.trim()) return '';
const exampleVars: Record<string, string> = {
platform: 'youtube',
channel: 'TechChannel',
title: 'How to Build a Server',
date: '2026-04-04',
year: '2026',
month: '04',
contentType: 'video',
id: 'dQw4w9WgXcQ',
ext: 'mp4',
};
return outputTemplate.replace(/\{([a-zA-Z]+)\}/g, (_m, v: string) => exampleVars[v] ?? `{${v}}`);
}, [outputTemplate]);
const templateErrors = useMemo(() => {
if (!outputTemplate.trim()) return []; // empty = use system default
const errors: string[] = [];
if (!outputTemplate.includes('{ext}')) errors.push('Must contain {ext}');
const matches = [...outputTemplate.matchAll(/\{([a-zA-Z]+)\}/g)];
for (const m of matches) {
if (!(TEMPLATE_VARIABLES as readonly string[]).includes(m[1])) {
errors.push(`Unknown variable: {${m[1]}}`);
}
}
return errors;
}, [outputTemplate]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: FormEvent) => { (e: FormEvent) => {
@ -115,9 +148,10 @@ export function FormatProfileForm({
embedChapters, embedChapters,
embedThumbnail, embedThumbnail,
sponsorBlockRemove: sponsorBlockRemove.trim() || null, sponsorBlockRemove: sponsorBlockRemove.trim() || null,
outputTemplate: outputTemplate.trim() || null,
}); });
}, },
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, onSubmit], [name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, outputTemplate, onSubmit],
); );
return ( return (
@ -312,6 +346,39 @@ export function FormatProfileForm({
</span> </span>
</div> </div>
{/* Output Template (per-profile override) */}
<div style={fieldGroupStyle}>
<label htmlFor="fp-output-template" style={labelStyle}>
Output Template Override
</label>
<input
id="fp-output-template"
type="text"
value={outputTemplate}
onChange={(e) => setOutputTemplate(e.target.value)}
placeholder="Leave blank to use system default"
style={{
...inputStyle,
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
fontSize: 'var(--font-size-sm)',
borderColor: templateErrors.length > 0 ? 'var(--danger)' : undefined,
}}
/>
{templateErrors.length > 0 && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--danger)', marginTop: 'var(--space-1)', display: 'block' }}>
{templateErrors.join('. ')}
</span>
)}
{templatePreview && templateErrors.length === 0 && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block', fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)' }}>
Preview: {templatePreview}
</span>
)}
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
Variables: {'{platform}'}, {'{channel}'}, {'{title}'}, {'{date}'}, {'{year}'}, {'{month}'}, {'{contentType}'}, {'{id}'}, {'{ext}'}
</span>
</div>
{/* Is Default checkbox */} {/* Is Default checkbox */}
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}> <div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<input <input

View file

@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react'; import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
import type { ComponentHealth } from '@shared/types/api'; import type { ComponentHealth } from '@shared/types/api';
import { formatBytes } from '../utils/format'; import { formatBytes, formatLocalDateTime } from '../utils/format';
import { useTimezone } from '../hooks/useTimezone';
import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api'; import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
import type { UseMutationResult } from '@tanstack/react-query'; import type { UseMutationResult } from '@tanstack/react-query';
@ -36,6 +37,7 @@ interface HealthStatusProps {
} }
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) { export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
const timezone = useTimezone();
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS; const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1); const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
@ -292,7 +294,7 @@ function YtDlpDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
{/* Last Updated */} {/* Last Updated */}
{!ytdlpLoading && ytdlpStatus && ( {!ytdlpLoading && ytdlpStatus && (
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}> <div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Last checked: {ytdlpStatus.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'} Last checked: {ytdlpStatus.lastUpdated ? formatLocalDateTime(ytdlpStatus.lastUpdated, timezone) : 'Never'}
</div> </div>
)} )}

View 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>
);
}

View 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>
);
}

View file

@ -8,10 +8,12 @@ import {
Server, Server,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Link2,
} from 'lucide-react'; } from 'lucide-react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { TubearrLogo } from './TubearrLogo'; import { TubearrLogo } from './TubearrLogo';
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext'; import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
import { AddUrlModal } from './AddUrlModal';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/', icon: Radio, label: 'Channels' }, { to: '/', icon: Radio, label: 'Channels' },
@ -24,6 +26,7 @@ const NAV_ITEMS = [
export function Sidebar() { export function Sidebar() {
const wsConnected = useDownloadProgressConnection(); const wsConnected = useDownloadProgressConnection();
const [showAddUrl, setShowAddUrl] = useState(false);
const [collapsed, setCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(() => {
try { try {
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true'; return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
@ -129,6 +132,38 @@ export function Sidebar() {
))} ))}
</div> </div>
{/* Add URL button */}
<div
style={{
padding: collapsed ? 'var(--space-2) var(--space-2)' : 'var(--space-2) var(--space-3)',
borderTop: '1px solid var(--border)',
}}
>
<button
onClick={() => setShowAddUrl(true)}
title={collapsed ? 'Download URL' : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
width: '100%',
padding: `var(--space-2) ${collapsed ? 'var(--space-2)' : 'var(--space-3)'}`,
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
justifyContent: collapsed ? 'center' : 'flex-start',
transition: 'opacity var(--transition-fast)',
}}
>
<Link2 size={16} style={{ flexShrink: 0 }} />
{!collapsed && <span>Add URL</span>}
</button>
</div>
{/* WebSocket connection status */} {/* WebSocket connection status */}
<div <div
style={{ style={{
@ -163,6 +198,8 @@ export function Sidebar() {
</span> </span>
)} )}
</div> </div>
<AddUrlModal open={showAddUrl} onClose={() => setShowAddUrl(false)} />
</nav> </nav>
); );
} }

View file

@ -16,10 +16,12 @@ const STATUS_STYLES: Record<string, BadgeStyle> = {
downloading: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' }, downloading: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-bg)' }, failed: { color: 'var(--danger)', backgroundColor: 'var(--danger-bg)' },
queued: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' }, queued: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
missing: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
ignored: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' }, ignored: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
// Queue statuses // Queue statuses
pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' }, pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' },
completed: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' }, completed: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' },
paused: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' },
cancelled: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' }, cancelled: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' },
// Check statuses // Check statuses
success: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' }, success: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' },

View 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;
}

View 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;
}

View file

@ -6,22 +6,10 @@ import { Pagination } from '../components/Pagination';
import { FilterBar, type FilterDefinition } from '../components/FilterBar'; import { FilterBar, type FilterDefinition } from '../components/FilterBar';
import { SkeletonActivityList } from '../components/Skeleton'; import { SkeletonActivityList } from '../components/Skeleton';
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity'; import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
import { formatRelativeTime } from '../utils/format'; import { formatRelativeTime, formatTimestamp } from '../utils/format';
import { useTimezone } from '../hooks/useTimezone';
import type { DownloadHistoryRecord } from '@shared/types/index'; import type { DownloadHistoryRecord } from '@shared/types/index';
// ── Helpers ──
function formatTimestamp(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatEventType(type: string): string { function formatEventType(type: string): string {
return type return type
.split('_') .split('_')
@ -58,6 +46,7 @@ const EVENT_TYPES = [
// ── Component ── // ── Component ──
export function ActivityPage() { export function ActivityPage() {
const timezone = useTimezone();
const [activeTab, setActiveTab] = useState<'history' | 'recent'>('history'); const [activeTab, setActiveTab] = useState<'history' | 'recent'>('history');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState<Record<string, string>>({ const [filterValues, setFilterValues] = useState<Record<string, string>>({
@ -176,7 +165,7 @@ export function ActivityPage() {
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
}} }}
> >
{formatTimestamp(item.createdAt)} {formatTimestamp(item.createdAt, timezone)}
</span> </span>
), ),
}, },

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
import { usePersistedState } from '../hooks/usePersistedState'; import { usePersistedState } from '../hooks/usePersistedState';
import { useBulkSelection } from '../hooks/useBulkSelection'; import { useBulkSelection } from '../hooks/useBulkSelection';
import { import {
@ -12,6 +12,7 @@ import {
ChevronUp, ChevronUp,
Download, Download,
ExternalLink, ExternalLink,
Filter,
Film, Film,
Grid3X3, Grid3X3,
LayoutList, LayoutList,
@ -26,12 +27,13 @@ import {
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels'; import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels';
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; import { useChannelContentPaginated, useContentTypeCounts, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, useUpdateContentRating, type ChannelContentFilters } from '../api/hooks/useContent';
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists'; import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { PlatformBadge } from '../components/PlatformBadge'; import { PlatformBadge } from '../components/PlatformBadge';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { RatingBadge, RatingPicker } from '../components/RatingBadge';
import { QualityLabel } from '../components/QualityLabel'; import { QualityLabel } from '../components/QualityLabel';
import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { DownloadProgressBar } from '../components/DownloadProgressBar';
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
@ -42,7 +44,8 @@ import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext'; import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext';
import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount } from '../utils/format'; import { formatDuration, formatFileSize, formatRelativeTime, formatSubscriberCount, formatLocalDateTime } from '../utils/format';
import { useTimezone } from '../hooks/useTimezone';
import type { ContentItem, MonitoringMode } from '@shared/types/index'; import type { ContentItem, MonitoringMode } from '@shared/types/index';
// ── Helpers ── // ── Helpers ──
@ -68,25 +71,47 @@ const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
// ── Component ── // ── Component ──
export function ChannelDetail() { export function ChannelDetail() {
const timezone = useTimezone();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const channelId = parseInt(id ?? '0', 10); const channelId = parseInt(id ?? '0', 10);
// ── Data hooks ── // ── Data hooks ──
const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId); const { data: channel, isLoading: channelLoading, error: channelError } = useChannel(channelId);
const { data: formatProfiles } = useFormatProfiles(); const { data: formatProfiles } = useFormatProfiles();
const { data: playlistData } = useChannelPlaylists(channelId); const { data: playlistData } = useChannelPlaylists(channelId);
const { data: contentTypeCounts } = useContentTypeCounts(channelId);
// ── Content type tab (URL-driven) ──
const activeTab = searchParams.get('tab') ?? 'all';
// ── Content pagination state ── // ── Content pagination state ──
const [contentPage, setContentPage] = useState(1); const [contentPage, setContentPage] = useState(1);
const setActiveTab = useCallback((tab: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === 'all') {
next.delete('tab');
} else {
next.set('tab', tab);
}
return next;
}, { replace: true });
setContentPage(1);
}, [setSearchParams]);
const [contentSearch, setContentSearch] = useState(''); const [contentSearch, setContentSearch] = useState('');
const [contentStatusFilter, setContentStatusFilter] = useState(''); const [contentStatusFilter, setContentStatusFilter] = useState('');
const [contentTypeFilter, setContentTypeFilter] = useState('');
const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null); const [sortKey, setSortKey] = usePersistedState<string | null>('tubearr-sort-key', null);
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc'); const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none'); const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table'); const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
// Derive contentType filter from active tab
const contentTypeFilter = activeTab === 'all' ? '' : activeTab;
const contentFilters: ChannelContentFilters = useMemo(() => ({ const contentFilters: ChannelContentFilters = useMemo(() => ({
page: contentPage, page: contentPage,
pageSize: 50, pageSize: 50,
@ -116,6 +141,7 @@ export function ChannelDetail() {
const toggleMonitored = useToggleMonitored(channelId); const toggleMonitored = useToggleMonitored(channelId);
const refreshPlaylists = useRefreshPlaylists(channelId); const refreshPlaylists = useRefreshPlaylists(channelId);
const bulkMonitored = useBulkMonitored(channelId); const bulkMonitored = useBulkMonitored(channelId);
const updateContentRating = useUpdateContentRating(channelId);
// ── Scan state (WebSocket-driven) ── // ── Scan state (WebSocket-driven) ──
const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId); const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId);
@ -126,6 +152,10 @@ export function ChannelDetail() {
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>(''); const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false); const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
const [localIncludeKeywords, setLocalIncludeKeywords] = useState('');
const [localExcludeKeywords, setLocalExcludeKeywords] = useState('');
const [keywordsSaved, setKeywordsSaved] = useState(false);
const [showKeywordFilters, setShowKeywordFilters] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
// ── Collapsible header ── // ── Collapsible header ──
@ -153,6 +183,18 @@ export function ChannelDetail() {
} }
}, [channel?.checkInterval]); }, [channel?.checkInterval]);
// Sync local keyword fields from channel data
useEffect(() => {
if (channel) {
setLocalIncludeKeywords(channel.includeKeywords ?? '');
setLocalExcludeKeywords(channel.excludeKeywords ?? '');
// Auto-expand if filters are already set
if (channel.includeKeywords || channel.excludeKeywords) {
setShowKeywordFilters(true);
}
}
}, [channel?.includeKeywords, channel?.excludeKeywords]); // eslint-disable-line react-hooks/exhaustive-deps
// Surface download errors via toast // Surface download errors via toast
useEffect(() => { useEffect(() => {
if (downloadContent.isError) { if (downloadContent.isError) {
@ -188,6 +230,32 @@ export function ChannelDetail() {
); );
}, [localCheckInterval, updateChannel]); }, [localCheckInterval, updateChannel]);
const handleKeywordsSave = useCallback(() => {
// Convert newlines to pipes for storage, preserving pipes inside /regex/ patterns
const toStored = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
// Replace newlines with pipe separators
return trimmed
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('|');
};
const include = toStored(localIncludeKeywords);
const exclude = toStored(localExcludeKeywords);
updateChannel.mutate(
{ includeKeywords: include, excludeKeywords: exclude },
{
onSuccess: () => {
setKeywordsSaved(true);
setTimeout(() => setKeywordsSaved(false), 2500);
toast('Keyword filters saved', 'success');
},
},
);
}, [localIncludeKeywords, localExcludeKeywords, updateChannel, toast]);
const handleMonitoringModeChange = useCallback( const handleMonitoringModeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => { (e: React.ChangeEvent<HTMLSelectElement>) => {
setMonitoringMode.mutate({ monitoringMode: e.target.value }); setMonitoringMode.mutate({ monitoringMode: e.target.value });
@ -568,6 +636,19 @@ export function ChannelDetail() {
sortable: true, sortable: true,
render: (item) => <ContentStatusCell item={item} />, render: (item) => <ContentStatusCell item={item} />,
}, },
{
key: 'contentRating',
label: 'Rating',
width: '90px',
render: (item) => (
<RatingPicker
value={item.contentRating}
onChange={(rating) => updateContentRating.mutate({ contentId: item.id, contentRating: rating })}
disabled={updateContentRating.isPending}
compact
/>
),
},
{ {
key: 'quality', key: 'quality',
label: 'Quality', label: 'Quality',
@ -583,7 +664,7 @@ export function ChannelDetail() {
render: (item) => ( render: (item) => (
<span <span
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }} style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
title={item.publishedAt ?? ''} title={item.publishedAt ? formatLocalDateTime(item.publishedAt, timezone) : ''}
> >
{formatRelativeTime(item.publishedAt)} {formatRelativeTime(item.publishedAt)}
</span> </span>
@ -597,7 +678,7 @@ export function ChannelDetail() {
render: (item) => ( render: (item) => (
<span <span
style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }} style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontVariantNumeric: 'tabular-nums' }}
title={item.downloadedAt ?? ''} title={item.downloadedAt ? formatLocalDateTime(item.downloadedAt, timezone) : ''}
> >
{formatRelativeTime(item.downloadedAt)} {formatRelativeTime(item.downloadedAt)}
</span> </span>
@ -626,7 +707,7 @@ export function ChannelDetail() {
), ),
}, },
], ],
[toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll], [toggleMonitored, selectedIds, toggleSelect, isAllSelected, toggleSelectAll, updateContentRating],
); );
// ── Render helpers ── // ── Render helpers ──
@ -1197,6 +1278,18 @@ export function ChannelDetail() {
</select> </select>
</div> </div>
{/* Content Rating group */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Rating
</span>
<RatingPicker
value={channel.contentRating}
onChange={(rating) => updateChannel.mutate({ contentRating: rating })}
disabled={updateChannel.isPending}
/>
</div>
{/* Actions group */} {/* Actions group */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}> <span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
@ -1263,6 +1356,164 @@ export function ChannelDetail() {
</button> </button>
</div> </div>
</div> </div>
{/* Keyword Filter Section — collapsible */}
<div
style={{
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
marginTop: 'var(--space-4)',
}}
>
<button
onClick={() => setShowKeywordFilters((prev) => !prev)}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: (channel.includeKeywords || channel.excludeKeywords) ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
padding: 0,
}}
aria-expanded={showKeywordFilters}
>
<Filter size={14} />
Keyword Filters
{(channel.includeKeywords || channel.excludeKeywords) && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 18,
height: 18,
borderRadius: '50%',
backgroundColor: 'var(--accent)',
color: '#fff',
fontSize: '10px',
fontWeight: 700,
lineHeight: 1,
}}
>
{(channel.includeKeywords ? 1 : 0) + (channel.excludeKeywords ? 1 : 0)}
</span>
)}
{showKeywordFilters ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{showKeywordFilters && (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 'var(--space-4)',
marginTop: 'var(--space-3)',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<label
htmlFor="include-keywords"
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Include Patterns
</label>
<textarea
id="include-keywords"
value={localIncludeKeywords}
onChange={(e) => setLocalIncludeKeywords(e.target.value)}
placeholder={'One pattern per line. Supports:\n• plain text match\n• glob: *livestream*\n• regex: /^ep\\d+/'}
rows={3}
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-mono, monospace)',
resize: 'vertical',
minHeight: 60,
}}
/>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Only titles matching at least one pattern will be auto-enqueued
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<label
htmlFor="exclude-keywords"
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Exclude Patterns
</label>
<textarea
id="exclude-keywords"
value={localExcludeKeywords}
onChange={(e) => setLocalExcludeKeywords(e.target.value)}
placeholder={'One pattern per line. Supports:\n• plain text: shorts\n• glob: *#shorts*\n• regex: /\\bshorts?\\b/'}
rows={3}
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-mono, monospace)',
resize: 'vertical',
minHeight: 60,
}}
/>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Titles matching any pattern will be skipped during scan
</span>
</div>
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<button
onClick={handleKeywordsSave}
disabled={updateChannel.isPending}
className="btn btn-ghost"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
opacity: updateChannel.isPending ? 0.6 : 1,
}}
>
{updateChannel.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : keywordsSaved ? (
<CheckCircle size={14} style={{ color: 'var(--success)' }} />
) : (
<Save size={14} />
)}
{keywordsSaved ? 'Saved!' : 'Save Filters'}
</button>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Use pipe <code style={{ backgroundColor: 'var(--bg-hover)', padding: '1px 4px', borderRadius: 'var(--radius-sm)' }}>|</code> to separate multiple patterns on one line, or put each on its own line
</span>
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
@ -1279,6 +1530,158 @@ export function ChannelDetail() {
style={{ style={{
padding: 'var(--space-4) var(--space-5)', padding: 'var(--space-4) var(--space-5)',
borderBottom: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-3)',
}}
>
{/* 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', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 'var(--space-3)', gap: 'var(--space-3)',
@ -1337,23 +1740,6 @@ export function ChannelDetail() {
<option value="failed">Failed</option> <option value="failed">Failed</option>
<option value="ignored">Ignored</option> <option value="ignored">Ignored</option>
</select> </select>
{/* Type filter */}
<select
value={contentTypeFilter}
onChange={(e) => { setContentTypeFilter(e.target.value); setContentPage(1); }}
aria-label="Filter by type"
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 90,
}}
>
<option value="">All Types</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="livestream">Livestream</option>
</select>
{/* View mode segmented control */} {/* View mode segmented control */}
<div <div
style={{ style={{
@ -1425,6 +1811,7 @@ export function ChannelDetail() {
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Sort & Group controls */} {/* Sort & Group controls */}
<SortGroupBar <SortGroupBar
sortKey={sortKey} sortKey={sortKey}
@ -1504,12 +1891,12 @@ export function ChannelDetail() {
alignItems: 'center', alignItems: 'center',
gap: 'var(--space-3)', gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-5)', padding: 'var(--space-3) var(--space-5)',
backgroundColor: 'rgba(30, 32, 40, 0.75)', backgroundColor: 'var(--glass-bg)',
backdropFilter: 'blur(16px) saturate(1.4)', backdropFilter: 'blur(16px) saturate(1.4)',
WebkitBackdropFilter: 'blur(16px) saturate(1.4)', WebkitBackdropFilter: 'blur(16px) saturate(1.4)',
border: '1px solid rgba(255, 255, 255, 0.08)', border: '1px solid var(--glass-border)',
borderRadius: 'var(--radius-xl)', borderRadius: 'var(--radius-xl)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.35), inset 0 0.5px 0 rgba(255, 255, 255, 0.06)', boxShadow: 'var(--shadow-md)',
}} }}
> >
<span <span

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Library as LibraryIcon, RefreshCw, Film, Music } from 'lucide-react'; import { Library as LibraryIcon, RefreshCw, Film, Music, RotateCcw } from 'lucide-react';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { QualityLabel } from '../components/QualityLabel'; import { QualityLabel } from '../components/QualityLabel';
@ -9,7 +9,7 @@ import { Pagination } from '../components/Pagination';
import { SearchBar } from '../components/SearchBar'; import { SearchBar } from '../components/SearchBar';
import { FilterBar, type FilterDefinition } from '../components/FilterBar'; import { FilterBar, type FilterDefinition } from '../components/FilterBar';
import { SkeletonLibrary } from '../components/Skeleton'; import { SkeletonLibrary } from '../components/Skeleton';
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary'; import { useLibraryContent, useRequeueContent, type LibraryFilters } from '../api/hooks/useLibrary';
import { useChannels } from '../api/hooks/useChannels'; import { useChannels } from '../api/hooks/useChannels';
import { formatDuration, formatFileSize } from '../utils/format'; import { formatDuration, formatFileSize } from '../utils/format';
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index'; import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
@ -44,6 +44,7 @@ export function Library() {
// Queries // Queries
const { data, isLoading, error, refetch } = useLibraryContent(filters); const { data, isLoading, error, refetch } = useLibraryContent(filters);
const { data: channels } = useChannels(); const { data: channels } = useChannels();
const requeue = useRequeueContent();
// Reset to page 1 when filters change // Reset to page 1 when filters change
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback((value: string) => {
@ -80,6 +81,7 @@ export function Library() {
{ value: 'downloading', label: 'Downloading' }, { value: 'downloading', label: 'Downloading' },
{ value: 'downloaded', label: 'Downloaded' }, { value: 'downloaded', label: 'Downloaded' },
{ value: 'failed', label: 'Failed' }, { value: 'failed', label: 'Failed' },
{ value: 'missing', label: 'Missing' },
{ value: 'ignored', label: 'Ignored' }, { value: 'ignored', label: 'Ignored' },
], ],
}, },
@ -260,8 +262,30 @@ export function Library() {
</span> </span>
), ),
}, },
{
key: 'actions',
label: '',
width: '110px',
render: (item) =>
item.status === 'missing' ? (
<button
onClick={(e) => {
e.stopPropagation();
requeue.mutate(item.id);
}}
disabled={requeue.isPending}
className="btn btn-warning"
style={{ fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)', gap: 'var(--space-1)' }}
title="Re-download this item"
aria-label={`Re-download ${item.title}`}
>
<RotateCcw size={12} />
Re-download
</button>
) : null,
},
], ],
[channels, navigate], [channels, navigate, requeue],
); );
// Extract pagination from response // Extract pagination from response

View file

@ -6,11 +6,13 @@ import { SkeletonQueueList } from '../components/Skeleton';
import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { DownloadProgressBar } from '../components/DownloadProgressBar';
import { useDownloadProgress } from '../contexts/DownloadProgressContext'; import { useDownloadProgress } from '../contexts/DownloadProgressContext';
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue'; import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
import { formatShortDateTime } from '../utils/format';
import { useTimezone } from '../hooks/useTimezone';
import type { QueueItem, QueueStatus } from '@shared/types/index'; import type { QueueItem, QueueStatus } from '@shared/types/index';
// ── Helpers ── // ── Helpers ──
function formatTime(iso: string | null): string { function formatTime(iso: string | null, timezone: string): string {
if (!iso) return '—'; if (!iso) return '—';
const d = new Date(iso); const d = new Date(iso);
const now = new Date(); const now = new Date();
@ -19,7 +21,7 @@ function formatTime(iso: string | null): string {
if (diffMs < 60_000) return 'just now'; if (diffMs < 60_000) return 'just now';
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`; if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`; if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); return formatShortDateTime(iso, timezone);
} }
// ── Status Tab Options ── // ── Status Tab Options ──
@ -28,6 +30,7 @@ const STATUS_TABS: { value: QueueStatus | ''; label: string }[] = [
{ value: '', label: 'All' }, { value: '', label: 'All' },
{ value: 'pending', label: 'Pending' }, { value: 'pending', label: 'Pending' },
{ value: 'downloading', label: 'Downloading' }, { value: 'downloading', label: 'Downloading' },
{ value: 'paused', label: 'Paused' },
{ value: 'completed', label: 'Completed' }, { value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' }, { value: 'failed', label: 'Failed' },
]; ];
@ -47,6 +50,7 @@ function QueueItemProgress({ item }: { item: QueueItem }) {
// ── Component ── // ── Component ──
export function Queue() { export function Queue() {
const timezone = useTimezone();
const [statusFilter, setStatusFilter] = useState<QueueStatus | ''>(''); const [statusFilter, setStatusFilter] = useState<QueueStatus | ''>('');
// Query with 5s auto-refresh // Query with 5s auto-refresh
@ -144,7 +148,7 @@ export function Queue() {
width: '110px', width: '110px',
render: (item) => ( render: (item) => (
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}> <span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
{formatTime(item.startedAt)} {formatTime(item.startedAt, timezone)}
</span> </span>
), ),
}, },
@ -154,16 +158,46 @@ export function Queue() {
width: '110px', width: '110px',
render: (item) => ( render: (item) => (
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}> <span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-xs)' }}>
{formatTime(item.completedAt)} {formatTime(item.completedAt, timezone)}
</span> </span>
), ),
}, },
{ {
key: 'actions', key: 'actions',
label: 'Actions', label: 'Actions',
width: '100px', width: '120px',
render: (item) => ( render: (item) => (
<div style={{ display: 'flex', gap: 'var(--space-1)' }}> <div style={{ display: 'flex', gap: 'var(--space-1)' }}>
{(item.status === 'pending' || item.status === 'downloading') && (
<button
onClick={(e) => {
e.stopPropagation();
pauseMutation.mutate(item.id);
}}
disabled={pauseMutation.isPending}
title="Pause"
aria-label="Pause item"
className="btn-icon"
style={{ color: 'var(--info)' }}
>
<Pause size={14} />
</button>
)}
{item.status === 'paused' && (
<button
onClick={(e) => {
e.stopPropagation();
resumeMutation.mutate(item.id);
}}
disabled={resumeMutation.isPending}
title="Resume"
aria-label="Resume paused item"
className="btn-icon"
style={{ color: 'var(--success)' }}
>
<Play size={14} />
</button>
)}
{item.status === 'failed' && ( {item.status === 'failed' && (
<button <button
onClick={(e) => { onClick={(e) => {
@ -179,7 +213,7 @@ export function Queue() {
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
)} )}
{item.status === 'pending' && ( {(item.status === 'pending' || item.status === 'paused') && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -187,7 +221,7 @@ export function Queue() {
}} }}
disabled={cancelMutation.isPending} disabled={cancelMutation.isPending}
title="Cancel" title="Cancel"
aria-label="Cancel pending item" aria-label="Cancel item"
className="btn-icon" className="btn-icon"
style={{ color: 'var(--danger)' }} style={{ color: 'var(--danger)' }}
> >
@ -289,7 +323,7 @@ export function Queue() {
)} )}
{/* Mutation errors */} {/* Mutation errors */}
{(retryMutation.error || cancelMutation.error) && ( {(retryMutation.error || cancelMutation.error || pauseMutation.error || resumeMutation.error) && (
<div <div
style={{ style={{
padding: 'var(--space-3)', padding: 'var(--space-3)',
@ -306,6 +340,10 @@ export function Queue() {
? retryMutation.error.message ? retryMutation.error.message
: cancelMutation.error instanceof Error : cancelMutation.error instanceof Error
? cancelMutation.error.message ? cancelMutation.error.message
: pauseMutation.error instanceof Error
? pauseMutation.error.message
: resumeMutation.error instanceof Error
? resumeMutation.error.message
: 'Action failed'} : 'Action failed'}
</div> </div>
)} )}

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useMemo, useEffect } from 'react'; import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save } from 'lucide-react'; import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree, Server, Sun, Moon, Clock } from 'lucide-react';
import { import {
useFormatProfiles, useFormatProfiles,
useCreateFormatProfile, useCreateFormatProfile,
@ -20,11 +20,20 @@ import {
type NotificationSetting, type NotificationSetting,
} from '../api/hooks/useNotifications'; } from '../api/hooks/useNotifications';
import { useApiKey, useRegenerateApiKey, useAppSettings, useUpdateAppSettings } from '../api/hooks/useSystem'; import { useApiKey, useRegenerateApiKey, useAppSettings, useUpdateAppSettings } from '../api/hooks/useSystem';
import {
useMediaServers,
useCreateMediaServer,
useUpdateMediaServer,
useDeleteMediaServer,
useTestMediaServer,
type MediaServer,
} from '../api/hooks/useMediaServers';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm'; import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm'; import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm'; import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
import { MediaServerForm, type MediaServerFormValues } from '../components/MediaServerForm';
import { SkeletonSettings } from '../components/Skeleton'; import { SkeletonSettings } from '../components/Skeleton';
import type { FormatProfile, PlatformSettings } from '@shared/types/index'; import type { FormatProfile, PlatformSettings } from '@shared/types/index';
@ -78,34 +87,116 @@ export function SettingsPage() {
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false); const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
// ── Media Servers state ──
const { data: mediaServers, isLoading: mediaServersLoading } = useMediaServers();
const createMediaServerMutation = useCreateMediaServer();
const updateMediaServerMutation = useUpdateMediaServer();
const deleteMediaServerMutation = useDeleteMediaServer();
const testMediaServerMutation = useTestMediaServer();
const [showCreateMediaServerModal, setShowCreateMediaServerModal] = useState(false);
const [editingMediaServer, setEditingMediaServer] = useState<MediaServer | null>(null);
const [deletingMediaServer, setDeletingMediaServer] = useState<MediaServer | null>(null);
const [mediaServerTestResults, setMediaServerTestResults] = useState<Record<number, 'success' | 'error' | 'loading' | null>>({});
// ── App Settings state ── // ── App Settings state ──
const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings(); const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings();
const updateAppSettingsMutation = useUpdateAppSettings(); const updateAppSettingsMutation = useUpdateAppSettings();
const [checkInterval, setCheckInterval] = useState<number | ''>(''); const [checkInterval, setCheckInterval] = useState<number | ''>('');
const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>(''); const [concurrentDownloads, setConcurrentDownloads] = useState<number | ''>('');
const [outputTemplate, setOutputTemplate] = useState('');
const [nfoEnabled, setNfoEnabled] = useState(false);
const [timezone, setTimezone] = useState('UTC');
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [settingsSaveFlash, setSettingsSaveFlash] = useState(false); const [settingsSaveFlash, setSettingsSaveFlash] = useState(false);
// Timezone options — computed once
const timezoneOptions = useMemo(() => {
try {
return Intl.supportedValuesOf('timeZone');
} catch {
// Fallback for older browsers
return ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney'];
}
}, []);
// Timezone search filter
const [tzSearch, setTzSearch] = useState('');
const filteredTimezones = useMemo(() => {
if (!tzSearch) return timezoneOptions;
const lower = tzSearch.toLowerCase();
return timezoneOptions.filter(tz => tz.toLowerCase().includes(lower));
}, [timezoneOptions, tzSearch]);
// Initialize local state from fetched app settings // Initialize local state from fetched app settings
useEffect(() => { useEffect(() => {
if (appSettings) { if (appSettings) {
setCheckInterval(appSettings.checkInterval); setCheckInterval(appSettings.checkInterval);
setConcurrentDownloads(appSettings.concurrentDownloads); setConcurrentDownloads(appSettings.concurrentDownloads);
setOutputTemplate(appSettings.outputTemplate);
setNfoEnabled(appSettings.nfoEnabled);
setTimezone(appSettings.timezone);
setTheme(appSettings.theme);
} }
}, [appSettings]); }, [appSettings]);
// Apply theme preview immediately when user toggles (before save).
// The App-level useTheme hook reconciles with the API value after save.
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('tubearr-theme', theme);
}, [theme]);
const settingsDirty = const settingsDirty =
checkInterval !== '' && checkInterval !== '' &&
concurrentDownloads !== '' && concurrentDownloads !== '' &&
appSettings != null && appSettings != null &&
(Number(checkInterval) !== appSettings.checkInterval || (Number(checkInterval) !== appSettings.checkInterval ||
Number(concurrentDownloads) !== appSettings.concurrentDownloads); Number(concurrentDownloads) !== appSettings.concurrentDownloads ||
outputTemplate !== appSettings.outputTemplate ||
nfoEnabled !== appSettings.nfoEnabled ||
timezone !== appSettings.timezone ||
theme !== appSettings.theme);
const settingsValid = const settingsValid =
checkInterval !== '' && checkInterval !== '' &&
concurrentDownloads !== '' && concurrentDownloads !== '' &&
Number(checkInterval) >= 1 && Number(checkInterval) >= 1 &&
Number(concurrentDownloads) >= 1 && Number(concurrentDownloads) >= 1 &&
Number(concurrentDownloads) <= 10; Number(concurrentDownloads) <= 10 &&
outputTemplate.trim().length > 0 &&
outputTemplate.includes('{ext}');
// ── Template preview ──
const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const;
const templatePreview = useMemo(() => {
const exampleVars: Record<string, string> = {
platform: 'youtube',
channel: 'TechChannel',
title: 'How to Build a Server',
date: '2026-04-04',
year: '2026',
month: '04',
contentType: 'video',
id: 'dQw4w9WgXcQ',
ext: 'mp4',
};
return outputTemplate.replace(/\{([a-zA-Z]+)\}/g, (_m, v: string) => exampleVars[v] ?? `{${v}}`);
}, [outputTemplate]);
const templateErrors = useMemo(() => {
const errors: string[] = [];
if (outputTemplate.trim().length === 0) return ['Template must not be empty'];
if (!outputTemplate.includes('{ext}')) errors.push('Must contain {ext}');
const matches = [...outputTemplate.matchAll(/\{([a-zA-Z]+)\}/g)];
for (const m of matches) {
if (!(TEMPLATE_VARIABLES as readonly string[]).includes(m[1])) {
errors.push(`Unknown variable: {${m[1]}}`);
}
}
return errors;
}, [outputTemplate]);
// ── App Settings handlers ── // ── App Settings handlers ──
@ -115,6 +206,10 @@ export function SettingsPage() {
{ {
checkInterval: Number(checkInterval), checkInterval: Number(checkInterval),
concurrentDownloads: Number(concurrentDownloads), concurrentDownloads: Number(concurrentDownloads),
outputTemplate,
nfoEnabled,
timezone,
theme,
}, },
{ {
onSuccess: () => { onSuccess: () => {
@ -125,6 +220,67 @@ export function SettingsPage() {
); );
}, [settingsDirty, settingsValid, checkInterval, concurrentDownloads, updateAppSettingsMutation]); }, [settingsDirty, settingsValid, checkInterval, concurrentDownloads, updateAppSettingsMutation]);
// ── Media Server handlers ──
const handleCreateMediaServer = useCallback(
(values: MediaServerFormValues) => {
createMediaServerMutation.mutate(values, {
onSuccess: () => setShowCreateMediaServerModal(false),
});
},
[createMediaServerMutation],
);
const handleUpdateMediaServer = useCallback(
(values: MediaServerFormValues) => {
if (!editingMediaServer) return;
// Only send token if user entered a new one
const payload: Record<string, unknown> = {
id: editingMediaServer.id,
name: values.name,
type: values.type,
url: values.url,
librarySection: values.librarySection,
enabled: values.enabled,
};
if (values.token) {
payload.token = values.token;
}
updateMediaServerMutation.mutate(payload as Parameters<typeof updateMediaServerMutation.mutate>[0], {
onSuccess: () => setEditingMediaServer(null),
});
},
[editingMediaServer, updateMediaServerMutation],
);
const handleDeleteMediaServer = useCallback(() => {
if (!deletingMediaServer) return;
deleteMediaServerMutation.mutate(deletingMediaServer.id, {
onSuccess: () => setDeletingMediaServer(null),
});
}, [deletingMediaServer, deleteMediaServerMutation]);
const handleTestMediaServer = useCallback(
(id: number) => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: 'loading' }));
testMediaServerMutation.mutate(id, {
onSuccess: (data) => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: data.success ? 'success' : 'error' }));
setTimeout(() => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: null }));
}, 4000);
},
onError: () => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: 'error' }));
setTimeout(() => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: null }));
}, 4000);
},
});
},
[testMediaServerMutation],
);
// ── Format Profile handlers ── // ── Format Profile handlers ──
const handleCreateProfile = useCallback( const handleCreateProfile = useCallback(
@ -482,6 +638,118 @@ export function SettingsPage() {
[], [],
); );
// ── Media Server columns ──
const mediaServerColumns = useMemo<Column<MediaServer>[]>(
() => [
{
key: 'name',
label: 'Name',
render: (s) => (
<span style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{s.name}</span>
),
},
{
key: 'type',
label: 'Type',
width: '100px',
render: (s) => (
<span
style={{
...badgeBase,
color: s.type === 'plex' ? '#e5a00d' : '#00a4dc',
backgroundColor: s.type === 'plex' ? 'rgba(229, 160, 13, 0.1)' : 'rgba(0, 164, 220, 0.1)',
}}
>
{s.type === 'plex' ? 'Plex' : 'Jellyfin'}
</span>
),
},
{
key: 'url',
label: 'URL',
render: (s) => (
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-mono, monospace)' }}>
{s.url}
</span>
),
},
{
key: 'section',
label: 'Library',
width: '120px',
render: (s) => (
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)' }}>
{s.librarySection ?? 'All'}
</span>
),
},
{
key: 'enabled',
label: 'Status',
width: '90px',
render: (s) => (
<span
style={{
...badgeBase,
color: s.enabled ? 'var(--success)' : 'var(--text-muted)',
backgroundColor: s.enabled ? 'var(--success-bg)' : 'var(--bg-hover)',
}}
>
{s.enabled ? 'Active' : 'Disabled'}
</span>
),
},
{
key: 'actions',
label: '',
width: '130px',
render: (s) => {
const result = mediaServerTestResults[s.id];
return (
<div style={{ display: 'flex', gap: 'var(--space-2)', justifyContent: 'flex-end', alignItems: 'center' }}>
{result === 'success' && <CheckCircle size={14} style={{ color: 'var(--success)' }} />}
{result === 'error' && <XCircle size={14} style={{ color: 'var(--danger)' }} />}
<button
onClick={(e) => { e.stopPropagation(); handleTestMediaServer(s.id); }}
title="Test connection"
aria-label={`Test ${s.name}`}
disabled={result === 'loading'}
className="btn-icon btn-icon-test"
style={{ opacity: result === 'loading' ? 0.5 : 1 }}
>
{result === 'loading'
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
: <Send size={14} />
}
</button>
<button
onClick={(e) => { e.stopPropagation(); setEditingMediaServer(s); }}
title="Edit server"
aria-label={`Edit ${s.name}`}
className="btn-icon btn-icon-edit"
>
<Pencil size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setDeletingMediaServer(s); }}
title="Delete server"
aria-label={`Delete ${s.name}`}
className="btn-icon btn-icon-delete"
>
<Trash2 size={14} />
</button>
</div>
);
},
},
],
[mediaServerTestResults, handleTestMediaServer],
);
// ── Notification columns ── // ── Notification columns ──
const notificationColumns = useMemo<Column<NotificationSetting>[]>( const notificationColumns = useMemo<Column<NotificationSetting>[]>(
@ -798,6 +1066,116 @@ export function SettingsPage() {
)} )}
</td> </td>
</tr> </tr>
{/* Timezone selector */}
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ width: '200px', padding: 'var(--space-4)', fontWeight: 500, color: 'var(--text-primary)', verticalAlign: 'middle' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<Clock size={14} />
Timezone
</span>
</td>
<td style={{ padding: 'var(--space-4)' }}>
{appSettingsLoading ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)', color: 'var(--text-muted)' }}>
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
Loading
</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', maxWidth: 320 }}>
<input
type="text"
value={tzSearch}
onChange={(e) => setTzSearch(e.target.value)}
placeholder="Filter timezones…"
aria-label="Filter timezone list"
style={{
width: '100%',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
}}
/>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
aria-label="Timezone"
style={{
width: '100%',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
}}
>
{filteredTimezones.map(tz => (
<option key={tz} value={tz}>{tz.replace(/_/g, ' ')}</option>
))}
</select>
</div>
)}
</td>
</tr>
{/* Theme toggle */}
<tr>
<td style={{ width: '200px', padding: 'var(--space-4)', fontWeight: 500, color: 'var(--text-primary)', verticalAlign: 'middle' }}>
Theme
</td>
<td style={{ padding: 'var(--space-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<button
type="button"
onClick={() => setTheme('dark')}
aria-label="Dark theme"
aria-pressed={theme === 'dark'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${theme === 'dark' ? 'var(--accent)' : 'var(--border)'}`,
backgroundColor: theme === 'dark' ? 'var(--accent-subtle)' : 'var(--bg-main)',
color: theme === 'dark' ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: theme === 'dark' ? 600 : 400,
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
<Moon size={14} />
Dark
</button>
<button
type="button"
onClick={() => setTheme('light')}
aria-label="Light theme"
aria-pressed={theme === 'light'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${theme === 'light' ? 'var(--accent)' : 'var(--border)'}`,
backgroundColor: theme === 'light' ? 'var(--accent-subtle)' : 'var(--bg-main)',
color: theme === 'light' ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
fontWeight: theme === 'light' ? 600 : 400,
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
<Sun size={14} />
Light
</button>
</div>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -836,6 +1214,212 @@ export function SettingsPage() {
</p> </p>
</section> </section>
{/* ── File Organization section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ marginBottom: 'var(--space-4)' }}>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
<FolderTree size={20} />
File Organization
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', marginTop: 'var(--space-1)' }}>
Configure the default output path template for downloaded files. Individual format profiles can override this.
</p>
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
padding: 'var(--space-4)',
}}
>
{/* Template input */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="output-template"
style={{
display: 'block',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
marginBottom: 'var(--space-1)',
}}
>
Output Template
</label>
<input
id="output-template"
type="text"
value={outputTemplate}
onChange={(e) => setOutputTemplate(e.target.value)}
placeholder="{platform}/{channel}/{title}.{ext}"
aria-label="Output path template"
style={{
width: '100%',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${templateErrors.length > 0 ? 'var(--danger)' : 'var(--border)'}`,
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
}}
/>
{templateErrors.length > 0 && (
<div style={{ color: 'var(--danger)', fontSize: 'var(--font-size-xs)', marginTop: 'var(--space-1)' }}>
{templateErrors.join('. ')}
</div>
)}
</div>
{/* Available variables */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginBottom: 'var(--space-2)', display: 'block' }}>
Available variables
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
{TEMPLATE_VARIABLES.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const input = document.getElementById('output-template') as HTMLInputElement | null;
if (input) {
const start = input.selectionStart ?? outputTemplate.length;
const end = input.selectionEnd ?? outputTemplate.length;
const newVal = outputTemplate.slice(0, start) + `{${v}}` + outputTemplate.slice(end);
setOutputTemplate(newVal);
// Restore cursor position after React re-render
requestAnimationFrame(() => {
input.focus();
const newPos = start + v.length + 2;
input.setSelectionRange(newPos, newPos);
});
} else {
setOutputTemplate((prev) => prev + `{${v}}`);
}
}}
style={{
padding: '2px 8px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-secondary)',
fontSize: 'var(--font-size-xs)',
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
cursor: 'pointer',
transition: 'background-color var(--transition-fast)',
}}
title={`Insert {${v}} at cursor`}
>
{`{${v}}`}
</button>
))}
</div>
</div>
{/* Live preview */}
<div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginBottom: 'var(--space-1)', display: 'block' }}>
Preview
</span>
<div
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-main)',
border: '1px solid var(--border)',
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
wordBreak: 'break-all',
}}
>
{templatePreview || '—'}
</div>
</div>
{/* NFO Sidecar toggle */}
<div
style={{
marginTop: 'var(--space-4)',
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<label
htmlFor="nfo-enabled"
style={{
display: 'block',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-primary)',
cursor: 'pointer',
}}
>
Generate NFO Files
</label>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Write Kodi-compatible .nfo sidecar files alongside downloaded media
</span>
</div>
<label
style={{
position: 'relative',
display: 'inline-block',
width: 44,
height: 24,
flexShrink: 0,
}}
>
<input
id="nfo-enabled"
type="checkbox"
checked={nfoEnabled}
onChange={(e) => setNfoEnabled(e.target.checked)}
style={{
opacity: 0,
width: 0,
height: 0,
position: 'absolute',
}}
/>
<span
style={{
position: 'absolute',
cursor: 'pointer',
inset: 0,
backgroundColor: nfoEnabled ? 'var(--accent)' : 'var(--bg-hover)',
borderRadius: 12,
border: `1px solid ${nfoEnabled ? 'var(--accent)' : 'var(--border)'}`,
transition: 'background-color 0.2s, border-color 0.2s',
}}
>
<span
style={{
position: 'absolute',
content: '""',
height: 18,
width: 18,
left: nfoEnabled ? 22 : 2,
top: 2,
backgroundColor: '#fff',
borderRadius: '50%',
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}}
/>
</span>
</label>
</div>
</div>
</section>
{/* ── Platform Settings section ── */} {/* ── Platform Settings section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}> <section style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ marginBottom: 'var(--space-4)' }}> <div style={{ marginBottom: 'var(--space-4)' }}>
@ -904,6 +1488,58 @@ export function SettingsPage() {
</div> </div>
</section> </section>
{/* ── Media Servers section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 'var(--space-4)',
}}
>
<div>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
<Server size={20} />
Media Servers
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', marginTop: 'var(--space-1)' }}>
Connect Plex or Jellyfin servers to automatically scan libraries after downloads complete.
</p>
</div>
<button
onClick={() => setShowCreateMediaServerModal(true)}
className="btn btn-primary"
>
<Plus size={16} />
Add Server
</button>
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
>
{mediaServersLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
<Loader size={16} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-2)' }} />
Loading media servers...
</div>
) : (
<Table
columns={mediaServerColumns}
data={mediaServers ?? []}
keyExtractor={(s) => s.id}
emptyMessage="No media servers configured. Add a Plex or Jellyfin server to enable automatic library scans."
/>
)}
</div>
</section>
{/* ── Notifications section ── */} {/* ── Notifications section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}> <section style={{ marginBottom: 'var(--space-8)' }}>
<div <div
@ -1046,6 +1682,81 @@ export function SettingsPage() {
)} )}
</Modal> </Modal>
{/* ── Media Server: Create modal ── */}
<Modal title="Add Media Server" open={showCreateMediaServerModal} onClose={() => setShowCreateMediaServerModal(false)} width={520}>
<MediaServerForm
onSubmit={handleCreateMediaServer}
onCancel={() => setShowCreateMediaServerModal(false)}
isPending={createMediaServerMutation.isPending}
error={createMediaServerMutation.error instanceof Error ? createMediaServerMutation.error.message : null}
/>
</Modal>
{/* ── Media Server: Edit modal ── */}
<Modal
title={`Edit "${editingMediaServer?.name ?? ''}"`}
open={!!editingMediaServer}
onClose={() => setEditingMediaServer(null)}
width={520}
>
{editingMediaServer && (
<MediaServerForm
server={editingMediaServer}
onSubmit={handleUpdateMediaServer}
onCancel={() => setEditingMediaServer(null)}
isPending={updateMediaServerMutation.isPending}
error={updateMediaServerMutation.error instanceof Error ? updateMediaServerMutation.error.message : null}
/>
)}
</Modal>
{/* ── Media Server: Delete confirmation ── */}
<Modal
title="Delete Media Server"
open={!!deletingMediaServer}
onClose={() => setDeletingMediaServer(null)}
width={400}
>
<p style={{ color: 'var(--text-secondary)', marginBottom: 'var(--space-5)', lineHeight: 1.6 }}>
Are you sure you want to delete <strong style={{ color: 'var(--text-primary)' }}>{deletingMediaServer?.name}</strong>?
This action cannot be undone.
</p>
{deleteMediaServerMutation.error && (
<div
role="alert"
style={{
padding: 'var(--space-3)',
marginBottom: 'var(--space-4)',
backgroundColor: 'var(--danger-bg)',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md)',
color: 'var(--danger)',
fontSize: 'var(--font-size-sm)',
}}
>
{deleteMediaServerMutation.error instanceof Error ? deleteMediaServerMutation.error.message : 'Delete failed'}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<button
onClick={() => setDeletingMediaServer(null)}
disabled={deleteMediaServerMutation.isPending}
className="btn btn-ghost"
>
Cancel
</button>
<button
onClick={handleDeleteMediaServer}
disabled={deleteMediaServerMutation.isPending}
className="btn btn-danger"
style={{ opacity: deleteMediaServerMutation.isPending ? 0.6 : 1 }}
>
{deleteMediaServerMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Delete
</button>
</div>
</Modal>
{/* ── Notification: Create modal ── */} {/* ── Notification: Create modal ── */}
<Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}> <Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}>
<NotificationForm <NotificationForm

View file

@ -1,8 +1,9 @@
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react'; import { RefreshCw, Server, Activity, Cpu, HardDrive, Search } from 'lucide-react';
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem'; import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp, useMissingScanStatus, useTriggerMissingScan } from '../api/hooks/useSystem';
import { HealthStatus } from '../components/HealthStatus'; import { HealthStatus } from '../components/HealthStatus';
import { SkeletonSystem } from '../components/Skeleton'; import { SkeletonSystem } from '../components/Skeleton';
import { formatBytes } from '../utils/format'; import { formatBytes, formatLocalDateTime } from '../utils/format';
import { useTimezone } from '../hooks/useTimezone';
// ── Helpers ── // ── Helpers ──
@ -21,10 +22,13 @@ function formatUptime(seconds: number): string {
// ── Component ── // ── Component ──
export function SystemPage() { export function SystemPage() {
const timezone = useTimezone();
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth(); const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus(); const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus(); const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
const updateYtDlp = useUpdateYtDlp(); const updateYtDlp = useUpdateYtDlp();
const { data: missingScanData } = useMissingScanStatus();
const triggerMissingScan = useTriggerMissingScan();
const isLoading = healthLoading || statusLoading; const isLoading = healthLoading || statusLoading;
@ -92,6 +96,102 @@ export function SystemPage() {
) : null} ) : null}
</section> </section>
{/* ── Missing File Scan section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
<Search size={18} style={{ color: 'var(--warning)' }} />
Missing File Scan
</h2>
<button
onClick={() => triggerMissingScan.mutate()}
disabled={triggerMissingScan.isPending}
className="btn btn-warning"
>
{triggerMissingScan.isPending ? (
<>
<RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
Scanning
</>
) : (
<>
<Search size={14} />
Scan Now
</>
)}
</button>
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--border)',
padding: 'var(--space-4)',
}}
>
{triggerMissingScan.data && (
<div
role="status"
style={{
padding: 'var(--space-3)',
marginBottom: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
backgroundColor: triggerMissingScan.data.data.missing > 0 ? 'var(--warning-bg)' : 'var(--success-bg)',
color: triggerMissingScan.data.data.missing > 0 ? 'var(--warning)' : 'var(--success)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
}}
>
Scan complete: checked {triggerMissingScan.data.data.checked} files, found {triggerMissingScan.data.data.missing} missing
{' '}({(triggerMissingScan.data.data.duration / 1000).toFixed(1)}s)
</div>
)}
{triggerMissingScan.error && (
<div
role="alert"
style={{
padding: 'var(--space-3)',
marginBottom: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--danger-bg)',
color: 'var(--danger)',
fontSize: 'var(--font-size-sm)',
}}
>
Scan failed: {triggerMissingScan.error instanceof Error ? triggerMissingScan.error.message : 'Unknown error'}
</div>
)}
{missingScanData?.data ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<SystemInfoRow
icon={<Activity size={14} style={{ color: 'var(--text-muted)' }} />}
label="Last Scan"
value={formatLocalDateTime(missingScanData.data.lastRun, timezone)}
/>
<SystemInfoRow
icon={<Search size={14} style={{ color: 'var(--info)' }} />}
label="Files Checked"
value={String(missingScanData.data.result.checked)}
/>
<SystemInfoRow
icon={<HardDrive size={14} style={{ color: missingScanData.data.result.missing > 0 ? 'var(--warning)' : 'var(--success)' }} />}
label="Missing Files"
value={String(missingScanData.data.result.missing)}
/>
</tbody>
</table>
) : (
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', margin: 0 }}>
No scan has been run yet. Click "Scan Now" to check for missing files.
</p>
)}
</div>
</section>
{/* ── System Status section ── */} {/* ── System Status section ── */}
<section> <section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>

View file

@ -60,12 +60,12 @@ a:hover {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08); background: var(--bg-selected);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15); background: var(--border-light);
} }
/* ── Buttons base ── */ /* ── Buttons base ── */
@ -158,7 +158,7 @@ tr:hover {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--bg-input) 25%, var(--bg-input) 25%,
rgba(255, 255, 255, 0.04) 50%, var(--bg-hover) 50%,
var(--bg-input) 75% var(--bg-input) 75%
); );
background-size: 200% 100%; background-size: 200% 100%;

View file

@ -97,3 +97,57 @@
--glass-bg: rgba(20, 22, 30, 0.6); --glass-bg: rgba(20, 22, 30, 0.6);
--glass-border: rgba(255, 255, 255, 0.08); --glass-border: rgba(255, 255, 255, 0.08);
} }
/* ── Light Theme ── */
[data-theme="light"] {
/* ── Backgrounds ── */
--bg-main: #f5f5f8;
--bg-sidebar: #ebedf2;
--bg-card: rgba(255, 255, 255, 0.9);
--bg-card-solid: #ffffff;
--bg-input: #ffffff;
--bg-hover: rgba(0, 0, 0, 0.04);
--bg-selected: rgba(0, 0, 0, 0.06);
--bg-header: #f0f1f5;
--bg-toolbar: #f0f1f5;
--bg-modal-overlay: rgba(0, 0, 0, 0.3);
--bg-glass: rgba(255, 255, 255, 0.7);
/* ── Accent ── */
--accent: #d14836;
--accent-hover: #c03c2b;
--accent-subtle: rgba(209, 72, 54, 0.08);
--accent-glow: rgba(209, 72, 54, 0.15);
/* ── Text ── */
--text-primary: #1a1c24;
--text-secondary: #5a5d6b;
--text-muted: #9397a5;
--text-inverse: #f5f5f8;
--text-link: #d14836;
/* ── Status colors ── */
--success: #1e8e3e;
--success-bg: rgba(30, 142, 62, 0.08);
--warning: #e37400;
--warning-bg: rgba(227, 116, 0, 0.08);
--danger: #d93025;
--danger-bg: rgba(217, 48, 37, 0.08);
--info: #d14836;
--info-bg: rgba(209, 72, 54, 0.08);
/* ── Borders ── */
--border: rgba(0, 0, 0, 0.08);
--border-light: rgba(0, 0, 0, 0.12);
--border-accent: rgba(209, 72, 54, 0.25);
/* ── Shadows ── */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-glow: 0 0 20px rgba(209, 72, 54, 0.1);
/* ── Glassmorphism ── */
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(0, 0, 0, 0.06);
}

View file

@ -3,6 +3,9 @@
* *
* Consolidates format helpers that were previously duplicated across * Consolidates format helpers that were previously duplicated across
* ChannelDetail, Library, ContentCard, ContentListItem, Channels, Activity. * ChannelDetail, Library, ContentCard, ContentListItem, Channels, Activity.
*
* Timezone-aware helpers accept an IANA timezone string (e.g. "America/New_York").
* Pass the value from useTimezone() at the call site.
*/ */
/** Format a byte count into a human-readable string (B, KB, MB, GB, TB). */ /** Format a byte count into a human-readable string (B, KB, MB, GB, TB). */
@ -60,3 +63,51 @@ export function formatSubscriberCount(count: number | null): string | null {
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`; return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
} }
// ── Timezone-aware formatters ──
/**
* Format an ISO date string as a full timestamp in the given timezone.
* Example: "Jan 5, 2:30:45 PM"
*/
export function formatTimestamp(isoString: string | null, timezone: string): string {
if (!isoString) return '—';
const d = new Date(isoString);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString(undefined, {
timeZone: timezone,
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* Format an ISO date string as a short date+time in the given timezone.
* Example: "Jan 5, 02:30 PM"
*/
export function formatShortDateTime(isoString: string | null, timezone: string): string {
if (!isoString) return '—';
const d = new Date(isoString);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString(undefined, {
timeZone: timezone,
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Format an ISO date string as a locale date+time string in the given timezone.
* Uses the browser's default locale format. Example: "1/5/2025, 2:30:45 PM"
*/
export function formatLocalDateTime(isoString: string | null, timezone: string): string {
if (!isoString) return '—';
const d = new Date(isoString);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString(undefined, { timeZone: timezone });
}

View file

@ -19,6 +19,9 @@ import { DownloadEventBus } from './services/event-bus';
import { QueueService } from './services/queue'; import { QueueService } from './services/queue';
import { NotificationService } from './services/notification'; import { NotificationService } from './services/notification';
import { HealthService } from './services/health'; import { HealthService } from './services/health';
import { MissingFileScanner } from './services/missing-file-scanner';
import { MediaServerService } from './services/media-server';
import { getEnabledMediaServers } from './db/repositories/media-server-repository';
import { PlatformRegistry } from './sources/platform-source'; import { PlatformRegistry } from './sources/platform-source';
import { YouTubeSource } from './sources/youtube'; import { YouTubeSource } from './sources/youtube';
import { SoundCloudSource } from './sources/soundcloud'; import { SoundCloudSource } from './sources/soundcloud';
@ -165,6 +168,43 @@ async function main(): Promise<void> {
); );
(server as { healthService: HealthService | null }).healthService = healthService; (server as { healthService: HealthService | null }).healthService = healthService;
// 5c-ii. Set up missing file scanner
const missingFileScanner = new MissingFileScanner(db);
(server as { missingFileScanner: MissingFileScanner | null }).missingFileScanner = missingFileScanner;
// 5d. Wire automatic media-server scans on download completion
const mediaServerService = new MediaServerService();
eventBus.onDownload('download:complete', (payload) => {
getEnabledMediaServers(db)
.then(async (servers) => {
if (servers.length === 0) return;
console.log(
`[media-server] download complete contentItemId=${payload.contentItemId} — triggering ${servers.length} server scan(s)`
);
const results = await Promise.allSettled(
servers.map((s) => mediaServerService.triggerScan(s))
);
for (let i = 0; i < results.length; i++) {
const r = results[i];
const s = servers[i];
if (r.status === 'fulfilled') {
if (r.value.success) {
console.log(`[media-server] scan ok server="${s.name}" msg="${r.value.message}"`);
} else {
console.log(`[media-server] scan failed server="${s.name}" msg="${r.value.message}"`);
}
} else {
console.log(`[media-server] scan error server="${s.name}" err="${r.reason}"`);
}
}
})
.catch((err) => {
console.log(
`[media-server] failed to query enabled servers: ${err instanceof Error ? err.message : String(err)}`
);
});
});
// 6. Graceful shutdown handler // 6. Graceful shutdown handler
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(`[${APP_NAME}] ${signal} received — shutting down gracefully...`); console.log(`[${APP_NAME}] ${signal} received — shutting down gracefully...`);

View file

@ -22,12 +22,16 @@ import { platformSettingsRoutes } from './routes/platform-settings';
import { scanRoutes } from './routes/scan'; import { scanRoutes } from './routes/scan';
import { collectRoutes } from './routes/collect'; import { collectRoutes } from './routes/collect';
import { playlistRoutes } from './routes/playlist'; import { playlistRoutes } from './routes/playlist';
import { adhocDownloadRoutes } from './routes/adhoc-download';
import { mediaServerRoutes } from './routes/media-server';
import { feedRoutes } from './routes/feed';
import { websocketRoutes } from './routes/websocket'; import { websocketRoutes } from './routes/websocket';
import type { SchedulerService } from '../services/scheduler'; import type { SchedulerService } from '../services/scheduler';
import type { DownloadService } from '../services/download'; import type { DownloadService } from '../services/download';
import type { QueueService } from '../services/queue'; import type { QueueService } from '../services/queue';
import type { HealthService } from '../services/health'; import type { HealthService } from '../services/health';
import type { DownloadEventBus } from '../services/event-bus'; import type { DownloadEventBus } from '../services/event-bus';
import type { MissingFileScanner } from '../services/missing-file-scanner';
import type { ViteDevServer } from 'vite'; import type { ViteDevServer } from 'vite';
// Extend Fastify's type system so routes can access the database and scheduler // Extend Fastify's type system so routes can access the database and scheduler
@ -38,6 +42,7 @@ declare module 'fastify' {
downloadService: DownloadService | null; downloadService: DownloadService | null;
queueService: QueueService | null; queueService: QueueService | null;
healthService: HealthService | null; healthService: HealthService | null;
missingFileScanner: MissingFileScanner | null;
} }
} }
@ -86,6 +91,9 @@ export async function buildServer(opts: BuildServerOptions): Promise<FastifyInst
// Decorate with health service (null until set by startup code) // Decorate with health service (null until set by startup code)
server.decorate('healthService', null); server.decorate('healthService', null);
// Decorate with missing file scanner (null until set by startup code)
server.decorate('missingFileScanner', null);
// Register CORS — permissive for development, tightened later // Register CORS — permissive for development, tightened later
await server.register(cors, { origin: true }); await server.register(cors, { origin: true });
@ -109,6 +117,9 @@ export async function buildServer(opts: BuildServerOptions): Promise<FastifyInst
await server.register(scanRoutes); await server.register(scanRoutes);
await server.register(collectRoutes); await server.register(collectRoutes);
await server.register(playlistRoutes); await server.register(playlistRoutes);
await server.register(adhocDownloadRoutes);
await server.register(mediaServerRoutes);
await server.register(feedRoutes);
// Register WebSocket route (before static file serving so /ws is handled) // Register WebSocket route (before static file serving so /ws is handled)
if (opts.eventBus) { if (opts.eventBus) {

View file

@ -136,6 +136,13 @@ async function authPluginHandler(fastify: FastifyInstance): Promise<void> {
return; return;
} }
// Skip auth for public routes that must work without API keys
// RSS readers and podcast apps can't easily send API keys
const urlPath = request.url.split('?')[0];
if (urlPath.startsWith('/api/v1/feed/') || urlPath.startsWith('/api/v1/media/')) {
return;
}
// Same-origin bypass: browser UI requests are trusted internal clients // Same-origin bypass: browser UI requests are trusted internal clients
if (isSameOriginRequest(request)) { if (isSameOriginRequest(request)) {
request.log.debug(`[auth] same-origin bypass for ${request.url}`); request.log.debug(`[auth] same-origin bypass for ${request.url}`);

View 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',
});
}
},
);
}

View file

@ -54,6 +54,9 @@ const updateChannelBodySchema = {
checkInterval: { type: 'number' as const, minimum: 1 }, checkInterval: { type: 'number' as const, minimum: 1 },
monitoringEnabled: { type: 'boolean' as const }, monitoringEnabled: { type: 'boolean' as const },
formatProfileId: { type: 'number' as const, nullable: true }, formatProfileId: { type: 'number' as const, nullable: true },
includeKeywords: { type: 'string' as const, nullable: true },
excludeKeywords: { type: 'string' as const, nullable: true },
contentRating: { type: 'string' as const, nullable: true },
}, },
additionalProperties: false, additionalProperties: false,
}; };
@ -253,7 +256,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
fastify.put<{ fastify.put<{
Params: { id: string }; Params: { id: string };
Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null }; Body: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; contentRating?: string | null };
}>( }>(
'/api/v1/channel/:id', '/api/v1/channel/:id',
{ {

View file

@ -4,10 +4,12 @@ import {
getAllContentItems, getAllContentItems,
getContentByChannelId, getContentByChannelId,
getChannelContentPaginated, getChannelContentPaginated,
getContentCountsByType,
setMonitored, setMonitored,
bulkSetMonitored, bulkSetMonitored,
updateContentItem,
} from '../../db/repositories/content-repository'; } from '../../db/repositories/content-repository';
import type { PaginatedResponse, ApiResponse } from '../../types/api'; import type { PaginatedResponse, ApiResponse, ContentTypeCounts } from '../../types/api';
import type { ContentItem, ContentStatus, ContentType } from '../../types/index'; import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
// ── JSON Schemas for Fastify Validation ── // ── JSON Schemas for Fastify Validation ──
@ -31,6 +33,15 @@ const toggleMonitoredBodySchema = {
additionalProperties: false, additionalProperties: false,
}; };
const updateRatingBodySchema = {
type: 'object' as const,
required: ['contentRating'],
properties: {
contentRating: { type: 'string' as const, nullable: true },
},
additionalProperties: false,
};
// ── Route Plugin ── // ── Route Plugin ──
/** /**
@ -40,6 +51,7 @@ const toggleMonitoredBodySchema = {
* GET /api/v1/content paginated content listing with optional filters * GET /api/v1/content paginated content listing with optional filters
* PATCH /api/v1/content/bulk/monitored bulk toggle monitored state * PATCH /api/v1/content/bulk/monitored bulk toggle monitored state
* PATCH /api/v1/content/:id/monitored toggle single item monitored state * PATCH /api/v1/content/:id/monitored toggle single item monitored state
* PATCH /api/v1/content/:id/rating update content item rating
* GET /api/v1/channel/:id/content content items for a specific channel * GET /api/v1/channel/:id/content content items for a specific channel
*/ */
export async function contentRoutes(fastify: FastifyInstance): Promise<void> { export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
@ -196,6 +208,83 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
// ── GET /api/v1/channel/:id/content ── // ── GET /api/v1/channel/:id/content ──
// ── PATCH /api/v1/content/:id/rating ──
fastify.patch<{
Params: { id: string };
Body: { contentRating: string | null };
}>(
'/api/v1/content/:id/rating',
{ schema: { body: updateRatingBodySchema } },
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Content item ID');
if (id === null) return;
try {
const result = await updateContentItem(
fastify.db,
id,
{ contentRating: request.body.contentRating },
);
if (!result) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: 'Content item not found',
});
}
const response: ApiResponse<ContentItem> = {
success: true,
data: result,
};
return response;
} catch (err) {
request.log.error(
{ err, id },
'[content] Failed to update content rating'
);
return reply.status(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: 'Failed to update content rating',
});
}
}
);
// ── GET /api/v1/channel/:id/content-counts ──
fastify.get<{
Params: { id: string };
}>('/api/v1/channel/:id/content-counts', async (request, reply) => {
const channelId = parseIdParam(request.params.id, reply, 'Channel ID');
if (channelId === null) return;
try {
const counts = await getContentCountsByType(fastify.db, channelId);
const response: ApiResponse<ContentTypeCounts> = {
success: true,
data: counts,
};
return response;
} catch (err) {
request.log.error(
{ err, channelId },
'[content] Failed to fetch content type counts'
);
return reply.status(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: `Failed to retrieve content type counts for channel ${channelId}`,
});
}
});
// ── GET /api/v1/channel/:id/content (paginated) ──
fastify.get<{ fastify.get<{
Params: { id: string }; Params: { id: string };
Querystring: { Querystring: {

257
src/server/routes/feed.ts Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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);
}
);
}

View 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);
}
);
}

View file

@ -19,6 +19,8 @@ import type { QueueStatus } from '../../types/index';
* POST /api/v1/queue enqueue a content item for download * POST /api/v1/queue enqueue a content item for download
* DELETE /api/v1/queue/:id cancel a queue item * DELETE /api/v1/queue/:id cancel a queue item
* POST /api/v1/queue/:id/retry retry a failed queue item * POST /api/v1/queue/:id/retry retry a failed queue item
* PUT /api/v1/queue/:id/pause pause a pending or downloading queue item
* PUT /api/v1/queue/:id/resume resume a paused queue item
*/ */
export async function queueRoutes(fastify: FastifyInstance): Promise<void> { export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
// ── GET /api/v1/queue ── // ── GET /api/v1/queue ──
@ -35,6 +37,7 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
'completed', 'completed',
'failed', 'failed',
'cancelled', 'cancelled',
'paused',
]; ];
if (!validStatuses.includes(status as QueueStatus)) { if (!validStatuses.includes(status as QueueStatus)) {
return _reply.status(400).send({ return _reply.status(400).send({
@ -216,4 +219,90 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
} }
} }
); );
// ── PUT /api/v1/queue/:id/pause ──
fastify.put<{ Params: { id: string } }>(
'/api/v1/queue/:id/pause',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
if (id === null) return;
if (!fastify.queueService) {
return reply.status(503).send({
statusCode: 503,
error: 'Service Unavailable',
message: 'Queue service is not initialized',
});
}
try {
const paused = await fastify.queueService.pauseItem(id);
return reply.status(200).send({ success: true, data: paused });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('not found')) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message,
});
}
if (message.includes('Cannot pause')) {
return reply.status(409).send({
statusCode: 409,
error: 'Conflict',
message,
});
}
throw err;
}
}
);
// ── PUT /api/v1/queue/:id/resume ──
fastify.put<{ Params: { id: string } }>(
'/api/v1/queue/:id/resume',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
if (id === null) return;
if (!fastify.queueService) {
return reply.status(503).send({
statusCode: 503,
error: 'Service Unavailable',
message: 'Queue service is not initialized',
});
}
try {
const resumed = await fastify.queueService.resumeItem(id);
return reply.status(200).send({ success: true, data: resumed });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('not found')) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message,
});
}
if (message.includes('Cannot resume')) {
return reply.status(409).send({
statusCode: 409,
error: 'Conflict',
message,
});
}
throw err;
}
}
);
} }

View file

@ -8,12 +8,18 @@ import { appConfig } from '../../config/index';
import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '../../types/api'; import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '../../types/api';
import { systemConfig } from '../../db/schema/index'; import { systemConfig } from '../../db/schema/index';
import { API_KEY_DB_KEY } from '../middleware/auth'; import { API_KEY_DB_KEY } from '../middleware/auth';
import { getContentItemById, updateContentItem } from '../../db/repositories/content-repository';
import { parseIdParam } from './helpers';
import { import {
getAppSettings, getAppSettings,
getAppSetting, getAppSetting,
setAppSetting, setAppSetting,
APP_CHECK_INTERVAL, APP_CHECK_INTERVAL,
APP_CONCURRENT_DOWNLOADS, APP_CONCURRENT_DOWNLOADS,
APP_OUTPUT_TEMPLATE,
APP_NFO_ENABLED,
APP_TIMEZONE,
APP_THEME,
YTDLP_LAST_UPDATED, YTDLP_LAST_UPDATED,
} from '../../db/repositories/system-config-repository'; } from '../../db/repositories/system-config-repository';
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp'; import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
@ -114,11 +120,15 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
*/ */
fastify.get('/api/v1/system/settings', async (_request, _reply) => { fastify.get('/api/v1/system/settings', async (_request, _reply) => {
const db = fastify.db; const db = fastify.db;
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS]); const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED, APP_TIMEZONE, APP_THEME]);
const response: AppSettingsResponse = { const response: AppSettingsResponse = {
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10), checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10), concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
timezone: settings[APP_TIMEZONE] ?? 'UTC',
theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light',
}; };
return response; return response;
@ -131,7 +141,7 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
*/ */
fastify.put('/api/v1/system/settings', async (request, reply) => { fastify.put('/api/v1/system/settings', async (request, reply) => {
const db = fastify.db; const db = fastify.db;
const body = request.body as { checkInterval?: number; concurrentDownloads?: number }; const body = request.body as { checkInterval?: number; concurrentDownloads?: number; outputTemplate?: string; nfoEnabled?: boolean; timezone?: string; theme?: string };
// Validate // Validate
if (body.checkInterval !== undefined) { if (body.checkInterval !== undefined) {
@ -172,12 +182,56 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
fastify.queueService.setConcurrency(body.concurrentDownloads); fastify.queueService.setConcurrency(body.concurrentDownloads);
} }
} }
if (body.outputTemplate !== undefined) {
if (typeof body.outputTemplate !== 'string' || body.outputTemplate.trim().length === 0) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'outputTemplate must be a non-empty string',
});
}
if (!body.outputTemplate.includes('{ext}')) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'outputTemplate must contain {ext} for the file extension',
});
}
await setAppSetting(db, APP_OUTPUT_TEMPLATE, body.outputTemplate);
}
if (body.nfoEnabled !== undefined) {
await setAppSetting(db, APP_NFO_ENABLED, body.nfoEnabled ? 'true' : 'false');
}
if (body.timezone !== undefined) {
if (typeof body.timezone !== 'string' || body.timezone.trim().length === 0) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'timezone must be a non-empty string',
});
}
await setAppSetting(db, APP_TIMEZONE, body.timezone);
}
if (body.theme !== undefined) {
if (body.theme !== 'dark' && body.theme !== 'light') {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'theme must be "dark" or "light"',
});
}
await setAppSetting(db, APP_THEME, body.theme);
}
// Return updated values // Return updated values
const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS]); const settings = await getAppSettings(db, [APP_CHECK_INTERVAL, APP_CONCURRENT_DOWNLOADS, APP_OUTPUT_TEMPLATE, APP_NFO_ENABLED, APP_TIMEZONE, APP_THEME]);
const response: AppSettingsResponse = { const response: AppSettingsResponse = {
checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10), checkInterval: parseInt(settings[APP_CHECK_INTERVAL] ?? '360', 10),
concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10), concurrentDownloads: parseInt(settings[APP_CONCURRENT_DOWNLOADS] ?? '2', 10),
outputTemplate: settings[APP_OUTPUT_TEMPLATE] ?? '{platform}/{channel}/{title}.{ext}',
nfoEnabled: settings[APP_NFO_ENABLED] === 'true',
timezone: settings[APP_TIMEZONE] ?? 'UTC',
theme: (settings[APP_THEME] === 'light' ? 'light' : 'dark') as 'dark' | 'light',
}; };
return response; return response;
@ -222,4 +276,110 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
}; };
return response; return response;
}); });
// ── Missing File Scan ──
/**
* POST /api/v1/system/missing-scan Trigger an on-demand missing file scan.
* Returns scan results (checked, missing, duration).
*/
fastify.post('/api/v1/system/missing-scan', async (request, reply) => {
if (!fastify.missingFileScanner) {
return reply.status(503).send({
statusCode: 503,
error: 'Service Unavailable',
message: 'Missing file scanner is not initialized',
});
}
try {
const result = await fastify.missingFileScanner.scanAll();
request.log.info(
{ checked: result.checked, missing: result.missing, duration: result.duration },
'[system] Missing file scan completed'
);
return { success: true, data: result };
} catch (err) {
request.log.error({ err }, '[system] Missing file scan failed');
return reply.status(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: 'Missing file scan failed',
});
}
});
/**
* GET /api/v1/system/missing-scan/status Last scan time and results.
*/
fastify.get('/api/v1/system/missing-scan/status', async (_request, reply) => {
if (!fastify.missingFileScanner) {
return reply.status(503).send({
statusCode: 503,
error: 'Service Unavailable',
message: 'Missing file scanner is not initialized',
});
}
const lastScan = await fastify.missingFileScanner.getLastScanResult();
return { success: true, data: lastScan };
});
// ── Content Requeue ──
/**
* POST /api/v1/content/:id/requeue Re-download a missing content item.
* Resets the content status from 'missing' to 'monitored' and enqueues for download.
*/
fastify.post<{ Params: { id: string } }>(
'/api/v1/content/:id/requeue',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Content item ID');
if (id === null) return;
const contentItem = await getContentItemById(fastify.db, id);
if (!contentItem) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Content item ${id} not found`,
});
}
if (contentItem.status !== 'missing') {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: `Content item ${id} has status '${contentItem.status}', expected 'missing'`,
});
}
if (!fastify.queueService) {
return reply.status(503).send({
statusCode: 503,
error: 'Service Unavailable',
message: 'Queue service is not initialized',
});
}
// Reset status to 'monitored' so the download pipeline treats it as a fresh item
await updateContentItem(fastify.db, id, { status: 'monitored' });
try {
const queueItem = await fastify.queueService.enqueue(id);
request.log.info({ contentItemId: id, queueItemId: queueItem.id }, '[system] Missing content item requeued');
return reply.status(201).send({ success: true, data: queueItem });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('already in the queue')) {
return reply.status(409).send({
statusCode: 409,
error: 'Conflict',
message,
});
}
throw err;
}
}
);
} }

View file

@ -5,6 +5,8 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '../db/schema/index'; import type * as schema from '../db/schema/index';
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp'; import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
import { updateContentItem } from '../db/repositories/content-repository'; import { updateContentItem } from '../db/repositories/content-repository';
import { getAppSetting, APP_NFO_ENABLED } from '../db/repositories/system-config-repository';
import { generateNfo, writeNfoFile } from './nfo-generator';
import { parseProgressLine } from './progress-parser'; import { parseProgressLine } from './progress-parser';
import type { DownloadEventBus } from './event-bus'; import type { DownloadEventBus } from './event-bus';
import type { RateLimiter } from './rate-limiter'; import type { RateLimiter } from './rate-limiter';
@ -49,14 +51,20 @@ export class DownloadService {
* *
* Status transitions: monitored downloading downloaded | failed * Status transitions: monitored downloading downloaded | failed
* *
* For ad-hoc downloads (no channel), pass channel as null and provide
* adhocOverrides with at least platform. channelName defaults to 'Ad-hoc'.
*
* @throws YtDlpError on download failure (after updating status to 'failed') * @throws YtDlpError on download failure (after updating status to 'failed')
*/ */
async downloadItem( async downloadItem(
contentItem: ContentItem, contentItem: ContentItem,
channel: Channel, channel: Channel | null,
formatProfile?: FormatProfile formatProfile?: FormatProfile,
adhocOverrides?: { platform?: Platform; channelName?: string }
): Promise<ContentItem> { ): Promise<ContentItem> {
const logPrefix = `[download] item=${contentItem.id} channel="${channel.name}"`; const platform = channel?.platform ?? adhocOverrides?.platform ?? 'generic';
const channelName = channel?.name ?? adhocOverrides?.channelName ?? 'Ad-hoc';
const logPrefix = `[download] item=${contentItem.id} channel="${channelName}"`;
// Mark as downloading // Mark as downloading
console.log(`${logPrefix} status=downloading`); console.log(`${logPrefix} status=downloading`);
@ -64,14 +72,20 @@ export class DownloadService {
try { try {
// Acquire rate limiter for platform // Acquire rate limiter for platform
await this.rateLimiter.acquire(channel.platform as Platform); await this.rateLimiter.acquire(platform as Platform);
// Build yt-dlp args // Build yt-dlp args
const template = formatProfile?.outputTemplate ?? undefined;
const outputTemplate = this.fileOrganizer.buildOutputPath( const outputTemplate = this.fileOrganizer.buildOutputPath(
channel.platform, platform,
channel.name, channelName,
contentItem.title, contentItem.title,
this.guessExtension(contentItem.contentType, formatProfile) this.guessExtension(contentItem.contentType, formatProfile),
template,
{
contentType: contentItem.contentType,
id: contentItem.platformContentId,
}
); );
const args = this.buildYtDlpArgs( const args = this.buildYtDlpArgs(
contentItem, contentItem,
@ -123,7 +137,10 @@ export class DownloadService {
downloadedAt: new Date().toISOString(), downloadedAt: new Date().toISOString(),
}); });
this.rateLimiter.reportSuccess(channel.platform as Platform); this.rateLimiter.reportSuccess(platform as Platform);
// Generate NFO sidecar if enabled
await this.maybeWriteNfo(contentItem, channel, finalPath, logPrefix);
// Emit download:complete event // Emit download:complete event
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id }); this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
@ -135,7 +152,7 @@ export class DownloadService {
return updated!; return updated!;
} catch (err: unknown) { } catch (err: unknown) {
// Report error to rate limiter // Report error to rate limiter
this.rateLimiter.reportError(channel.platform as Platform); this.rateLimiter.reportError(platform as Platform);
// Classify the error for better retry decisions // Classify the error for better retry decisions
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
@ -255,7 +272,7 @@ export class DownloadService {
*/ */
private buildYtDlpArgs( private buildYtDlpArgs(
contentItem: ContentItem, contentItem: ContentItem,
channel: Channel, channel: Channel | null,
formatProfile: FormatProfile | undefined, formatProfile: FormatProfile | undefined,
outputTemplate: string outputTemplate: string
): string[] { ): string[] {
@ -293,9 +310,10 @@ export class DownloadService {
args.push('--no-playlist'); args.push('--no-playlist');
args.push('--print', 'after_move:filepath'); args.push('--print', 'after_move:filepath');
// Cookie support // Cookie support — use channel platform if available, fallback to contentItem URL inference
const cookiePlatform = channel?.platform ?? this.inferPlatformFromUrl(contentItem.url);
const cookiePath = this.cookieManager.getCookieFilePath( const cookiePath = this.cookieManager.getCookieFilePath(
channel.platform as Platform cookiePlatform as Platform
); );
if (cookiePath) { if (cookiePath) {
args.push('--cookies', cookiePath); args.push('--cookies', cookiePath);
@ -434,6 +452,39 @@ export class DownloadService {
} }
return contentType === 'audio' ? 'mp3' : 'mp4'; return contentType === 'audio' ? 'mp3' : 'mp4';
} }
/**
* Infer a platform string from a URL for cookie lookup.
* Used for ad-hoc downloads where no channel is available.
*/
private inferPlatformFromUrl(url: string): string {
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
if (url.includes('soundcloud.com')) return 'soundcloud';
return 'generic';
}
/**
* Write an NFO sidecar file alongside the downloaded media if the feature is enabled.
* NFO generation is best-effort failure is logged but never fails the download.
*/
private async maybeWriteNfo(
contentItem: ContentItem,
channel: Channel | null,
mediaFilePath: string,
logPrefix: string
): Promise<void> {
try {
const nfoEnabled = await getAppSetting(this.db, APP_NFO_ENABLED);
if (nfoEnabled !== 'true') return;
const nfoXml = generateNfo(contentItem, channel);
const nfoPath = await writeNfoFile(nfoXml, mediaFilePath);
console.log(`${logPrefix} nfo written path="${nfoPath}"`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`${logPrefix} nfo generation failed (non-fatal): ${msg}`);
}
}
} }
// ── Helpers ── // ── Helpers ──

View file

@ -20,32 +20,156 @@ const MAX_FILENAME_LENGTH = 200;
/** Maximum attempts to find a unique filename. */ /** Maximum attempts to find a unique filename. */
const MAX_UNIQUE_ATTEMPTS = 100; const MAX_UNIQUE_ATTEMPTS = 100;
/** Default output path template — matches the legacy hardcoded layout. */
export const DEFAULT_OUTPUT_TEMPLATE = '{platform}/{channel}/{title}.{ext}';
/** All supported template variable names. */
export const TEMPLATE_VARIABLES = [
'platform',
'channel',
'title',
'date',
'year',
'month',
'contentType',
'id',
'ext',
] as const;
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
/** Variables that callers can supply when resolving a template. */
export type TemplateVars = Partial<Record<TemplateVariable, string>>;
/** Result of template validation. */
export interface TemplateValidationResult {
valid: boolean;
errors: string[];
}
// ── Regex helpers ──
/** Matches a `{variableName}` placeholder. */
const TEMPLATE_VAR_RE = /\{([a-zA-Z]+)\}/g;
/** Characters illegal in path segments (beyond what sanitizeFilename strips). */
const ILLEGAL_PATH_CHARS = /[<>"|?*\x00-\x1f]/;
// ── FileOrganizer ── // ── FileOrganizer ──
/** /**
* Builds structured output paths from content metadata and sanitizes * Builds structured output paths from content metadata and sanitizes
* filenames for cross-platform safety. * filenames for cross-platform safety. Supports configurable path
* * templates with variables like {platform}, {channel}, {title}, {ext}.
* Path template: `{mediaPath}/{platform}/{channelName}/{title}.{ext}`
*/ */
export class FileOrganizer { export class FileOrganizer {
constructor(private readonly mediaPath: string) {} constructor(private readonly mediaPath: string) {}
/** /**
* Build the full output path for a downloaded file. * Build the full output path for a downloaded file.
* Sanitizes channelName and title for filesystem safety. *
* When `template` is provided, resolves it against the supplied metadata.
* Otherwise falls back to the default `{platform}/{channel}/{title}.{ext}`.
*
* Backward-compatible: the 4-arg positional call still works identically.
*/ */
buildOutputPath( buildOutputPath(
platform: string, platform: string,
channelName: string, channelName: string,
title: string, title: string,
ext: string ext: string,
template?: string,
extra?: { contentType?: string; id?: string }
): string { ): string {
const safeName = this.sanitizeFilename(channelName);
const safeTitle = this.sanitizeFilename(title);
const safeExt = ext.startsWith('.') ? ext.slice(1) : ext; const safeExt = ext.startsWith('.') ? ext.slice(1) : ext;
const now = new Date();
return path.join(this.mediaPath, platform, safeName, `${safeTitle}.${safeExt}`); const vars: TemplateVars = {
platform,
channel: channelName,
title,
ext: safeExt,
date: now.toISOString().slice(0, 10), // YYYY-MM-DD
year: String(now.getFullYear()),
month: String(now.getMonth() + 1).padStart(2, '0'),
...(extra?.contentType != null ? { contentType: extra.contentType } : {}),
...(extra?.id != null ? { id: extra.id } : {}),
};
const resolved = this.resolveTemplate(template ?? DEFAULT_OUTPUT_TEMPLATE, vars);
return path.join(this.mediaPath, resolved);
}
/**
* Resolve a template string by replacing `{variable}` placeholders with
* sanitized values from `vars`. Path separators (`/`) in the template are
* preserved each segment between separators is sanitized independently.
*
* Unknown variables are left as-is (e.g. `{unknown}` stays `{unknown}`).
* Missing known variables resolve to empty string.
*/
resolveTemplate(template: string, vars: TemplateVars): string {
// Split on forward slash to handle each path segment
const segments = template.split('/');
const resolvedSegments = segments.map((segment) => {
// Replace all {var} tokens in this segment
const resolved = segment.replace(TEMPLATE_VAR_RE, (_match, varName: string) => {
if (!TEMPLATE_VARIABLES.includes(varName as TemplateVariable)) {
return `{${varName}}`; // Unknown variable — leave untouched
}
const value = vars[varName as TemplateVariable] ?? '';
// Don't sanitize ext — it's a bare token used after the dot
if (varName === 'ext') return value;
return this.sanitizeFilename(value);
});
return resolved;
});
return resolvedSegments.join('/');
}
/**
* Validate a template string for correctness.
*
* Rules:
* - Must contain `{ext}` (required for file extension).
* - Must not contain illegal filesystem characters outside of `{var}` placeholders.
* - All `{var}` names must be recognized template variables.
* - Must not be empty.
*/
validateTemplate(template: string): TemplateValidationResult {
const errors: string[] = [];
if (!template || template.trim().length === 0) {
return { valid: false, errors: ['Template must not be empty'] };
}
// Check for required {ext}
if (!template.includes('{ext}')) {
errors.push('Template must contain {ext} for the file extension');
}
// Check all variable references are recognized
const varMatches = [...template.matchAll(TEMPLATE_VAR_RE)];
for (const match of varMatches) {
const varName = match[1];
if (!TEMPLATE_VARIABLES.includes(varName as TemplateVariable)) {
errors.push(`Unknown template variable: {${varName}}`);
}
}
// Check for illegal characters outside variable placeholders
// Strip all {var} placeholders, then check what remains
const stripped = template.replace(TEMPLATE_VAR_RE, '');
if (ILLEGAL_PATH_CHARS.test(stripped)) {
errors.push('Template contains illegal filesystem characters');
}
return { valid: errors.length === 0, errors };
} }
/** /**

View 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, '\\$&');
}

View 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)}`,
};
}
}
}

View 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;
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 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;
}

View file

@ -34,18 +34,21 @@ export interface QueueState {
completed: number; completed: number;
failed: number; failed: number;
cancelled: number; cancelled: number;
paused: number;
} }
// ── QueueService ── // ── QueueService ──
/** /**
* Orchestrates the download queue lifecycle: enqueue, process with concurrency * Orchestrates the download queue lifecycle: enqueue, process with concurrency
* control, retry on failure, cancel, and recover interrupted items on startup. * control, retry on failure, cancel, pause/resume, and recover interrupted items on startup.
* *
* Status transitions: * Status transitions:
* pending downloading completed | failed * pending downloading completed | failed
* failed pending (retry) or failed (max attempts exhausted) * failed pending (retry) or failed (max attempts exhausted)
* pending | failed cancelled * pending | failed cancelled
* pending | downloading paused
* paused pending (resume)
* *
* Concurrency is managed via an in-memory counter Node's single-threaded * Concurrency is managed via an in-memory counter Node's single-threaded
* event loop ensures processNext() is not re-entrant within a single tick. * event loop ensures processNext() is not re-entrant within a single tick.
@ -56,6 +59,8 @@ export class QueueService {
private concurrency: number; private concurrency: number;
private readonly onDownloadComplete?: (event: NotificationEvent) => void; private readonly onDownloadComplete?: (event: NotificationEvent) => void;
private readonly onDownloadFailed?: (event: NotificationEvent) => void; private readonly onDownloadFailed?: (event: NotificationEvent) => void;
/** Maps queueItemId → AbortController for in-flight downloads (used by pause to cancel yt-dlp). */
private readonly activeAbortControllers = new Map<number, AbortController>();
constructor( constructor(
private readonly db: Db, private readonly db: Db,
@ -231,6 +236,76 @@ export class QueueService {
return updated!; return updated!;
} }
/**
* Pause a queue item. Pending items are set to 'paused' immediately.
* Downloading items have their yt-dlp process killed and are set to 'paused'.
*
* @throws Error if item not found or not in a pausable status.
*/
async pauseItem(queueItemId: number): Promise<QueueItem> {
const item = await getQueueItemById(this.db, queueItemId);
if (!item) {
throw new Error(`Queue item ${queueItemId} not found`);
}
const pausable: QueueStatus[] = ['pending', 'downloading'];
if (!pausable.includes(item.status)) {
throw new Error(
`Cannot pause queue item ${queueItemId} — status is '${item.status}', must be 'pending' or 'downloading'`
);
}
// If downloading, abort the yt-dlp process
if (item.status === 'downloading') {
const controller = this.activeAbortControllers.get(queueItemId);
if (controller) {
controller.abort('paused');
}
}
const updated = await updateQueueItemStatus(this.db, queueItemId, 'paused');
console.log(
`[queue] pause queueId=${queueItemId} contentId=${item.contentItemId} previousStatus=${item.status} status=paused`
);
return updated!;
}
/**
* Resume a paused queue item. Sets it back to 'pending' and triggers processing.
*
* @throws Error if item not found or not paused.
*/
async resumeItem(queueItemId: number): Promise<QueueItem> {
const item = await getQueueItemById(this.db, queueItemId);
if (!item) {
throw new Error(`Queue item ${queueItemId} not found`);
}
if (item.status !== 'paused') {
throw new Error(
`Cannot resume queue item ${queueItemId} — status is '${item.status}', expected 'paused'`
);
}
const updated = await updateQueueItemStatus(this.db, queueItemId, 'pending', {
error: null,
startedAt: null,
});
// Reset content status to queued
await updateContentItem(this.db, item.contentItemId, { status: 'queued' });
console.log(
`[queue] resume queueId=${queueItemId} contentId=${item.contentItemId} status=pending`
);
this.processNext();
return updated!;
}
/** /**
* Recover items that were stuck in 'downloading' status after a crash/restart. * Recover items that were stuck in 'downloading' status after a crash/restart.
* Resets them to 'pending' so they'll be picked up again. * Resets them to 'pending' so they'll be picked up again.
@ -281,6 +356,15 @@ export class QueueService {
this.processNext(); this.processNext();
} }
/**
* Infer platform from a URL for ad-hoc downloads.
*/
private inferPlatformFromUrl(url: string): string {
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
if (url.includes('soundcloud.com')) return 'soundcloud';
return 'generic';
}
// ── Internal ── // ── Internal ──
/** /**
@ -289,6 +373,8 @@ export class QueueService {
*/ */
private async processItem(queueItem: QueueItem): Promise<void> { private async processItem(queueItem: QueueItem): Promise<void> {
const logPrefix = `[queue] process queueId=${queueItem.id} contentId=${queueItem.contentItemId}`; const logPrefix = `[queue] process queueId=${queueItem.id} contentId=${queueItem.contentItemId}`;
const abortController = new AbortController();
this.activeAbortControllers.set(queueItem.id, abortController);
try { try {
// Transition to downloading // Transition to downloading
@ -304,22 +390,33 @@ export class QueueService {
throw new Error(`Content item ${queueItem.contentItemId} not found`); throw new Error(`Content item ${queueItem.contentItemId} not found`);
} }
const channel = await getChannelById(this.db, contentItem.channelId); const channel = contentItem.channelId
if (!channel) { ? await getChannelById(this.db, contentItem.channelId)
: null;
if (contentItem.channelId && !channel) {
throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`); throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`);
} }
// Resolve format profile: channel-specific > default > undefined // Resolve format profile: channel-specific > default > undefined
let formatProfile = undefined; let formatProfile = undefined;
if (channel.formatProfileId) { if (channel?.formatProfileId) {
formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined; formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined;
} }
if (!formatProfile) { if (!formatProfile) {
formatProfile = await getDefaultFormatProfile(this.db) ?? undefined; formatProfile = await getDefaultFormatProfile(this.db) ?? undefined;
} }
// Execute download // Execute download — ad-hoc items (no channel) pass null with platform/channelName overrides
if (channel) {
await this.downloadService.downloadItem(contentItem, channel, formatProfile); await this.downloadService.downloadItem(contentItem, channel, formatProfile);
} else {
// Ad-hoc download: infer platform from URL, use stored title metadata
const platform = this.inferPlatformFromUrl(contentItem.url);
await this.downloadService.downloadItem(contentItem, null, formatProfile, {
platform: platform as import('../types/index').Platform,
channelName: 'Ad-hoc',
});
}
// Success — mark completed // Success — mark completed
await updateQueueItemStatus(this.db, queueItem.id, 'completed', { await updateQueueItemStatus(this.db, queueItem.id, 'completed', {
@ -329,7 +426,7 @@ export class QueueService {
// Record downloaded history event // Record downloaded history event
await createHistoryEvent(this.db, { await createHistoryEvent(this.db, {
contentItemId: queueItem.contentItemId, contentItemId: queueItem.contentItemId,
channelId: channel.id, channelId: channel?.id ?? null,
eventType: 'downloaded', eventType: 'downloaded',
status: 'completed', status: 'completed',
details: { details: {
@ -346,8 +443,8 @@ export class QueueService {
try { try {
this.onDownloadComplete({ this.onDownloadComplete({
contentTitle: contentItem.title, contentTitle: contentItem.title,
channelName: channel.name, channelName: channel?.name ?? 'Ad-hoc',
platform: channel.platform, platform: channel?.platform ?? 'generic',
url: contentItem.url, url: contentItem.url,
filePath: contentItem.filePath ?? undefined, filePath: contentItem.filePath ?? undefined,
}); });
@ -358,6 +455,11 @@ export class QueueService {
} }
} }
} catch (err: unknown) { } catch (err: unknown) {
// 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 errorMsg = err instanceof Error ? err.message : String(err);
const newAttempts = queueItem.attempts + 1; const newAttempts = queueItem.attempts + 1;
const exhausted = newAttempts >= queueItem.maxAttempts; const exhausted = newAttempts >= queueItem.maxAttempts;
@ -422,7 +524,9 @@ export class QueueService {
); );
} }
} }
}
} finally { } finally {
this.activeAbortControllers.delete(queueItem.id);
this.activeCount--; this.activeCount--;
this.processNext(); this.processNext();
} }

View file

@ -6,6 +6,7 @@ import type { PlatformRegistry, PlatformSource, FetchRecentContentOptions } from
import type { RateLimiter } from './rate-limiter'; import type { RateLimiter } from './rate-limiter';
import { YtDlpError } from '../sources/yt-dlp'; import { YtDlpError } from '../sources/yt-dlp';
import type { EventBus } from './event-bus'; import type { EventBus } from './event-bus';
import { matchesKeywordFilter } from './keyword-filter';
import { import {
getEnabledChannels, getEnabledChannels,
updateChannel, updateChannel,
@ -236,9 +237,19 @@ export class SchedulerService {
(item) => !existingIds.has(item.platformContentId) (item) => !existingIds.has(item.platformContentId)
); );
// 6b. Apply keyword filter — exclude/include patterns from channel settings
const filteredItems = newItems.filter((item) =>
matchesKeywordFilter(item.title, channel.includeKeywords, channel.excludeKeywords)
);
if (filteredItems.length < newItems.length) {
console.log(
`[scheduler] Keyword filter: ${newItems.length - filteredItems.length} of ${newItems.length} new items filtered out for channel ${channel.id}`
);
}
// 7. Insert new items (check abort between each) // 7. Insert new items (check abort between each)
let insertedCount = 0; let insertedCount = 0;
for (const item of newItems) { for (const item of filteredItems) {
// Check if scan was cancelled // Check if scan was cancelled
if (effectiveSignal.aborted) { if (effectiveSignal.aborted) {
console.log( console.log(
@ -310,7 +321,7 @@ export class SchedulerService {
// This runs after the scan result is returned — enrichment updates DB records // This runs after the scan result is returned — enrichment updates DB records
// and triggers a final cache invalidation when done. // and triggers a final cache invalidation when done.
if (insertedCount > 0 && !effectiveSignal.aborted) { if (insertedCount > 0 && !effectiveSignal.aborted) {
this.enrichNewItems(channel, newItems, existingIds, rateLimitDelay, source, effectiveSignal) this.enrichNewItems(channel, filteredItems, existingIds, rateLimitDelay, source, effectiveSignal)
.catch((err) => { .catch((err) => {
console.error( console.error(
`[scheduler] Background enrichment failed for channel ${channel.id}:`, `[scheduler] Background enrichment failed for channel ${channel.id}:`,

View file

@ -70,10 +70,21 @@ export interface ContentCounts {
downloaded: number; downloaded: number;
} }
/** Per-content-type counts for a channel. */
export interface ContentTypeCounts {
video: number;
audio: number;
livestream: number;
}
/** App-wide settings (check interval, concurrent downloads). */ /** App-wide settings (check interval, concurrent downloads). */
export interface AppSettingsResponse { export interface AppSettingsResponse {
checkInterval: number; checkInterval: number;
concurrentDownloads: number; concurrentDownloads: number;
outputTemplate: string;
nfoEnabled: boolean;
timezone: string;
theme: 'dark' | 'light';
} }
/** Channel with aggregated content counts — returned by GET /api/v1/channel. */ /** Channel with aggregated content counts — returned by GET /api/v1/channel. */

View file

@ -21,6 +21,7 @@ export const ContentStatus = {
Downloaded: 'downloaded', Downloaded: 'downloaded',
Failed: 'failed', Failed: 'failed',
Ignored: 'ignored', Ignored: 'ignored',
Missing: 'missing',
} as const; } as const;
export type ContentStatus = (typeof ContentStatus)[keyof typeof ContentStatus]; export type ContentStatus = (typeof ContentStatus)[keyof typeof ContentStatus];
@ -30,6 +31,7 @@ export const QueueStatus = {
Completed: 'completed', Completed: 'completed',
Failed: 'failed', Failed: 'failed',
Cancelled: 'cancelled', Cancelled: 'cancelled',
Paused: 'paused',
} as const; } as const;
export type QueueStatus = (typeof QueueStatus)[keyof typeof QueueStatus]; export type QueueStatus = (typeof QueueStatus)[keyof typeof QueueStatus];
@ -77,6 +79,9 @@ export interface Channel {
bannerUrl: string | null; bannerUrl: string | null;
description: string | null; description: string | null;
subscriberCount: number | null; subscriberCount: number | null;
contentRating: string | null;
includeKeywords: string | null;
excludeKeywords: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
lastCheckedAt: string | null; lastCheckedAt: string | null;
@ -85,7 +90,7 @@ export interface Channel {
export interface ContentItem { export interface ContentItem {
id: number; id: number;
channelId: number; channelId: number | null;
title: string; title: string;
platformContentId: string; platformContentId: string;
url: string; url: string;
@ -100,6 +105,7 @@ export interface ContentItem {
publishedAt: string | null; publishedAt: string | null;
downloadedAt: string | null; downloadedAt: string | null;
monitored: boolean; monitored: boolean;
contentRating: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@ -144,6 +150,7 @@ export interface FormatProfile {
embedChapters: boolean; embedChapters: boolean;
embedThumbnail: boolean; embedThumbnail: boolean;
sponsorBlockRemove: string | null; // comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler' sponsorBlockRemove: string | null; // comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
outputTemplate: string | null; // per-profile path template override e.g. '{platform}/{channel}/{title}.{ext}'
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@ -193,6 +200,24 @@ export interface SystemConfig {
updatedAt: string; updatedAt: string;
} }
export const MediaServerType = {
Plex: 'plex',
Jellyfin: 'jellyfin',
} as const;
export type MediaServerType = (typeof MediaServerType)[keyof typeof MediaServerType];
export interface MediaServer {
id: number;
name: string;
type: MediaServerType;
url: string;
token: string;
librarySection: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface Playlist { export interface Playlist {
id: number; id: number;
channelId: number; channelId: number;