Compare commits

..

No commits in common. "master" and "milestone/M008-performance-polish-downloads" have entirely different histories.

143 changed files with 1096 additions and 22867 deletions

View file

@ -19,16 +19,7 @@
"Bash(gh repo:*)",
"Bash(git remote:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npm list:*)",
"Bash(npm show:*)",
"Bash(gsd --version)",
"Bash(gsd version:*)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('version', 'no version field'\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")",
"Bash(gsd)",
"Read(//home/aux/.config/**)",
"Read(//home/aux/**)"
"Bash(git commit:*)"
]
}
}

View file

@ -1,66 +0,0 @@
name: Bug Report
about: Something is broken or behaving unexpectedly
labels:
- bug
body:
- type: dropdown
id: area
attributes:
label: Area
description: Which part of the system is affected?
options:
- Frontend / UI
- Backend / API
- Download pipeline (yt-dlp, queue, progress)
- Scheduler / monitoring
- Database / data integrity
- Docker / deployment
- Other
validations:
required: true
- type: input
id: location
attributes:
label: Where specifically?
description: Page URL, route, API endpoint, service name, or container — whatever narrows it down.
placeholder: "/channels/5, POST /api/downloads, scheduler service, etc."
- type: dropdown
id: reproducibility
attributes:
label: Reproducibility
options:
- Always
- Sometimes
- Saw it once
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Describe the bug and how to reproduce it. Include what you expected to happen if it's not obvious.
placeholder: |
Steps:
1. Go to ...
2. Click ...
3. See error ...
Expected: ...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / error output
description: Paste any relevant logs, error messages, or stack traces. Check browser console (F12) for frontend issues, or docker logs for backend issues.
render: shell
- type: textarea
id: context
attributes:
label: Additional context
description: What you already tried, when it started, screenshots, related issues.

View file

@ -1 +0,0 @@
blank_issues_enabled: true

View file

@ -1,54 +0,0 @@
name: Feature Request
about: Suggest a new capability or improvement
labels:
- enhancement
body:
- type: dropdown
id: area
attributes:
label: Area
description: Which part of the system does this touch?
options:
- Frontend / UI
- Backend / API
- Download pipeline (yt-dlp, queue, progress)
- Scheduler / monitoring
- New platform / source support
- Docker / deployment
- Other
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Scope
description: Rough size of what you're asking for.
options:
- Tweak — adjust existing behavior
- Feature — new capability or screen
- Large — multi-part, needs planning
validations:
required: true
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: Describe the use case or pain point. "I want to ..." or "Currently there's no way to ..."
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How should it work? Be as specific as you can — endpoint behavior, UI flow, config options, etc. If you have multiple ideas, list them.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Links, mockups, examples from other tools, related issues.

View file

@ -1,22 +0,0 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Run tests
run: npm test

View file

@ -1,18 +0,0 @@
{
"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

@ -1,49 +0,0 @@
# Phase 01: Wire Up WebSocket Download Progress End-to-End
The backend event bus, WebSocket route, progress parser, and frontend context/hook/component all exist but are not connected in the UI. This phase wires everything together so users see real-time download progress in the Queue page and Channel Detail page. By the end, downloading items will show a live progress bar with percentage, speed, and ETA — completing the WIP feature from commit 0541a5f.
## Tasks
- [x] Wrap the app in DownloadProgressProvider:
- Read `src/frontend/src/App.tsx` and `src/frontend/src/contexts/DownloadProgressContext.tsx` to understand current structure
- In `App.tsx`, import `DownloadProgressProvider` from `../contexts/DownloadProgressContext`
- Wrap the `<AuthenticatedLayout />` route (or the `<Routes>` in `App()`) with `<DownloadProgressProvider>` so all pages can access download progress
- Ensure the provider is inside the existing `QueryClientProvider` (check `main.tsx` for provider ordering)
- **Note:** Already wired in `main.tsx` (lines 25-29) from commit 0541a5f. Provider wraps entire app inside QueryClientProvider. No changes needed.
- [x] Integrate DownloadProgressBar into the Queue page for actively downloading items:
- Read `src/frontend/src/pages/Queue.tsx` and `src/frontend/src/components/DownloadProgressBar.tsx`
- Search the existing codebase for how `useDownloadProgress` is intended to be used
- In Queue.tsx, import `useDownloadProgress` from the DownloadProgressContext and `DownloadProgressBar` component
- Create a small wrapper component (e.g., `QueueItemProgress`) that calls `useDownloadProgress(contentItemId)` and renders `<DownloadProgressBar>` when progress exists, or falls back to the existing `<StatusBadge>` when no active progress
- Update the `status` column render in the Queue table to use this wrapper for items with status `downloading`
- **Note:** Already wired from prior commits. `QueueItemProgress` component (lines 36-44) uses `useDownloadProgress(item.contentItemId)` and renders `<DownloadProgressBar>` for active downloads, falling back to `<StatusBadge>`. Status column at line 94 uses this wrapper. Frontend builds clean, all 606 tests pass.
- [x] Integrate download progress into the Channel Detail page:
- Read `src/frontend/src/pages/ChannelDetail.tsx` to understand how content items are displayed
- Search for how content items render their status in this page
- For content items with status `downloading`, show the `DownloadProgressBar` alongside or instead of the static status badge
- Use the same `useDownloadProgress` hook pattern established in the Queue page
- **Note:** Already wired in commit 0541a5f. `ContentStatusCell` component (lines 38-46) uses `useDownloadProgress(item.id)` and renders `<DownloadProgressBar>` for active downloads, falling back to `<StatusBadge>`. Used in status column at line 560.
- [x] Add a WebSocket connection status indicator to the Sidebar or app header:
- Read `src/frontend/src/components/Sidebar.tsx`
- Import `useDownloadProgressConnection` from the DownloadProgressContext
- Add a small visual indicator (e.g., a colored dot) near the bottom of the sidebar that shows green when WebSocket is connected and grey/red when disconnected
- Use existing CSS variables (`--success` for connected, `--text-muted` for disconnected)
- **Done:** Added connection status indicator at bottom of sidebar with green/grey dot and "Connected"/"Disconnected" label. Collapses to just the dot when sidebar is collapsed. Uses `useDownloadProgressConnection` hook.
- [x] Verify the backend emits progress events during streaming downloads:
- Read `src/services/download.ts` to confirm the `spawnDownload` method emits `download:progress` events via the event bus
- Read `src/server/routes/websocket.ts` to confirm the WebSocket route subscribes to the event bus and broadcasts to clients
- Read `src/server/index.ts` to confirm the event bus is passed to both the WebSocket route plugin and the server builder
- If any wiring is missing between `buildServer()` and the WebSocket route registration, fix it
- Verify the `--newline` and `--progress` flags are added to yt-dlp args in `spawnDownload` (they should already be there)
- **Verified:** All wiring is correct. Single `DownloadEventBus` instance created in `src/index.ts:61` is shared between `buildServer` (→ WebSocket route) and `DownloadService`. `spawnDownload` adds `--newline`/`--progress` flags, parses progress lines, and emits all three event types. WebSocket route subscribes and broadcasts to clients. All 13 related tests pass.
- [x] Invalidate relevant queries on WebSocket events for immediate UI freshness:
- Read the `DownloadProgressContext.tsx` — it already invalidates `content` and `queue` query keys on `download:complete` and `download:failed`
- Read `src/frontend/src/api/hooks/useQueue.ts` and `src/frontend/src/api/hooks/useContent.ts` to verify they use matching query keys
- Also invalidate `activity` and `channels` query keys on complete/failed events so the Activity page and channel content counts update without manual refresh
- Add `library` query key invalidation on complete events if the library hook uses a separate query key
- **Done:** Added `activity`, `channels`, and `library` query key invalidations to both `download:complete` and `download:failed` handlers in `DownloadProgressContext.tsx`. Verified query keys match: `useActivity` uses `['activity']`, `useChannels` uses `['channels']`, `useLibraryContent` uses `['library']`. Frontend builds clean, all 40 backend tests pass.

View file

@ -1,250 +0,0 @@
# Naming Templates & Metadata Embedding — Design Spec
> Drafted 2026-04-04. Build target: next milestone.
## Overview
Configurable filename templates with token-based variables, Plex-compatible metadata embedding via ffmpeg, and batch rename tooling for existing libraries.
---
## 1. Naming Template Engine
### Token Syntax
Templates use `{token}` placeholders with optional formatters:
| Token | Description | Example Output |
|-------|-------------|---------------|
| `{title}` | Content item title | `This is the video name!` |
| `{channel}` | Channel name | `RockinWorms Homestead` |
| `{platform}` | Platform name | `youtube` |
| `{Platform}` | Platform name (capitalized) | `YouTube` |
| `{date}` | Publish date (YYYY-MM-DD) | `2026-04-03` |
| `{date:MM-DD-YYYY}` | Publish date (custom format) | `04-03-2026` |
| `{date:YYYY}` | Year only | `2026` |
| `{date:YYYYMMDD}` | Compact date | `20260403` |
| `{quality}` | Resolution shorthand | `1080p` |
| `{codec}` | Video codec | `h264` |
| `{id}` | Platform content ID | `dQw4w9WgXcQ` |
| `{ext}` | File extension | `mp4` |
| `{duration}` | Duration (HH-MM-SS) | `00-12-34` |
| `{index}` | Item index (zero-padded) | `001` |
| `{type}` | Content type | `video` |
### Conditional Sections
Wrap optional sections in `[]`:
- `{channel} - {title} [{quality}]``RockinWorms - Title [1080p]` (quality present)
- `{channel} - {title} [{quality}]``RockinWorms - Title` (quality null — brackets removed)
### Path Template (directory structure)
Separate from filename template. Controls folder hierarchy:
- Default: `{platform}/{channel}`
- Custom: `{platform}/{channel}/{date:YYYY}` (year subfolders)
- Custom: `{Platform} - {channel}` (flat by channel)
### Live Preview
Settings UI shows a real-time preview as the user types:
```
Template: {channel} - {title} ({date:MM-DD-YYYY}) [{quality}]
Preview: RockinWorms Homestead - Sifting Worm Castings (04-03-2026) [1080p].mp4
```
Uses a sample content item from the platform's existing library for realistic preview.
---
## 2. Schema Changes
### `naming_profiles` table (new)
| Column | Type | Notes |
|--------|------|-------|
| id | INTEGER PK | |
| name | TEXT NOT NULL | e.g. "YouTube Default", "Music Archive" |
| pathTemplate | TEXT NOT NULL | Directory structure: `{platform}/{channel}` |
| filenameTemplate | TEXT NOT NULL | Filename: `{channel} - {title} ({date:MM-DD-YYYY}) [{quality}]` |
| createdAt | TEXT | |
| updatedAt | TEXT | |
### `platform_settings` additions
| Column | Type | Notes |
|--------|------|-------|
| namingProfileId | INTEGER FK → naming_profiles | Per-platform naming override |
### `channels` additions
| Column | Type | Notes |
|--------|------|-------|
| namingProfileId | INTEGER FK → naming_profiles | Per-channel override (trumps platform) |
### Resolution order
1. Channel-level `namingProfileId` (if set)
2. Platform-level `namingProfileId` (if set)
3. System default (hardcoded: `{platform}/{channel}/{title}`)
---
## 3. Plex Metadata Embedding
### Strategy
Post-download step using ffmpeg (already available in the container). Zero re-encoding — metadata written via `-c copy -metadata key=value`.
### Tags Written (MP4 container — Plex Local Media Assets compatible)
| ffmpeg key | Source | Plex field |
|-----------|--------|------------|
| `title` | Content item title | Title |
| `artist` | Channel name | Studio/Artist |
| `date` | publishedAt (YYYY-MM-DD) | Originally Available |
| `year` | publishedAt year | Year |
| `comment` | Channel description or content URL | Summary |
| `description` | Content URL | Summary (fallback) |
| `genre` | Platform name | Genre |
| `show` | Channel name | Show (for TV-style libraries) |
| `episode_sort` | Item index in channel | Sort Order |
| `synopsis` | Content URL | (for some Plex agents) |
### Thumbnail embedding
Already implemented via `--embed-thumbnail` in FormatProfile. Plex reads embedded cover art from MP4.
### MKV container
MKV uses Matroska tags — different key names but ffmpeg handles the translation. Same `-metadata` flags work.
### Implementation
New `MetadataEmbedder` service:
```typescript
class MetadataEmbedder {
async embedMetadata(filePath: string, metadata: MediaMetadata): Promise<void>
}
interface MediaMetadata {
title: string;
artist: string; // channel name
date: string; // YYYY-MM-DD
year: string; // YYYY
comment: string; // URL or description
genre: string; // platform
show: string; // channel name (for TV library mode)
}
```
Called after download completes, before marking status as `downloaded`. Uses `ffmpeg -i input -c copy -metadata ... output && mv output input` (atomic replace via temp file).
### Toggle
Add `embedMetadata: boolean` to FormatProfile (default: false). When enabled, the post-download step runs.
---
## 4. Batch Rename Tool
### Purpose
Apply a naming profile retroactively to already-downloaded files. Handles:
1. Rename files on disk
2. Update `filePath` in database
3. Update `qualityMetadata` path references
4. Handle conflicts (duplicate names)
### API
```
POST /api/v1/channel/:id/rename-preview
Body: { namingProfileId: number }
Response: { renames: [{ contentId, oldPath, newPath, conflict: boolean }] }
POST /api/v1/channel/:id/rename-apply
Body: { namingProfileId: number }
Response: { renamed: number, skipped: number, errors: number }
```
### UI
Channel detail page: "Rename Files" button → modal showing preview of all renames → confirm to apply.
### Safety
- Preview-first: always show what WILL change before applying
- Conflict detection: flag files that would collide
- Dry-run by default
- Rollback: store old paths in download_history for manual recovery
---
## 5. Frontend Components
### NamingProfileEditor
- Template input with token autocomplete (type `{` to see suggestions)
- Live preview using sample data from the platform
- Path template + filename template as separate fields
- Save as named profile
### Platform Settings Integration
- "Naming Profile" dropdown in PlatformSettingsForm
- Per-channel override in ChannelDetail settings section
### Batch Rename Modal
- Channel-scoped: triggered from ChannelDetail
- Shows table: Old Name → New Name with conflict highlighting
- Apply button with confirmation
- Progress indicator for large channels
---
## 6. Implementation Order
### Slice 1: Template Engine + Schema
- `NamingTemplate` class with `resolve(tokens)` method
- Token parsing, date formatting, conditional sections
- Migration: `naming_profiles` table + FK columns
- CRUD routes + repository
### Slice 2: Download Integration
- `FileOrganizer.buildOutputPath` uses naming profile instead of hardcoded pattern
- Resolution chain: channel → platform → system default
- Settings UI: NamingProfileEditor component + platform/channel dropdowns
### Slice 3: Metadata Embedding
- `MetadataEmbedder` service (ffmpeg -c copy -metadata)
- Post-download hook in DownloadService
- `embedMetadata` toggle on FormatProfile
- Verify Plex reads the tags (manual UAT)
### Slice 4: Batch Rename
- Preview API + Apply API
- Rename modal in ChannelDetail
- Conflict detection + rollback tracking
---
## 7. Plex Library Type Recommendations
Document in the app's help/docs:
| Content Type | Plex Library Type | Naming Pattern |
|-------------|------------------|----------------|
| YouTube channels (episodic) | TV Shows | `{channel} - s01e{index} - {title}` |
| YouTube channels (non-episodic) | Other Videos / Personal Media | `{channel} - {title} ({date:YYYY-MM-DD})` |
| Music (SoundCloud, Bandcamp) | Music | `{channel}/{title}` (+ audio tags) |
| Generic (Vimeo, misc) | Other Videos | `{title} ({date:YYYY})` |
---
## Open Questions
1. Should naming profiles be global or per-user? (Currently single-user, so global is fine)
2. Should we support yt-dlp's own output template syntax (`%(title)s`) as an alternative to our `{title}` tokens? Pros: power users already know it. Cons: two template syntaxes to maintain.
3. TV-style episode numbering: auto-increment based on publish date order, or explicit mapping?

View file

@ -1 +0,0 @@
ALTER TABLE `queue_items` ADD `error_category` text;

View file

@ -1,3 +0,0 @@
ALTER TABLE `channels` ADD `banner_url` text;--> statement-breakpoint
ALTER TABLE `channels` ADD `description` text;--> statement-breakpoint
ALTER TABLE `channels` ADD `subscriber_count` integer;

View file

@ -1,4 +0,0 @@
-- Add YouTube enhancement columns to format_profiles
ALTER TABLE format_profiles ADD COLUMN embed_chapters INTEGER NOT NULL DEFAULT 0;--> statement-breakpoint
ALTER TABLE format_profiles ADD COLUMN embed_thumbnail INTEGER NOT NULL DEFAULT 0;--> statement-breakpoint
ALTER TABLE format_profiles ADD COLUMN sponsor_block_remove TEXT;

View file

@ -1,43 +0,0 @@
-- 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

@ -1,27 +0,0 @@
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;

View file

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

View file

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

View file

@ -1,11 +0,0 @@
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

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

View file

@ -1,2 +0,0 @@
ALTER TABLE `platform_settings` ADD `nfo_enabled` integer NOT NULL DEFAULT false;--> statement-breakpoint
ALTER TABLE `platform_settings` ADD `default_view` text NOT NULL DEFAULT 'list';

View file

@ -1,6 +0,0 @@
-- Fix content items where monitored=false but status is still 'monitored'
-- These were incorrectly set to status='monitored' during scan/import
-- when the channel's monitoring mode didn't include them.
UPDATE content_items
SET status = 'ignored', updated_at = datetime('now')
WHERE monitored = 0 AND status = 'monitored';

View file

@ -1,976 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "eaac3184-0b4a-45d4-b2a9-da09dbd4bd56",
"prevId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"tables": {
"channels": {
"name": "channels",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform_id": {
"name": "platform_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monitoring_enabled": {
"name": "monitoring_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"check_interval": {
"name": "check_interval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 360
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"format_profile_id": {
"name": "format_profile_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"last_checked_at": {
"name": "last_checked_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_check_status": {
"name": "last_check_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monitoring_mode": {
"name": "monitoring_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'all'"
}
},
"indexes": {},
"foreignKeys": {
"channels_format_profile_id_format_profiles_id_fk": {
"name": "channels_format_profile_id_format_profiles_id_fk",
"tableFrom": "channels",
"tableTo": "format_profiles",
"columnsFrom": [
"format_profile_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_items": {
"name": "content_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"channel_id": {
"name": "channel_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform_content_id": {
"name": "platform_content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quality_metadata": {
"name": "quality_metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'monitored'"
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_at": {
"name": "downloaded_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monitored": {
"name": "monitored",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"content_items_channel_id_channels_id_fk": {
"name": "content_items_channel_id_channels_id_fk",
"tableFrom": "content_items",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"format_profiles": {
"name": "format_profiles",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_resolution": {
"name": "video_resolution",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"audio_codec": {
"name": "audio_codec",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"audio_bitrate": {
"name": "audio_bitrate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"container_format": {
"name": "container_format",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_default": {
"name": "is_default",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"subtitle_languages": {
"name": "subtitle_languages",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"embed_subtitles": {
"name": "embed_subtitles",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"content_item_id": {
"name": "content_item_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"channel_id": {
"name": "channel_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"download_history_content_item_id_content_items_id_fk": {
"name": "download_history_content_item_id_content_items_id_fk",
"tableFrom": "download_history",
"tableTo": "content_items",
"columnsFrom": [
"content_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"download_history_channel_id_channels_id_fk": {
"name": "download_history_channel_id_channels_id_fk",
"tableFrom": "download_history",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_playlist": {
"name": "content_playlist",
"columns": {
"content_item_id": {
"name": "content_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"playlist_id": {
"name": "playlist_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"content_playlist_content_item_id_content_items_id_fk": {
"name": "content_playlist_content_item_id_content_items_id_fk",
"tableFrom": "content_playlist",
"tableTo": "content_items",
"columnsFrom": [
"content_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"content_playlist_playlist_id_playlists_id_fk": {
"name": "content_playlist_playlist_id_playlists_id_fk",
"tableFrom": "content_playlist",
"tableTo": "playlists",
"columnsFrom": [
"playlist_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"content_playlist_content_item_id_playlist_id_pk": {
"columns": [
"content_item_id",
"playlist_id"
],
"name": "content_playlist_content_item_id_playlist_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_settings": {
"name": "notification_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"on_grab": {
"name": "on_grab",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"on_download": {
"name": "on_download",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"on_failure": {
"name": "on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"platform_settings": {
"name": "platform_settings",
"columns": {
"platform": {
"name": "platform",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"default_format_profile_id": {
"name": "default_format_profile_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"check_interval": {
"name": "check_interval",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 360
},
"concurrency_limit": {
"name": "concurrency_limit",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 2
},
"subtitle_languages": {
"name": "subtitle_languages",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grab_all_enabled": {
"name": "grab_all_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"grab_all_order": {
"name": "grab_all_order",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'newest'"
},
"scan_limit": {
"name": "scan_limit",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 100
},
"rate_limit_delay": {
"name": "rate_limit_delay",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1000
},
"default_monitoring_mode": {
"name": "default_monitoring_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'all'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"platform_settings_default_format_profile_id_format_profiles_id_fk": {
"name": "platform_settings_default_format_profile_id_format_profiles_id_fk",
"tableFrom": "platform_settings",
"tableTo": "format_profiles",
"columnsFrom": [
"default_format_profile_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"playlists": {
"name": "playlists",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"channel_id": {
"name": "channel_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform_playlist_id": {
"name": "platform_playlist_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"playlists_channel_id_channels_id_fk": {
"name": "playlists_channel_id_channels_id_fk",
"tableFrom": "playlists",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"queue_items": {
"name": "queue_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"content_item_id": {
"name": "content_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"max_attempts": {
"name": "max_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 3
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_category": {
"name": "error_category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"queue_items_content_item_id_content_items_id_fk": {
"name": "queue_items_content_item_id_content_items_id_fk",
"tableFrom": "queue_items",
"tableTo": "content_items",
"columnsFrom": [
"content_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"system_config": {
"name": "system_config",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -1,997 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2032ed4f-0e7d-4a3c-9e00-96716084f3f6",
"prevId": "eaac3184-0b4a-45d4-b2a9-da09dbd4bd56",
"tables": {
"channels": {
"name": "channels",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform_id": {
"name": "platform_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"monitoring_enabled": {
"name": "monitoring_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"check_interval": {
"name": "check_interval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 360
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"format_profile_id": {
"name": "format_profile_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"last_checked_at": {
"name": "last_checked_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_check_status": {
"name": "last_check_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monitoring_mode": {
"name": "monitoring_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'all'"
},
"banner_url": {
"name": "banner_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscriber_count": {
"name": "subscriber_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"channels_format_profile_id_format_profiles_id_fk": {
"name": "channels_format_profile_id_format_profiles_id_fk",
"tableFrom": "channels",
"tableTo": "format_profiles",
"columnsFrom": [
"format_profile_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_items": {
"name": "content_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"channel_id": {
"name": "channel_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform_content_id": {
"name": "platform_content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quality_metadata": {
"name": "quality_metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'monitored'"
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_at": {
"name": "downloaded_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"monitored": {
"name": "monitored",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"content_items_channel_id_channels_id_fk": {
"name": "content_items_channel_id_channels_id_fk",
"tableFrom": "content_items",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"format_profiles": {
"name": "format_profiles",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_resolution": {
"name": "video_resolution",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"audio_codec": {
"name": "audio_codec",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"audio_bitrate": {
"name": "audio_bitrate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"container_format": {
"name": "container_format",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_default": {
"name": "is_default",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"subtitle_languages": {
"name": "subtitle_languages",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"embed_subtitles": {
"name": "embed_subtitles",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"content_item_id": {
"name": "content_item_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"channel_id": {
"name": "channel_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"download_history_content_item_id_content_items_id_fk": {
"name": "download_history_content_item_id_content_items_id_fk",
"tableFrom": "download_history",
"tableTo": "content_items",
"columnsFrom": [
"content_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"download_history_channel_id_channels_id_fk": {
"name": "download_history_channel_id_channels_id_fk",
"tableFrom": "download_history",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_playlist": {
"name": "content_playlist",
"columns": {
"content_item_id": {
"name": "content_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"playlist_id": {
"name": "playlist_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"content_playlist_content_item_id_content_items_id_fk": {
"name": "content_playlist_content_item_id_content_items_id_fk",
"tableFrom": "content_playlist",
"tableTo": "content_items",
"columnsFrom": [
"content_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"content_playlist_playlist_id_playlists_id_fk": {
"name": "content_playlist_playlist_id_playlists_id_fk",
"tableFrom": "content_playlist",
"tableTo": "playlists",
"columnsFrom": [
"playlist_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"content_playlist_content_item_id_playlist_id_pk": {
"columns": [
"content_item_id",
"playlist_id"
],
"name": "content_playlist_content_item_id_playlist_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_settings": {
"name": "notification_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"on_grab": {
"name": "on_grab",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"on_download": {
"name": "on_download",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"on_failure": {
"name": "on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"platform_settings": {
"name": "platform_settings",
"columns": {
"platform": {
"name": "platform",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"default_format_profile_id": {
"name": "default_format_profile_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"check_interval": {
"name": "check_interval",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 360
},
"concurrency_limit": {
"name": "concurrency_limit",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 2
},
"subtitle_languages": {
"name": "subtitle_languages",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grab_all_enabled": {
"name": "grab_all_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"grab_all_order": {
"name": "grab_all_order",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'newest'"
},
"scan_limit": {
"name": "scan_limit",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 100
},
"rate_limit_delay": {
"name": "rate_limit_delay",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1000
},
"default_monitoring_mode": {
"name": "default_monitoring_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'all'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"platform_settings_default_format_profile_id_format_profiles_id_fk": {
"name": "platform_settings_default_format_profile_id_format_profiles_id_fk",
"tableFrom": "platform_settings",
"tableTo": "format_profiles",
"columnsFrom": [
"default_format_profile_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"playlists": {
"name": "playlists",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"channel_id": {
"name": "channel_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform_playlist_id": {
"name": "platform_playlist_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"playlists_channel_id_channels_id_fk": {
"name": "playlists_channel_id_channels_id_fk",
"tableFrom": "playlists",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"queue_items": {
"name": "queue_items",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"content_item_id": {
"name": "content_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"max_attempts": {
"name": "max_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 3
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_category": {
"name": "error_category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"queue_items_content_item_id_content_items_id_fk": {
"name": "queue_items_content_item_id_content_items_id_fk",
"tableFrom": "queue_items",
"tableTo": "content_items",
"columnsFrom": [
"content_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"system_config": {
"name": "system_config",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -64,83 +64,6 @@
"when": 1774839000000,
"tag": "0008_add_default_monitoring_mode",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1775192114394,
"tag": "0009_many_carlie_cooper",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1775196046744,
"tag": "0010_special_ghost_rider",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1775253600000,
"tag": "0011_add_youtube_enhancements",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1775520000000,
"tag": "0012_adhoc_nullable_channel",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1775279021003,
"tag": "0013_flat_lady_deathstrike",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775279888856,
"tag": "0014_adorable_miek",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1775280800944,
"tag": "0015_perfect_lethal_legion",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1775281783887,
"tag": "0016_right_galactus",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1775282773898,
"tag": "0017_wild_havok",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1775520100000,
"tag": "0018_platform_settings_nfo_view",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1775520200000,
"tag": "0019_fix_unmonitored_status",
"breakpoints": true
}
]
}

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { initDatabaseAsync, closeDatabase } from '../db/index';
@ -39,16 +39,6 @@ vi.mock('node:fs/promises', async (importOriginal) => {
};
});
// Mock getPlatformSettings for per-platform NFO feature flag
const getPlatformSettingsMock = vi.fn();
vi.mock('../db/repositories/platform-settings-repository', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
...actual,
getPlatformSettings: (...args: unknown[]) => getPlatformSettingsMock(...args),
};
});
// ── Test Helpers ──
let tmpDir: string;
@ -344,7 +334,7 @@ describe('DownloadService', () => {
containerFormat: 'mkv',
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
embedSubtitles: false,
createdAt: '',
updatedAt: '',
};
@ -398,7 +388,7 @@ describe('DownloadService', () => {
containerFormat: null,
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
embedSubtitles: false,
createdAt: '',
updatedAt: '',
};
@ -652,7 +642,7 @@ describe('DownloadService', () => {
containerFormat: null,
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
embedSubtitles: false,
createdAt: '',
updatedAt: '',
};
@ -696,7 +686,7 @@ describe('DownloadService', () => {
containerFormat: 'mkv',
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
embedSubtitles: false,
createdAt: '',
updatedAt: '',
};
@ -748,7 +738,7 @@ describe('DownloadService', () => {
containerFormat: null,
isDefault: false,
subtitleLanguages: null,
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null, outputTemplate: null,
embedSubtitles: false,
createdAt: '',
updatedAt: '',
};
@ -771,386 +761,4 @@ describe('DownloadService', () => {
expect(args).not.toContain('--audio-quality');
});
});
describe('downloadItem — NFO sidecar generation', () => {
function setupSuccessfulDownload(deps: ReturnType<typeof createMockDeps>) {
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
writeFileSync(outputPath, 'fake video data');
execYtDlpMock.mockResolvedValueOnce({
stdout: outputPath,
stderr: '',
exitCode: 0,
});
statMock.mockResolvedValueOnce({ size: 10_000_000 });
return outputPath;
}
it('writes .nfo sidecar when platform nfoEnabled is true', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
const outputPath = setupSuccessfulDownload(deps);
getPlatformSettingsMock.mockResolvedValueOnce({ nfoEnabled: 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 platform nfoEnabled is false', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
const outputPath = setupSuccessfulDownload(deps);
getPlatformSettingsMock.mockResolvedValueOnce({ nfoEnabled: false });
await service.downloadItem(testContentItem, testChannel);
const nfoPath = outputPath.replace(/\.mp4$/, '.nfo');
expect(existsSync(nfoPath)).toBe(false);
});
it('does not write .nfo when no platform settings exist', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
const outputPath = setupSuccessfulDownload(deps);
getPlatformSettingsMock.mockResolvedValueOnce(null); // No settings for platform
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);
getPlatformSettingsMock.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);
getPlatformSettingsMock.mockResolvedValueOnce({ nfoEnabled: true });
const result = await service.downloadItem(testContentItem, testChannel);
expect(result.status).toBe('downloaded');
expect(result.filePath).toBeDefined();
});
});
describe('downloadItem — SponsorBlock arg construction', () => {
function setupForArgs(deps: ReturnType<typeof createMockDeps>) {
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
writeFileSync(outputPath, 'data');
execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 });
statMock.mockResolvedValueOnce({ size: 1000 });
return outputPath;
}
it('produces --sponsorblock-remove with comma-separated categories', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const profile: FormatProfile = {
id: 20, name: 'SB Test', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: 'sponsor,selfpromo',
outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
const sbIdx = args.indexOf('--sponsorblock-remove');
expect(sbIdx).toBeGreaterThanOrEqual(0);
expect(args[sbIdx + 1]).toBe('sponsor,selfpromo');
});
it('does not include --sponsorblock-remove when sponsorBlockRemove is null', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const profile: FormatProfile = {
id: 21, name: 'No SB', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: null,
outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).not.toContain('--sponsorblock-remove');
});
it('does not include --sponsorblock-remove when value is empty/whitespace', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const profile: FormatProfile = {
id: 22, name: 'Empty SB', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: ' ',
outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).not.toContain('--sponsorblock-remove');
});
it('handles all SponsorBlock category types', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const allCategories = 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler';
const profile: FormatProfile = {
id: 23, name: 'All SB', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false,
embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: allCategories,
outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
const sbIdx = args.indexOf('--sponsorblock-remove');
expect(sbIdx).toBeGreaterThanOrEqual(0);
expect(args[sbIdx + 1]).toBe(allCategories);
});
});
describe('downloadItem — subtitle arg construction', () => {
function setupForArgs(deps: ReturnType<typeof createMockDeps>) {
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
writeFileSync(outputPath, 'data');
execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 });
statMock.mockResolvedValueOnce({ size: 1000 });
return outputPath;
}
it('produces --write-subs and --sub-langs when subtitleLanguages is set', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const profile: FormatProfile = {
id: 30, name: 'Sub Test', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false,
subtitleLanguages: 'en,es',
embedSubtitles: false, embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).toContain('--write-subs');
const slIdx = args.indexOf('--sub-langs');
expect(slIdx).toBeGreaterThanOrEqual(0);
expect(args[slIdx + 1]).toBe('en,es');
// embedSubtitles is false, so no --embed-subs
expect(args).not.toContain('--embed-subs');
});
it('produces --embed-subs when embedSubtitles=true AND subtitleLanguages is set', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const profile: FormatProfile = {
id: 31, name: 'Embed Sub', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false,
subtitleLanguages: 'en,es',
embedSubtitles: true,
embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).toContain('--write-subs');
expect(args).toContain('--sub-langs');
expect(args).toContain('--embed-subs');
});
it('does NOT produce --embed-subs when embedSubtitles=true but subtitleLanguages is null', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const profile: FormatProfile = {
id: 32, name: 'No Lang', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false,
subtitleLanguages: null,
embedSubtitles: true,
embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).not.toContain('--write-subs');
expect(args).not.toContain('--sub-langs');
expect(args).not.toContain('--embed-subs');
});
it('does not produce subtitle args when no format profile', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
await service.downloadItem(testContentItem, testChannel);
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).not.toContain('--write-subs');
expect(args).not.toContain('--sub-langs');
expect(args).not.toContain('--embed-subs');
});
it('handles single subtitle language', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
setupForArgs(deps);
const profile: FormatProfile = {
id: 33, name: 'Single Lang', videoResolution: null, audioCodec: null, audioBitrate: null,
containerFormat: null, isDefault: false,
subtitleLanguages: 'en',
embedSubtitles: true,
embedChapters: false, embedThumbnail: false,
sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).toContain('--write-subs');
const slIdx = args.indexOf('--sub-langs');
expect(args[slIdx + 1]).toBe('en');
expect(args).toContain('--embed-subs');
});
});
describe('downloadItem — combined SponsorBlock + subtitle args', () => {
it('includes both SponsorBlock and subtitle args when both are configured', async () => {
const deps = createMockDeps();
const service = new DownloadService(
db, deps.rateLimiter, deps.fileOrganizer,
deps.qualityAnalyzer, deps.cookieManager
);
const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4');
mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true });
writeFileSync(outputPath, 'data');
execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 });
statMock.mockResolvedValueOnce({ size: 1000 });
const profile: FormatProfile = {
id: 40, name: 'Full Features', videoResolution: '1080p', audioCodec: null, audioBitrate: null,
containerFormat: 'mkv', isDefault: false,
subtitleLanguages: 'en,es,fr',
embedSubtitles: true,
embedChapters: true, embedThumbnail: true,
sponsorBlockRemove: 'sponsor,selfpromo,intro,outro',
outputTemplate: null, createdAt: '', updatedAt: '',
};
await service.downloadItem(testContentItem, testChannel, profile);
const args = execYtDlpMock.mock.calls[0][0] as string[];
// Subtitle args
expect(args).toContain('--write-subs');
const slIdx = args.indexOf('--sub-langs');
expect(args[slIdx + 1]).toBe('en,es,fr');
expect(args).toContain('--embed-subs');
// SponsorBlock args
const sbIdx = args.indexOf('--sponsorblock-remove');
expect(sbIdx).toBeGreaterThanOrEqual(0);
expect(args[sbIdx + 1]).toBe('sponsor,selfpromo,intro,outro');
// Chapter + thumbnail embedding
expect(args).toContain('--embed-chapters');
expect(args).toContain('--embed-thumbnail');
});
});
});

View file

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

View file

@ -21,6 +21,7 @@ import {
createContentItem,
getContentItemById,
updateContentItem,
getContentItemsByStatus,
} from '../db/repositories/content-repository';
import type { Platform } from '../types/index';
@ -439,6 +440,95 @@ describe('Content Item Update & Query Functions', () => {
const result = await updateContentItem(db, 999, { status: 'failed' });
expect(result).toBeNull();
});
it('gets content items by status', async () => {
const dbPath = freshDbPath();
const db = await initDatabaseAsync(dbPath);
await runMigrations(dbPath);
const channel = await createChannel(db, {
name: 'Multi Channel',
platform: 'youtube' as Platform,
platformId: 'UC_MULTI',
url: 'https://www.youtube.com/@Multi',
monitoringEnabled: true,
checkInterval: 360,
imageUrl: null,
metadata: null,
formatProfileId: null,
});
// Create items with different statuses
await createContentItem(db, {
channelId: channel.id,
title: 'Item 1',
platformContentId: 'v1',
url: 'https://youtube.com/watch?v=v1',
contentType: 'video' as const,
duration: null,
status: 'monitored',
});
const item2 = await createContentItem(db, {
channelId: channel.id,
title: 'Item 2',
platformContentId: 'v2',
url: 'https://youtube.com/watch?v=v2',
contentType: 'video' as const,
duration: null,
status: 'monitored',
});
await createContentItem(db, {
channelId: channel.id,
title: 'Item 3',
platformContentId: 'v3',
url: 'https://youtube.com/watch?v=v3',
contentType: 'audio' as const,
duration: null,
status: 'downloaded',
});
const monitored = await getContentItemsByStatus(db, 'monitored');
expect(monitored).toHaveLength(2);
const downloaded = await getContentItemsByStatus(db, 'downloaded');
expect(downloaded).toHaveLength(1);
expect(downloaded[0].title).toBe('Item 3');
});
it('respects limit parameter on getContentItemsByStatus', async () => {
const dbPath = freshDbPath();
const db = await initDatabaseAsync(dbPath);
await runMigrations(dbPath);
const channel = await createChannel(db, {
name: 'Limit Channel',
platform: 'youtube' as Platform,
platformId: 'UC_LIMIT',
url: 'https://www.youtube.com/@Limit',
monitoringEnabled: true,
checkInterval: 360,
imageUrl: null,
metadata: null,
formatProfileId: null,
});
for (let i = 0; i < 5; i++) {
await createContentItem(db, {
channelId: channel.id,
title: `Item ${i}`,
platformContentId: `vid_${i}`,
url: `https://youtube.com/watch?v=vid_${i}`,
contentType: 'video' as const,
duration: null,
status: 'monitored',
});
}
const limited = await getContentItemsByStatus(db, 'monitored', 2);
expect(limited).toHaveLength(2);
});
});
// ── Config ──

View file

@ -1,252 +0,0 @@
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

@ -1,332 +0,0 @@
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

@ -1,337 +0,0 @@
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

@ -1,207 +0,0 @@
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

@ -1,220 +0,0 @@
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

@ -1,267 +0,0 @@
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
expect(body.grabAllEnabled).toBe(false);
expect(body.grabAllOrder).toBe('newest');
expect(body.scanLimit).toBe(500);
expect(body.scanLimit).toBe(100);
expect(body.rateLimitDelay).toBe(1000);
});
@ -344,137 +344,6 @@ describe('Platform Settings API', () => {
});
});
// ── Generic platform ──
describe('Generic platform', () => {
it('accepts generic as a valid platform', async () => {
const res = await server.inject({
method: 'PUT',
url: '/api/v1/platform-settings/generic',
headers: { 'x-api-key': apiKey },
payload: {
checkInterval: 480,
concurrencyLimit: 1,
},
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.platform).toBe('generic');
expect(body.checkInterval).toBe(480);
expect(body.nfoEnabled).toBe(false);
expect(body.defaultView).toBe('list');
});
it('shows generic in list of all platform settings', async () => {
const res = await server.inject({
method: 'GET',
url: '/api/v1/platform-settings',
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const platforms = res.json().map((s: { platform: string }) => s.platform);
expect(platforms).toContain('generic');
});
});
// ── nfoEnabled ──
describe('nfoEnabled', () => {
it('persists nfoEnabled through PUT → GET round-trip', async () => {
const putRes = await server.inject({
method: 'PUT',
url: '/api/v1/platform-settings/youtube',
headers: { 'x-api-key': apiKey },
payload: {
nfoEnabled: true,
},
});
expect(putRes.statusCode).toBe(200);
expect(putRes.json().nfoEnabled).toBe(true);
const getRes = await server.inject({
method: 'GET',
url: '/api/v1/platform-settings/youtube',
headers: { 'x-api-key': apiKey },
});
expect(getRes.statusCode).toBe(200);
expect(getRes.json().nfoEnabled).toBe(true);
});
it('defaults nfoEnabled to false when not specified', async () => {
await server.inject({
method: 'DELETE',
url: '/api/v1/platform-settings/generic',
headers: { 'x-api-key': apiKey },
});
const putRes = await server.inject({
method: 'PUT',
url: '/api/v1/platform-settings/generic',
headers: { 'x-api-key': apiKey },
payload: { checkInterval: 360 },
});
expect(putRes.statusCode).toBe(200);
expect(putRes.json().nfoEnabled).toBe(false);
});
});
// ── defaultView ──
describe('defaultView', () => {
it('persists defaultView through PUT → GET round-trip', async () => {
const putRes = await server.inject({
method: 'PUT',
url: '/api/v1/platform-settings/youtube',
headers: { 'x-api-key': apiKey },
payload: {
defaultView: 'poster',
},
});
expect(putRes.statusCode).toBe(200);
expect(putRes.json().defaultView).toBe('poster');
const getRes = await server.inject({
method: 'GET',
url: '/api/v1/platform-settings/youtube',
headers: { 'x-api-key': apiKey },
});
expect(getRes.statusCode).toBe(200);
expect(getRes.json().defaultView).toBe('poster');
});
it('rejects invalid defaultView value', async () => {
const res = await server.inject({
method: 'PUT',
url: '/api/v1/platform-settings/youtube',
headers: { 'x-api-key': apiKey },
payload: {
defaultView: 'grid',
},
});
expect(res.statusCode).toBe(400);
});
it('defaults defaultView to list when not specified', async () => {
await server.inject({
method: 'DELETE',
url: '/api/v1/platform-settings/generic',
headers: { 'x-api-key': apiKey },
});
const putRes = await server.inject({
method: 'PUT',
url: '/api/v1/platform-settings/generic',
headers: { 'x-api-key': apiKey },
payload: { checkInterval: 360 },
});
expect(putRes.statusCode).toBe(200);
expect(putRes.json().defaultView).toBe('list');
});
});
// ── Auth ──
describe('Authentication', () => {

View file

@ -14,6 +14,7 @@ import {
getPendingQueueItems,
updateQueueItemStatus,
countQueueItemsByStatus,
deleteQueueItem,
getQueueItemByContentItemId,
} from '../db/repositories/queue-repository';
import type { Channel, ContentItem } from '../types/index';
@ -343,6 +344,25 @@ describe('Queue Repository', () => {
});
});
describe('deleteQueueItem', () => {
it('deletes an existing item and returns true', async () => {
const item = await createQueueItem(db, {
contentItemId: testContentItem.id,
});
const deleted = await deleteQueueItem(db, item.id);
expect(deleted).toBe(true);
const found = await getQueueItemById(db, item.id);
expect(found).toBeNull();
});
it('returns false for non-existent ID', async () => {
const deleted = await deleteQueueItem(db, 99999);
expect(deleted).toBe(false);
});
});
describe('getQueueItemByContentItemId', () => {
it('returns queue item for a given content item ID', async () => {
const item = await createQueueItem(db, {

View file

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

View file

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

View file

@ -33,11 +33,9 @@ import type {
Channel,
PlatformSourceMetadata,
PlatformContentMetadata,
PlaylistDiscoveryResult,
} from '../types/index';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '../db/schema/index';
import { getPlaylistsByChannelId } from '../db/repositories/playlist-repository';
// ── Rate Limiter Tests ──
@ -612,7 +610,6 @@ describe('SchedulerService', () => {
qualityMetadata: null,
status: 'monitored',
monitored: false,
contentRating: null,
publishedAt: null,
downloadedAt: null,
createdAt: new Date().toISOString(),
@ -662,180 +659,6 @@ describe('SchedulerService', () => {
scheduler.stop();
});
// ── Keyword filter tests ──
it('excludes items matching excludeKeywords pattern', async () => {
const channel = await insertTestChannel({ excludeKeywords: 'shorts|#shorts' });
const scheduler = new SchedulerService(db, registry, rateLimiter);
const items: PlatformContentMetadata[] = [
{
platformContentId: `kf_exc_${channel.id}_1`,
title: 'Great Video About Coding',
url: 'https://www.youtube.com/watch?v=1',
contentType: 'video',
duration: 600,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_exc_${channel.id}_2`,
title: 'Quick shorts compilation',
url: 'https://www.youtube.com/watch?v=2',
contentType: 'video',
duration: 30,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_exc_${channel.id}_3`,
title: 'My Day #shorts vlog',
url: 'https://www.youtube.com/watch?v=3',
contentType: 'video',
duration: 15,
thumbnailUrl: null,
publishedAt: null,
},
];
mockFetchRecentContent.mockResolvedValueOnce(items);
const result = await scheduler.checkChannel(channel);
// Only the first item should pass the filter
expect(result.newItems).toBe(1);
expect(result.totalFetched).toBe(3);
const content = await getContentByChannelId(db, channel.id);
const inserted = content.filter(c =>
c.platformContentId.startsWith(`kf_exc_${channel.id}`)
);
expect(inserted.length).toBe(1);
expect(inserted[0].title).toBe('Great Video About Coding');
scheduler.stop();
});
it('includes only items matching includeKeywords pattern', async () => {
const channel = await insertTestChannel({ includeKeywords: 'tutorial|guide' });
const scheduler = new SchedulerService(db, registry, rateLimiter);
const items: PlatformContentMetadata[] = [
{
platformContentId: `kf_inc_${channel.id}_1`,
title: 'Python Tutorial for Beginners',
url: 'https://www.youtube.com/watch?v=1',
contentType: 'video',
duration: 1800,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_inc_${channel.id}_2`,
title: 'Random Vlog Day 5',
url: 'https://www.youtube.com/watch?v=2',
contentType: 'video',
duration: 300,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_inc_${channel.id}_3`,
title: 'Ultimate Guide to Docker',
url: 'https://www.youtube.com/watch?v=3',
contentType: 'video',
duration: 2400,
thumbnailUrl: null,
publishedAt: null,
},
];
mockFetchRecentContent.mockResolvedValueOnce(items);
const result = await scheduler.checkChannel(channel);
expect(result.newItems).toBe(2);
const content = await getContentByChannelId(db, channel.id);
const inserted = content.filter(c =>
c.platformContentId.startsWith(`kf_inc_${channel.id}`)
);
expect(inserted.length).toBe(2);
const titles = inserted.map(c => c.title);
expect(titles).toContain('Python Tutorial for Beginners');
expect(titles).toContain('Ultimate Guide to Docker');
scheduler.stop();
});
it('applies both include and exclude patterns together', async () => {
const channel = await insertTestChannel({
includeKeywords: 'tutorial',
excludeKeywords: 'shorts',
});
const scheduler = new SchedulerService(db, registry, rateLimiter);
const items: PlatformContentMetadata[] = [
{
platformContentId: `kf_both_${channel.id}_1`,
title: 'Tutorial: Getting Started',
url: 'https://www.youtube.com/watch?v=1',
contentType: 'video',
duration: 1800,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_both_${channel.id}_2`,
title: 'Tutorial shorts recap',
url: 'https://www.youtube.com/watch?v=2',
contentType: 'video',
duration: 30,
thumbnailUrl: null,
publishedAt: null,
},
{
platformContentId: `kf_both_${channel.id}_3`,
title: 'Random Gaming Stream',
url: 'https://www.youtube.com/watch?v=3',
contentType: 'video',
duration: 7200,
thumbnailUrl: null,
publishedAt: null,
},
];
mockFetchRecentContent.mockResolvedValueOnce(items);
const result = await scheduler.checkChannel(channel);
// Item 1: matches include, no exclude match → pass
// Item 2: matches include AND exclude → excluded (exclude wins)
// Item 3: doesn't match include → excluded
expect(result.newItems).toBe(1);
const content = await getContentByChannelId(db, channel.id);
const inserted = content.filter(c =>
c.platformContentId.startsWith(`kf_both_${channel.id}`)
);
expect(inserted.length).toBe(1);
expect(inserted[0].title).toBe('Tutorial: Getting Started');
scheduler.stop();
});
it('does not filter when no keywords are set', async () => {
const channel = await insertTestChannel({
includeKeywords: null,
excludeKeywords: null,
});
const scheduler = new SchedulerService(db, registry, rateLimiter);
mockFetchRecentContent.mockResolvedValueOnce(
makeCannedContent(4, `kf_none_${channel.id}`)
);
const result = await scheduler.checkChannel(channel);
expect(result.newItems).toBe(4);
scheduler.stop();
});
// ── monitoringMode-aware item creation tests ──
it("creates items with monitored=false when channel monitoringMode is 'none'", async () => {
@ -893,91 +716,6 @@ describe('SchedulerService', () => {
scheduler.stop();
});
it('calls fetchPlaylists after successful scan when source supports it', async () => {
const channel = await insertTestChannel();
// Build a registry with fetchPlaylists support
const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise<PlaylistDiscoveryResult[]>>()
.mockResolvedValueOnce([
{
platformPlaylistId: 'PL_test_1',
title: 'Test Playlist',
videoIds: [],
},
]);
const mockSourceWithPlaylists: PlatformSource = {
resolveChannel: mockResolveChannel,
fetchRecentContent: mockFetchRecentContent,
fetchPlaylists: fetchPlaylistsFn,
};
const reg = new PlatformRegistry();
reg.register(Platform.YouTube, mockSourceWithPlaylists);
const localLimiter = new RateLimiter({
[Platform.YouTube]: { minIntervalMs: 0 },
});
const scheduler = new SchedulerService(db, reg, localLimiter);
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `plsync_${channel.id}`));
const result = await scheduler.checkChannel(channel);
expect(result.status).toBe('success');
// Allow background sidecar to complete
await new Promise((r) => setTimeout(r, 100));
expect(fetchPlaylistsFn).toHaveBeenCalledOnce();
// Verify playlist was persisted
const playlists = await getPlaylistsByChannelId(db, channel.id);
expect(playlists.length).toBe(1);
expect(playlists[0].title).toBe('Test Playlist');
scheduler.stop();
});
it('scan succeeds even when playlist sync throws', async () => {
const channel = await insertTestChannel();
const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise<PlaylistDiscoveryResult[]>>()
.mockRejectedValueOnce(new Error('Playlist fetch exploded'));
const mockSourceWithPlaylists: PlatformSource = {
resolveChannel: mockResolveChannel,
fetchRecentContent: mockFetchRecentContent,
fetchPlaylists: fetchPlaylistsFn,
};
const reg = new PlatformRegistry();
reg.register(Platform.YouTube, mockSourceWithPlaylists);
const localLimiter = new RateLimiter({
[Platform.YouTube]: { minIntervalMs: 0 },
});
const scheduler = new SchedulerService(db, reg, localLimiter);
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(2, `plfail_${channel.id}`));
const result = await scheduler.checkChannel(channel);
expect(result.status).toBe('success');
expect(result.newItems).toBe(2);
// Allow background sidecar to attempt and fail
await new Promise((r) => setTimeout(r, 100));
expect(fetchPlaylistsFn).toHaveBeenCalledOnce();
scheduler.stop();
});
it('skips playlist sync when source does not support fetchPlaylists', async () => {
const channel = await insertTestChannel();
const scheduler = new SchedulerService(db, registry, rateLimiter);
// Default mock source has no fetchPlaylists
mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `noplsync_${channel.id}`));
const result = await scheduler.checkChannel(channel);
expect(result.status).toBe('success');
expect(result.newItems).toBe(1);
scheduler.stop();
});
});
// ── addChannel() / removeChannel() ──

View file

@ -135,12 +135,6 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
metadata: null,
formatProfileId: null,
monitoringMode: 'all',
bannerUrl: null,
description: null,
subscriberCount: null,
includeKeywords: null,
excludeKeywords: null,
contentRating: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
lastCheckedAt: null,
@ -243,9 +237,6 @@ describe('YouTubeSource', () => {
imageUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw',
platform: 'youtube',
bannerUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
description: null,
subscriberCount: null,
});
// Verify yt-dlp was called with correct args
@ -337,7 +328,7 @@ describe('YouTubeSource', () => {
expect(mockExecYtDlp).toHaveBeenNthCalledWith(
1,
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
{ timeout: 90_000 }
{ timeout: 60_000 }
);
// Verify Phase 2 calls use --dump-json --no-playlist per video
@ -446,7 +437,7 @@ describe('YouTubeSource', () => {
expect(mockExecYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '--dump-json', '--playlist-items', '1:10', channel.url],
{ timeout: 90_000 }
{ timeout: 60_000 }
);
});
@ -459,7 +450,7 @@ describe('YouTubeSource', () => {
expect(mockExecYtDlp).toHaveBeenCalledWith(
['--flat-playlist', '--dump-json', '--playlist-items', '1:50', channel.url],
{ timeout: 90_000 }
{ timeout: 60_000 }
);
});
});
@ -678,9 +669,6 @@ describe('SoundCloudSource', () => {
imageUrl: 'https://i1.sndcdn.com/avatars-large.jpg',
url: 'https://soundcloud.com/deadmau5',
platform: 'soundcloud',
bannerUrl: null,
description: null,
subscriberCount: null,
});
});
});

View file

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

View file

@ -1,161 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
classifyYtDlpError,
YtDlpError,
type YtDlpErrorCategory,
} from '../sources/yt-dlp';
describe('classifyYtDlpError', () => {
// ── rate_limit ──
it('classifies HTTP 429 as rate_limit', () => {
expect(classifyYtDlpError('ERROR: HTTP Error 429: Too Many Requests')).toBe('rate_limit');
});
it('classifies "too many requests" as rate_limit', () => {
expect(classifyYtDlpError('ERROR: too many requests, please retry later')).toBe('rate_limit');
});
// ── format_unavailable ──
it('classifies "requested format" as format_unavailable', () => {
expect(classifyYtDlpError('ERROR: requested format not available')).toBe('format_unavailable');
});
it('classifies "format is not available" as format_unavailable', () => {
expect(classifyYtDlpError('ERROR: format is not available')).toBe('format_unavailable');
});
// ── geo_blocked ──
it('classifies geo-restriction as geo_blocked', () => {
expect(
classifyYtDlpError('ERROR: Video not available in your country')
).toBe('geo_blocked');
});
it('classifies "geo" keyword as geo_blocked', () => {
expect(classifyYtDlpError('ERROR: geo-restricted content')).toBe('geo_blocked');
});
// ── age_restricted ──
it('classifies age-restricted content', () => {
expect(classifyYtDlpError('ERROR: age restricted video')).toBe('age_restricted');
});
it('classifies age verify as age_restricted', () => {
expect(classifyYtDlpError('Sign in to confirm your age. This video may be inappropriate for some users. Verify your age')).toBe('age_restricted');
});
// ── private ──
it('classifies private video', () => {
expect(classifyYtDlpError('ERROR: Private video. Sign in if you\'ve been granted access')).toBe('private');
});
it('classifies "video unavailable"', () => {
expect(classifyYtDlpError('ERROR: Video unavailable')).toBe('private');
});
it('classifies "been removed"', () => {
expect(classifyYtDlpError('ERROR: This video has been removed by the uploader')).toBe('private');
});
// ── sign_in_required ──
it('classifies "sign in" as sign_in_required', () => {
expect(classifyYtDlpError('ERROR: Sign in to confirm you are not a bot')).toBe('sign_in_required');
});
it('classifies "login required" as sign_in_required', () => {
expect(classifyYtDlpError('ERROR: This video requires login required authentication')).toBe('sign_in_required');
});
// ── copyright ──
it('classifies "copyright" keyword', () => {
expect(classifyYtDlpError('ERROR: This video contains content from UMG, who has blocked it on copyright grounds')).toBe('copyright');
});
it('classifies "blocked...claim" pattern', () => {
expect(classifyYtDlpError('ERROR: Video blocked due to a claim by Sony Music')).toBe('copyright');
});
// ── network ──
it('classifies connection error as network', () => {
expect(classifyYtDlpError('ERROR: unable to download webpage: connection refused')).toBe('network');
});
it('classifies timeout as network', () => {
expect(classifyYtDlpError('ERROR: timed out')).toBe('network');
});
it('classifies urlopen error as network', () => {
expect(classifyYtDlpError('ERROR: <urlopen error [Errno -2] Name or service not known>')).toBe('network');
});
// ── unknown ──
it('returns unknown for empty string', () => {
expect(classifyYtDlpError('')).toBe('unknown');
});
it('returns unknown for unrecognized error', () => {
expect(classifyYtDlpError('ERROR: Something completely unexpected happened')).toBe('unknown');
});
// ── Priority / first-match-wins ──
it('first match wins when multiple signals present', () => {
// Contains both '429' (rate_limit) and 'connection' (network) — rate_limit is checked first
const result = classifyYtDlpError('ERROR: 429 connection refused');
expect(result).toBe('rate_limit');
});
});
describe('YtDlpError.category', () => {
it('auto-populates category from stderr in constructor', () => {
const err = new YtDlpError(
'yt-dlp failed',
'ERROR: HTTP Error 429: Too Many Requests',
1
);
expect(err.category).toBe('rate_limit');
expect(err.isRateLimit).toBe(true);
});
it('sets category to unknown for unrecognized errors', () => {
const err = new YtDlpError('yt-dlp failed', 'some weird error', 1);
expect(err.category).toBe('unknown');
expect(err.isRateLimit).toBe(false);
});
it('sets sign_in_required category', () => {
const err = new YtDlpError(
'yt-dlp failed',
'ERROR: Sign in to confirm you are not a bot',
1
);
expect(err.category).toBe('sign_in_required');
});
it('sets copyright category', () => {
const err = new YtDlpError(
'yt-dlp failed',
'ERROR: blocked on copyright grounds',
1
);
expect(err.category).toBe('copyright');
});
it('preserves all existing YtDlpError properties', () => {
const err = new YtDlpError('msg', 'stderr text', 42);
expect(err.name).toBe('YtDlpError');
expect(err.message).toBe('msg');
expect(err.stderr).toBe('stderr text');
expect(err.exitCode).toBe(42);
expect(err.category).toBe('unknown');
});
});

View file

@ -9,20 +9,12 @@ import type { Channel, Platform, MonitoringMode } from '../../types/index';
/** Fields needed to create a new channel (auto-generated fields excluded). */
export type CreateChannelData = Omit<
Channel,
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords' | 'contentRating'
> & {
monitoringMode?: Channel['monitoringMode'];
bannerUrl?: string | null;
description?: string | null;
subscriberCount?: number | null;
includeKeywords?: string | null;
excludeKeywords?: string | null;
contentRating?: string | null;
};
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode'
> & { monitoringMode?: Channel['monitoringMode'] };
/** Fields that can be updated on an existing channel. */
export type UpdateChannelData = Partial<
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords'>
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode'>
>;
type Db = LibSQLDatabase<typeof schema>;
@ -47,11 +39,6 @@ export async function createChannel(
metadata: data.metadata,
formatProfileId: data.formatProfileId,
monitoringMode: data.monitoringMode ?? 'all',
bannerUrl: data.bannerUrl ?? null,
description: data.description ?? null,
subscriberCount: data.subscriberCount ?? null,
includeKeywords: data.includeKeywords ?? null,
excludeKeywords: data.excludeKeywords ?? null,
})
.returning();
@ -198,15 +185,9 @@ function mapRow(row: typeof channels.$inferSelect): Channel {
metadata: row.metadata as Record<string, unknown> | null,
formatProfileId: row.formatProfileId,
monitoringMode: (row.monitoringMode ?? 'all') as Channel['monitoringMode'],
bannerUrl: row.bannerUrl ?? null,
description: row.description ?? null,
subscriberCount: row.subscriberCount ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastCheckedAt: row.lastCheckedAt,
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 { contentItems } from '../schema/index';
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
import type { ContentCounts, ContentTypeCounts } from '../../types/api';
import type { ContentCounts } from '../../types/api';
// ── Types ──
/** Fields needed to create a new content item. */
export interface CreateContentItemData {
channelId: number | null;
channelId: number;
title: string;
platformContentId: string;
url: string;
@ -29,7 +29,6 @@ export interface UpdateContentItemData {
qualityMetadata?: QualityInfo | null;
status?: ContentStatus;
downloadedAt?: string | null;
contentRating?: string | null;
}
type Db = LibSQLDatabase<typeof schema>;
@ -44,22 +43,16 @@ export async function createContentItem(
db: Db,
data: CreateContentItemData
): Promise<ContentItem | null> {
// Check for existing item — dedup by (channelId, platformContentId) for channel items,
// or by platformContentId alone for ad-hoc items (channelId=null)
const dedupConditions = data.channelId !== null
? and(
eq(contentItems.channelId, data.channelId),
eq(contentItems.platformContentId, data.platformContentId)
)
: and(
sql`${contentItems.channelId} IS NULL`,
eq(contentItems.platformContentId, data.platformContentId)
);
// Check for existing item first — dedup by (channelId, platformContentId)
const existing = await db
.select({ id: contentItems.id })
.from(contentItems)
.where(dedupConditions)
.where(
and(
eq(contentItems.channelId, data.channelId),
eq(contentItems.platformContentId, data.platformContentId)
)
)
.limit(1);
if (existing.length > 0) {
@ -174,26 +167,21 @@ function resolveSortColumn(sortBy?: string) {
}
}
/** Check if a specific content item exists for a channel (or ad-hoc if channelId is null). Returns the item or null. */
/** Check if a specific content item exists for a channel. Returns the item or null. */
export async function getContentByPlatformContentId(
db: Db,
channelId: number | null,
channelId: number,
platformContentId: string
): Promise<ContentItem | null> {
const conditions = channelId !== null
? and(
eq(contentItems.channelId, channelId),
eq(contentItems.platformContentId, platformContentId)
)
: and(
sql`${contentItems.channelId} IS NULL`,
eq(contentItems.platformContentId, platformContentId)
);
const rows = await db
.select()
.from(contentItems)
.where(conditions)
.where(
and(
eq(contentItems.channelId, channelId),
eq(contentItems.platformContentId, platformContentId)
)
)
.limit(1);
return rows.length > 0 ? mapRow(rows[0]) : null;
@ -300,6 +288,24 @@ export async function bulkSetMonitored(
}
/** Get content items by status, ordered by creation date (oldest first). */
export async function getContentItemsByStatus(
db: Db,
status: ContentStatus,
limit?: number
): Promise<ContentItem[]> {
let query = db
.select()
.from(contentItems)
.where(eq(contentItems.status, status))
.orderBy(contentItems.createdAt);
if (limit !== undefined) {
query = query.limit(limit) as typeof query;
}
const rows = await query;
return rows.map(mapRow);
}
// ── Paginated Listing ──
@ -373,35 +379,6 @@ function buildContentFilterConditions(filters?: ContentItemFilters) {
return conditions;
}
// ── Content Counts by Type ──
/**
* Get content counts grouped by content type for a single channel.
* Returns { video, audio, livestream } with zero-defaults for missing types.
*/
export async function getContentCountsByType(
db: Db,
channelId: number
): Promise<ContentTypeCounts> {
const rows = await db
.select({
contentType: contentItems.contentType,
count: sql<number>`count(*)`,
})
.from(contentItems)
.where(eq(contentItems.channelId, channelId))
.groupBy(contentItems.contentType);
const counts: ContentTypeCounts = { video: 0, audio: 0, livestream: 0 };
for (const row of rows) {
const ct = row.contentType as keyof ContentTypeCounts;
if (ct in counts) {
counts[ct] = Number(row.count);
}
}
return counts;
}
// ── Content Counts by Channel ──
/**
@ -427,8 +404,7 @@ export async function getContentCountsByChannelIds(
const map = new Map<number, ContentCounts>();
for (const row of rows) {
// channelId is always non-null here — we're filtering by specific channelIds via inArray
map.set(row.channelId!, {
map.set(row.channelId, {
total: Number(row.total),
monitored: Number(row.monitored),
downloaded: Number(row.downloaded),
@ -486,7 +462,6 @@ function mapRow(row: typeof contentItems.$inferSelect): ContentItem {
publishedAt: row.publishedAt ?? null,
downloadedAt: row.downloadedAt ?? null,
monitored: row.monitored,
contentRating: row.contentRating ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};

View file

@ -16,9 +16,6 @@ export interface CreateFormatProfileData {
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
embedChapters?: boolean;
embedThumbnail?: boolean;
sponsorBlockRemove?: string | null;
}
/** Fields that can be updated on an existing format profile. */
@ -31,9 +28,6 @@ export interface UpdateFormatProfileData {
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
embedChapters?: boolean;
embedThumbnail?: boolean;
sponsorBlockRemove?: string | null;
}
type Db = LibSQLDatabase<typeof schema>;
@ -66,9 +60,6 @@ export async function createFormatProfile(
isDefault: data.isDefault ?? false,
subtitleLanguages: data.subtitleLanguages ?? null,
embedSubtitles: data.embedSubtitles ?? false,
embedChapters: data.embedChapters ?? false,
embedThumbnail: data.embedThumbnail ?? false,
sponsorBlockRemove: data.sponsorBlockRemove ?? null,
})
.returning();
@ -189,10 +180,6 @@ function mapRow(row: typeof formatProfiles.$inferSelect): FormatProfile {
isDefault: row.isDefault,
subtitleLanguages: row.subtitleLanguages ?? null,
embedSubtitles: row.embedSubtitles,
embedChapters: row.embedChapters,
embedThumbnail: row.embedThumbnail,
sponsorBlockRemove: row.sponsorBlockRemove ?? null,
outputTemplate: row.outputTemplate ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};

View file

@ -1,137 +0,0 @@
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

@ -18,8 +18,6 @@ export interface UpsertPlatformSettingsData {
scanLimit?: number;
rateLimitDelay?: number;
defaultMonitoringMode?: MonitoringMode;
nfoEnabled?: boolean;
defaultView?: 'list' | 'poster' | 'table';
}
type Db = LibSQLDatabase<typeof schema>;
@ -66,11 +64,9 @@ export async function upsertPlatformSettings(
subtitleLanguages: data.subtitleLanguages ?? null,
grabAllEnabled: data.grabAllEnabled ?? false,
grabAllOrder: data.grabAllOrder ?? 'newest',
scanLimit: data.scanLimit ?? 500,
scanLimit: data.scanLimit ?? 100,
rateLimitDelay: data.rateLimitDelay ?? 1000,
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
nfoEnabled: data.nfoEnabled ?? false,
defaultView: data.defaultView ?? 'list',
createdAt: now,
updatedAt: now,
})
@ -83,11 +79,9 @@ export async function upsertPlatformSettings(
subtitleLanguages: data.subtitleLanguages ?? null,
grabAllEnabled: data.grabAllEnabled ?? false,
grabAllOrder: data.grabAllOrder ?? 'newest',
scanLimit: data.scanLimit ?? 500,
scanLimit: data.scanLimit ?? 100,
rateLimitDelay: data.rateLimitDelay ?? 1000,
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
nfoEnabled: data.nfoEnabled ?? false,
defaultView: data.defaultView ?? 'list',
updatedAt: now,
},
})
@ -120,11 +114,9 @@ function mapRow(row: typeof platformSettings.$inferSelect): PlatformSettings {
subtitleLanguages: row.subtitleLanguages,
grabAllEnabled: row.grabAllEnabled,
grabAllOrder: row.grabAllOrder as 'newest' | 'oldest',
scanLimit: row.scanLimit ?? 500,
scanLimit: row.scanLimit ?? 100,
rateLimitDelay: row.rateLimitDelay ?? 1000,
defaultMonitoringMode: (row.defaultMonitoringMode ?? 'all') as MonitoringMode,
nfoEnabled: row.nfoEnabled,
defaultView: (row.defaultView ?? 'list') as 'list' | 'poster' | 'table',
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};

View file

@ -150,6 +150,12 @@ export async function getContentPlaylistMappings(
}
/** Delete all playlists for a channel. Cascade handles junction rows. */
export async function deletePlaylistsByChannelId(
db: Db,
channelId: number
): Promise<void> {
await db.delete(playlists).where(eq(playlists.channelId, channelId));
}
// ── Row Mapping ──

View file

@ -16,7 +16,6 @@ export interface CreateQueueItemData {
/** Optional fields when updating queue item status. */
export interface UpdateQueueItemFields {
error?: string | null;
errorCategory?: string | null;
startedAt?: string | null;
completedAt?: string | null;
attempts?: number;
@ -73,7 +72,6 @@ export async function getQueueItemsByStatus(
attempts: queueItems.attempts,
maxAttempts: queueItems.maxAttempts,
error: queueItems.error,
errorCategory: queueItems.errorCategory,
startedAt: queueItems.startedAt,
completedAt: queueItems.completedAt,
createdAt: queueItems.createdAt,
@ -103,7 +101,6 @@ export async function getAllQueueItems(
attempts: queueItems.attempts,
maxAttempts: queueItems.maxAttempts,
error: queueItems.error,
errorCategory: queueItems.errorCategory,
startedAt: queueItems.startedAt,
completedAt: queueItems.completedAt,
createdAt: queueItems.createdAt,
@ -157,7 +154,6 @@ export async function updateQueueItemStatus(
};
if (updates?.error !== undefined) setData.error = updates.error;
if (updates?.errorCategory !== undefined) setData.errorCategory = updates.errorCategory;
if (updates?.startedAt !== undefined) setData.startedAt = updates.startedAt;
if (updates?.completedAt !== undefined) setData.completedAt = updates.completedAt;
if (updates?.attempts !== undefined) setData.attempts = updates.attempts;
@ -192,7 +188,6 @@ export async function countQueueItemsByStatus(
completed: 0,
failed: 0,
cancelled: 0,
paused: 0,
};
for (const row of rows) {
@ -203,6 +198,17 @@ export async function countQueueItemsByStatus(
}
/** Delete a queue item by ID. Returns true if a row was deleted. */
export async function deleteQueueItem(
db: Db,
id: number
): Promise<boolean> {
const result = await db
.delete(queueItems)
.where(eq(queueItems.id, id))
.returning({ id: queueItems.id });
return result.length > 0;
}
/**
* Get a queue item by content item ID (for dedup checking before enqueue).
@ -233,7 +239,6 @@ function mapRow(row: typeof queueItems.$inferSelect): QueueItem {
attempts: row.attempts,
maxAttempts: row.maxAttempts,
error: row.error,
errorCategory: row.errorCategory,
startedAt: row.startedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,
@ -250,7 +255,6 @@ interface JoinedQueueRow {
attempts: number;
maxAttempts: number;
error: string | null;
errorCategory: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
@ -269,7 +273,6 @@ function mapJoinedRow(row: JoinedQueueRow): QueueItem {
attempts: row.attempts,
maxAttempts: row.maxAttempts,
error: row.error,
errorCategory: row.errorCategory,
startedAt: row.startedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,

View file

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

View file

@ -28,10 +28,4 @@ export const channels = sqliteTable('channels', {
lastCheckedAt: text('last_checked_at'), // null until first monitoring check
lastCheckStatus: text('last_check_status'), // 'success' | 'error' | 'rate_limited'
monitoringMode: text('monitoring_mode').notNull().default('all'), // 'all' | 'future' | 'existing' | 'none'
bannerUrl: text('banner_url'),
description: text('description'),
subscriberCount: integer('subscriber_count'),
includeKeywords: text('include_keywords'), // nullable — pipe-separated patterns for auto-enqueue filtering
excludeKeywords: text('exclude_keywords'), // nullable — pipe-separated patterns for auto-enqueue filtering
contentRating: text('content_rating'), // nullable — default rating for all content from this channel (e.g. 'TV-PG', 'TV-MA')
});

View file

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

View file

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

View file

@ -1,19 +0,0 @@
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

@ -17,8 +17,6 @@ export const platformSettings = sqliteTable('platform_settings', {
scanLimit: integer('scan_limit').default(100),
rateLimitDelay: integer('rate_limit_delay').default(1000),
defaultMonitoringMode: text('default_monitoring_mode').notNull().default('all'),
nfoEnabled: integer('nfo_enabled', { mode: 'boolean' }).notNull().default(false),
defaultView: text('default_view').notNull().default('list'),
createdAt: text('created_at')
.notNull()
.default(sql`(datetime('now'))`),

View file

@ -13,7 +13,6 @@ export const queueItems = sqliteTable('queue_items', {
attempts: integer('attempts').notNull().default(0),
maxAttempts: integer('max_attempts').notNull().default(3),
error: text('error'),
errorCategory: text('error_category'), // rate_limit|format_unavailable|geo_blocked|age_restricted|private|network|sign_in_required|copyright|unknown
startedAt: text('started_at'),
completedAt: text('completed_at'),
createdAt: text('created_at')

View file

@ -4,17 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tubearr</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<script>
// Apply saved theme instantly to prevent flash of wrong theme
(function() {
var t = localStorage.getItem('tubearr-theme');
if (t === 'light') document.documentElement.dataset.theme = 'light';
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View file

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

View file

@ -1,52 +0,0 @@
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

@ -39,6 +39,8 @@ interface CreateChannelInput {
monitoringEnabled?: boolean;
monitoringMode?: string;
formatProfileId?: number;
grabAll?: boolean;
grabAllOrder?: 'newest' | 'oldest';
}
/** Create a new channel by URL (resolves metadata via backend). */
@ -59,7 +61,7 @@ export function useUpdateChannel(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; contentRating?: string | null }) =>
mutationFn: (data: { name?: string; checkInterval?: number; monitoringEnabled?: boolean; formatProfileId?: number | null }) =>
apiClient.put<Channel>(`/api/v1/channel/${id}`, data),
onSuccess: (updated) => {
queryClient.setQueryData(channelKeys.detail(id), updated);
@ -86,9 +88,9 @@ export function useDeleteChannel() {
export interface ScanChannelResult {
channelId: number;
channelName: string;
newItems?: number;
totalFetched?: number;
status: 'started' | 'success' | 'error' | 'rate_limited' | 'already_running';
newItems: number;
totalFetched: number;
status: 'success' | 'error' | 'rate_limited' | 'already_running';
}
export interface ScanAllResult {
@ -98,22 +100,18 @@ export interface ScanAllResult {
// ── Scan Mutations ──
/**
* Trigger a manual scan for a single channel.
* Returns immediately with status 'started' progress is streamed via WebSocket.
*/
/** Trigger a manual scan for a single channel. */
export function useScanChannel(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () =>
apiClient.post<ScanChannelResult>(`/api/v1/channel/${id}/scan`),
});
}
/** Cancel an in-progress scan for a channel. */
export function useCancelScan(id: number) {
return useMutation({
mutationFn: () =>
apiClient.post<{ channelId: number; cancelled: boolean }>(`/api/v1/channel/${id}/scan-cancel`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: channelKeys.all });
queryClient.invalidateQueries({ queryKey: channelKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(id) });
},
});
}
@ -130,6 +128,46 @@ export function useScanAllChannels() {
});
}
// ── Scan Status Polling ──
interface ScanStatusResponse {
scanning: boolean;
}
/**
* Poll the scan-status endpoint while `enabled` is true.
* When the scan completes (scanning flips false), calls `onComplete`.
* Polls every 2s.
*/
export function useScanStatus(
channelId: number,
enabled: boolean,
onComplete?: () => void,
) {
const queryClient = useQueryClient();
const onCompleteRef = { current: onComplete };
onCompleteRef.current = onComplete;
return useQuery({
queryKey: ['scan-status', channelId] as const,
queryFn: async () => {
const result = await apiClient.get<ScanStatusResponse>(
`/api/v1/channel/${channelId}/scan-status`,
);
// When scan just finished, refetch content and notify caller
if (!result.scanning) {
queryClient.invalidateQueries({ queryKey: channelKeys.all });
queryClient.invalidateQueries({ queryKey: channelKeys.detail(channelId) });
queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(channelId) });
onCompleteRef.current?.();
}
return result;
},
enabled: enabled && channelId > 0,
refetchInterval: enabled ? 2000 : false,
});
}
/** Set the monitoring mode for a channel (cascades to content items). */
export function useSetMonitoringMode(channelId: number) {
const queryClient = useQueryClient();

View file

@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tansta
import { apiClient } from '../client';
import { queueKeys } from './useQueue';
import type { ContentItem } from '@shared/types/index';
import type { ApiResponse, PaginatedResponse, ContentTypeCounts } from '@shared/types/api';
import type { ApiResponse, PaginatedResponse } from '@shared/types/api';
// ── Collect Types ──
@ -31,12 +31,24 @@ export const contentKeys = {
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
['content', 'channel', channelId, 'paginated', filters] as const,
countsByType: (channelId: number) =>
['content', 'channel', channelId, 'counts-by-type'] as const,
};
// ── Queries ──
/** Fetch content items for a specific channel (legacy — all items). */
export function useChannelContent(channelId: number) {
return useQuery({
queryKey: contentKeys.byChannel(channelId),
queryFn: async () => {
const response = await apiClient.get<ApiResponse<ContentItem[]>>(
`/api/v1/channel/${channelId}/content`,
);
return response.data;
},
enabled: channelId > 0,
});
}
/** Fetch paginated content items for a channel with search/filter/sort. */
export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) {
return useQuery({
@ -61,20 +73,6 @@ export function useChannelContentPaginated(channelId: number, filters: ChannelCo
});
}
/** Fetch content counts grouped by content type for a channel. */
export function useContentTypeCounts(channelId: number) {
return useQuery({
queryKey: contentKeys.countsByType(channelId),
queryFn: async () => {
const response = await apiClient.get<ApiResponse<ContentTypeCounts>>(
`/api/v1/channel/${channelId}/content-counts`,
);
return response.data;
},
enabled: channelId > 0,
});
}
// ── Mutations ──
/** Enqueue a content item for download. Returns 202 with queue item. */
@ -154,16 +152,3 @@ 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,7 +29,6 @@ interface CreateFormatProfileInput {
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
outputTemplate?: string | null;
}
interface UpdateFormatProfileInput {
@ -41,7 +40,6 @@ interface UpdateFormatProfileInput {
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
outputTemplate?: string | null;
}
// ── Mutations ──

View file

@ -1,4 +1,4 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../client';
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
import type { PaginatedResponse } from '@shared/types/api';
@ -44,17 +44,3 @@ 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

@ -1,111 +0,0 @@
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

@ -41,8 +41,6 @@ export interface UpdatePlatformSettingsInput {
scanLimit?: number;
rateLimitDelay?: number;
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
nfoEnabled?: boolean;
defaultView?: 'list' | 'poster' | 'table';
}
// ── Mutations ──

View file

@ -57,33 +57,3 @@ 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

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse } from '@shared/types/api';
// ── Query Keys ──
@ -9,8 +9,6 @@ export const systemKeys = {
health: ['system', 'health'] as const,
apiKey: ['system', 'apikey'] as const,
appSettings: ['system', 'appSettings'] as const,
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
missingScanStatus: ['system', 'missingScanStatus'] as const,
};
// ── Queries ──
@ -72,64 +70,3 @@ export function useUpdateAppSettings() {
},
});
}
/** Fetch yt-dlp version and last-updated timestamp. Auto-refreshes every 60s. */
export function useYtDlpStatus() {
return useQuery({
queryKey: systemKeys.ytdlpStatus,
queryFn: () => apiClient.get<YtDlpStatusResponse>('/api/v1/system/ytdlp/status'),
refetchInterval: 60_000,
});
}
/** Trigger a yt-dlp update check. Invalidates the status query on success. */
export function useUpdateYtDlp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<YtDlpUpdateResponse>('/api/v1/system/ytdlp/update'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: systemKeys.ytdlpStatus });
},
});
}
// ── 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

@ -31,18 +31,12 @@ function detectPlatform(url: string): Platform | null {
return 'soundcloud';
}
// Any valid URL → Generic (yt-dlp supports 1000+ sites)
if (/^https?:\/\/.+/.test(url)) {
return 'generic';
}
return null;
}
const PLATFORM_LABELS: Record<Platform, string> = {
youtube: 'YouTube',
soundcloud: 'SoundCloud',
generic: 'Generic',
};
// ── Component ──
@ -56,7 +50,9 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
const [url, setUrl] = useState('');
const [checkInterval, setCheckInterval] = useState('');
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
const [monitoringMode, setMonitoringMode] = useState<string>('none');
const [monitoringMode, setMonitoringMode] = useState<string>('all');
const [grabAll, setGrabAll] = useState(false);
const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest');
const createChannel = useCreateChannel();
const { data: platformSettingsList } = usePlatformSettings();
@ -86,6 +82,16 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
if (settings.defaultMonitoringMode) {
setMonitoringMode(settings.defaultMonitoringMode);
}
// Pre-fill grab-all defaults for YouTube
if (detectedPlatform === 'youtube') {
if (settings.grabAllEnabled) {
setGrabAll(true);
}
if (settings.grabAllOrder) {
setGrabAllOrder(settings.grabAllOrder);
}
}
}, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = (e: React.FormEvent) => {
@ -98,6 +104,8 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
monitoringMode,
formatProfileId: formatProfileId ?? undefined,
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
},
{
onSuccess: (newChannel) => {
@ -119,7 +127,9 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
setUrl('');
setCheckInterval('');
setFormatProfileId(undefined);
setMonitoringMode('none');
setMonitoringMode('all');
setGrabAll(false);
setGrabAllOrder('newest');
createChannel.reset();
};
@ -132,29 +142,7 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
return (
<Modal title="Add Channel" open={open} onClose={handleClose}>
<form onSubmit={handleSubmit} style={{ position: 'relative' }}>
{/* Loading overlay */}
{createChannel.isPending && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-3)',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 'var(--radius-md)',
}}
>
<Loader size={28} style={{ animation: 'spin 1s linear infinite', color: 'var(--accent)' }} />
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 500, color: 'var(--text-primary)' }}>
Resolving channel
</span>
</div>
)}
<form onSubmit={handleSubmit}>
{/* URL input */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
@ -200,34 +188,31 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
)}
</div>
{/* Monitoring Mode — shown when platform detected */}
{detectedPlatform && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="monitoring-mode"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Monitoring Mode
</label>
<select
id="monitoring-mode"
value={monitoringMode}
onChange={(e) => setMonitoringMode(e.target.value)}
disabled={createChannel.isPending}
style={{ width: '100%' }}
>
<option value="all">Monitor All</option>
<option value="future">Future Only</option>
<option value="none">None</option>
</select>
</div>
)}
{/* Check interval (optional) */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="check-interval"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Check Interval (minutes)
</label>
<input
id="check-interval"
type="number"
min={1}
value={checkInterval}
onChange={(e) => setCheckInterval(e.target.value)}
placeholder="360 (default: 6 hours)"
disabled={createChannel.isPending}
style={{ width: '100%' }}
/>
</div>
{/* Format Profile (optional, shown when platform detected) */}
{detectedPlatform && formatProfiles && formatProfiles.length > 0 && (
@ -263,31 +248,105 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
</div>
)}
{/* Check interval (optional) */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="check-interval"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Check Interval (minutes)
</label>
<input
id="check-interval"
type="number"
min={1}
value={checkInterval}
onChange={(e) => setCheckInterval(e.target.value)}
placeholder="360 (default: 6 hours)"
disabled={createChannel.isPending}
style={{ width: '100%' }}
/>
</div>
{/* Monitoring Mode — shown when platform detected */}
{detectedPlatform && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="monitoring-mode"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Monitoring Mode
</label>
<select
id="monitoring-mode"
value={monitoringMode}
onChange={(e) => setMonitoringMode(e.target.value)}
disabled={createChannel.isPending}
style={{ width: '100%' }}
>
<option value="all">Monitor All</option>
<option value="future">Future Only</option>
<option value="none">None</option>
</select>
</div>
)}
{/* Grab All — YouTube only */}
{detectedPlatform === 'youtube' && (
<>
<div
style={{
marginBottom: grabAll ? 'var(--space-3)' : 'var(--space-4)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
}}
>
<input
id="grab-all"
type="checkbox"
checked={grabAll}
onChange={(e) => setGrabAll(e.target.checked)}
disabled={createChannel.isPending}
style={{ width: 'auto' }}
/>
<label
htmlFor="grab-all"
style={{
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
Grab all existing content?
</label>
</div>
{/* Download order — shown when grab-all enabled */}
{grabAll && (
<div style={{ marginBottom: 'var(--space-4)', paddingLeft: 'var(--space-5)' }}>
<label
htmlFor="grab-all-order"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Download Order
</label>
<select
id="grab-all-order"
value={grabAllOrder}
onChange={(e) => setGrabAllOrder(e.target.value as 'newest' | 'oldest')}
disabled={createChannel.isPending}
style={{ width: '100%' }}
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
<p
style={{
margin: 'var(--space-1) 0 0',
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
}}
>
Back-catalog items will be enqueued at low priority.
</p>
</div>
)}
</>
)}
{/* Error display */}
{createChannel.isError && (
@ -303,12 +362,9 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
color: 'var(--danger)',
}}
>
{createChannel.error instanceof Error &&
createChannel.error.message.toLowerCase().includes('already exists')
? 'This channel has already been added.'
: createChannel.error instanceof Error
? createChannel.error.message
: 'Failed to add channel'}
{createChannel.error instanceof Error
? createChannel.error.message
: 'Failed to add channel'}
</div>
)}

View file

@ -1,425 +0,0 @@
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,257 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Settings2, GripVertical, Eye, EyeOff } from 'lucide-react';
// ── Types ──
export interface ListColumnDef {
key: string;
label: string;
/** Columns that cannot be hidden */
required?: boolean;
}
export interface ColumnConfig {
key: string;
visible: boolean;
}
// ── Constants ──
export const LIST_COLUMNS: ListColumnDef[] = [
{ key: 'thumbnail', label: 'Thumbnail' },
{ key: 'contentType', label: 'Type' },
{ key: 'contentRating', label: 'Rating' },
{ key: 'quality', label: 'Quality' },
{ key: 'publishedAt', label: 'Published' },
{ key: 'downloadedAt', label: 'Downloaded' },
{ key: 'duration', label: 'Duration' },
{ key: 'fileSize', label: 'Size' },
];
export const DEFAULT_COLUMN_CONFIG: ColumnConfig[] = LIST_COLUMNS.map((col) => ({
key: col.key,
visible: true,
}));
/** Merge stored config with current column defs — handles new columns added after storage was saved */
export function mergeColumnConfig(stored: ColumnConfig[]): ColumnConfig[] {
const storedMap = new Map(stored.map((c) => [c.key, c]));
const merged: ColumnConfig[] = [];
const seen = new Set<string>();
// Preserve stored order for known columns
for (const s of stored) {
if (LIST_COLUMNS.some((c) => c.key === s.key)) {
merged.push(s);
seen.add(s.key);
}
}
// Append any new columns not in stored config
for (const col of LIST_COLUMNS) {
if (!seen.has(col.key)) {
merged.push({ key: col.key, visible: true });
}
}
return merged;
}
// ── Component ──
interface ColumnConfigPopoverProps {
columns: ColumnConfig[];
onChange: (columns: ColumnConfig[]) => void;
}
export function ColumnConfigPopover({ columns, onChange }: ColumnConfigPopoverProps) {
const [open, setOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const toggleVisibility = useCallback(
(key: string) => {
onChange(
columns.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c)),
);
},
[columns, onChange],
);
const moveColumn = useCallback(
(fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
const next = [...columns];
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
onChange(next);
},
[columns, onChange],
);
const handleDragStart = useCallback((idx: number) => {
setDragIndex(idx);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault();
if (dragIndex !== null && dragIndex !== idx) {
moveColumn(dragIndex, idx);
setDragIndex(idx);
}
},
[dragIndex, moveColumn],
);
const handleDragEnd = useCallback(() => {
setDragIndex(null);
}, []);
const visibleCount = columns.filter((c) => c.visible).length;
return (
<div style={{ position: 'relative' }}>
<button
ref={buttonRef}
onClick={() => setOpen((prev) => !prev)}
title="Configure columns"
aria-label="Configure list columns"
aria-expanded={open}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 34,
height: 34,
borderRadius: 'var(--radius-md)',
backgroundColor: open ? 'var(--accent)' : 'var(--bg-input)',
color: open ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
flexShrink: 0,
}}
>
<Settings2 size={16} />
</button>
{open && (
<div
ref={popoverRef}
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 200,
minWidth: 220,
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-md)',
padding: 'var(--space-2) 0',
}}
>
<div
style={{
padding: 'var(--space-2) var(--space-3)',
borderBottom: '1px solid var(--border)',
marginBottom: 'var(--space-1)',
}}
>
<span
style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 600,
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Columns ({visibleCount}/{columns.length})
</span>
</div>
{columns.map((col, idx) => {
const def = LIST_COLUMNS.find((c) => c.key === col.key);
if (!def) return null;
return (
<div
key={col.key}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-1) var(--space-3)',
cursor: 'grab',
backgroundColor: dragIndex === idx ? 'var(--bg-hover)' : 'transparent',
transition: 'background-color var(--transition-fast)',
userSelect: 'none',
}}
>
<GripVertical
size={14}
style={{ color: 'var(--text-muted)', flexShrink: 0, opacity: 0.5 }}
/>
<button
onClick={() => toggleVisibility(col.key)}
title={col.visible ? `Hide ${def.label}` : `Show ${def.label}`}
aria-label={col.visible ? `Hide ${def.label} column` : `Show ${def.label} column`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: 'var(--radius-sm)',
color: col.visible ? 'var(--accent)' : 'var(--text-muted)',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'color var(--transition-fast)',
flexShrink: 0,
padding: 0,
}}
>
{col.visible ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<span
style={{
fontSize: 'var(--font-size-sm)',
color: col.visible ? 'var(--text-primary)' : 'var(--text-muted)',
flex: 1,
}}
>
{def.label}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -1,10 +1,37 @@
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, HardDriveDownload, Music } from 'lucide-react';
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { DownloadProgressBar } from './DownloadProgressBar';
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
import { formatDuration, formatRelativeTime } from '../utils/format';
import type { ContentItem } from '@shared/types/index';
// ── Helpers ──
function formatDuration(seconds: number | null): string {
if (seconds == null) 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')}`;
}
function formatRelativeTime(isoString: string | null): string {
if (!isoString) return '';
const delta = Date.now() - Date.parse(isoString);
if (delta < 0) return 'just now';
const seconds = Math.floor(delta / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
// ── Component ──
interface ContentCardProps {
@ -219,27 +246,6 @@ export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDow
</button>
)}
{item.status === 'downloaded' && item.filePath && (
<a
href={`/api/v1/content/${item.id}/download`}
onClick={(e) => e.stopPropagation()}
title="Save to device"
aria-label={`Save ${item.title} to device`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-muted)',
transition: 'color var(--transition-fast)',
}}
>
<HardDriveDownload size={14} />
</a>
)}
<a
href={item.url}
target="_blank"

View file

@ -1,344 +0,0 @@
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, HardDriveDownload, Music } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { RatingBadge } from './RatingBadge';
import { QualityLabel } from './QualityLabel';
import { DownloadProgressBar } from './DownloadProgressBar';
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
import { formatDuration, formatRelativeTime, formatFileSize } from '../utils/format';
import type { ContentItem } from '@shared/types/index';
import type { ColumnConfig } from './ColumnConfig';
// ── Component ──
interface ContentListItemProps {
item: ContentItem;
selected: boolean;
onSelect: (id: number) => void;
onToggleMonitored: (id: number, monitored: boolean) => void;
onDownload: (id: number) => void;
/** Optional column visibility/order config. If omitted, uses legacy hardcoded layout. */
columnConfig?: ColumnConfig[];
}
/** Check if a column key is visible given the config */
function isVisible(config: ColumnConfig[] | undefined, key: string): boolean {
if (!config) return true; // legacy: show all default fields
return config.some((c) => c.key === key && c.visible);
}
/** Get ordered visible column keys */
function visibleKeys(config: ColumnConfig[] | undefined): string[] {
if (!config) return ['thumbnail', 'publishedAt', 'duration', 'contentType'];
return config.filter((c) => c.visible).map((c) => c.key);
}
// ── Meta field renderers ──
function MetaSeparator() {
return <span style={{ opacity: 0.5 }}>·</span>;
}
function renderMetaField(item: ContentItem, key: string): React.ReactNode {
switch (key) {
case 'contentType':
return <span style={{ textTransform: 'capitalize' }}>{item.contentType}</span>;
case 'contentRating':
return item.contentRating ? <RatingBadge rating={item.contentRating} /> : null;
case 'quality':
return item.qualityMetadata ? <QualityLabel quality={item.qualityMetadata} /> : null;
case 'publishedAt':
return formatRelativeTime(item.publishedAt) ? <span>{formatRelativeTime(item.publishedAt)}</span> : null;
case 'downloadedAt':
return formatRelativeTime(item.downloadedAt) ? <span>{formatRelativeTime(item.downloadedAt)}</span> : null;
case 'duration': {
const d = formatDuration(item.duration);
return d ? <span style={{ fontVariantNumeric: 'tabular-nums' }}>{d}</span> : null;
}
case 'fileSize': {
const s = formatFileSize(item.fileSize);
return s ? <span>{s}</span> : null;
}
default:
return null;
}
}
export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload, columnConfig }: ContentListItemProps) {
const progress = useDownloadProgress(item.id);
const duration = formatDuration(item.duration);
const showThumbnail = isVisible(columnConfig, 'thumbnail');
const metaKeys = visibleKeys(columnConfig).filter((k) => k !== 'thumbnail');
return (
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-2)',
backgroundColor: selected ? 'var(--bg-selected)' : 'var(--bg-card-solid)',
border: selected ? '1px solid var(--accent)' : '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
transition: 'all var(--transition-fast)',
cursor: 'pointer',
minHeight: 56,
}}
onClick={() => onSelect(item.id)}
onMouseEnter={(e) => {
if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)';
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
if (cb) cb.style.opacity = '1';
}}
onMouseLeave={(e) => {
if (!selected) e.currentTarget.style.borderColor = 'var(--border)';
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
if (cb && !selected) cb.style.opacity = '0';
}}
>
{/* Selection checkbox */}
<div
className="list-checkbox"
style={{
flexShrink: 0,
opacity: selected ? 1 : 0,
transition: 'opacity var(--transition-fast)',
}}
>
<input
type="checkbox"
checked={selected}
onChange={(e) => {
e.stopPropagation();
onSelect(item.id);
}}
onClick={(e) => e.stopPropagation()}
aria-label={`Select ${item.title}`}
style={{
width: 16,
height: 16,
cursor: 'pointer',
accentColor: 'var(--accent)',
}}
/>
</div>
{/* Thumbnail */}
{showThumbnail && (
<div
style={{
position: 'relative',
flexShrink: 0,
width: 100,
aspectRatio: '16/9',
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
backgroundColor: 'var(--bg-input)',
}}
>
{item.thumbnailUrl ? (
<img
src={item.thumbnailUrl}
alt=""
loading="lazy"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
}}
>
{item.contentType === 'audio' ? <Music size={20} /> : <Film size={20} />}
</div>
)}
{/* Duration badge on thumbnail */}
{duration && (
<span
style={{
position: 'absolute',
bottom: 2,
right: 2,
padding: '0px 4px',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
color: '#fff',
fontSize: '10px',
fontWeight: 500,
fontVariantNumeric: 'tabular-nums',
lineHeight: '16px',
}}
>
{duration}
</span>
)}
{/* Download progress overlay */}
{item.status === 'downloading' && progress && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}>
<DownloadProgressBar progress={progress} />
</div>
)}
</div>
)}
{/* Info section */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Title */}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
fontWeight: 500,
fontSize: 'var(--font-size-sm)',
color: 'var(--text-primary)',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textDecoration: 'none',
}}
title={item.title}
>
{item.title}
</a>
{/* Meta row: dynamic columns */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
color: 'var(--text-muted)',
fontSize: 'var(--font-size-xs)',
}}
>
{metaKeys.reduce<React.ReactNode[]>((acc, key, i) => {
const node = renderMetaField(item, key);
if (node) {
if (acc.length > 0) {
acc.push(<MetaSeparator key={`sep-${key}`} />);
}
acc.push(<span key={key}>{node}</span>);
}
return acc;
}, [])}
</div>
</div>
{/* Right section: status badge + action buttons */}
<div
style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
}}
>
<StatusBadge status={item.status} />
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
<button
onClick={(e) => {
e.stopPropagation();
onToggleMonitored(item.id, !item.monitored);
}}
title={item.monitored ? 'Unmonitor' : 'Monitor'}
aria-label={item.monitored ? `Unmonitor ${item.title}` : `Monitor ${item.title}`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: item.monitored ? 'var(--accent)' : 'var(--text-muted)',
transition: 'color var(--transition-fast)',
}}
>
{item.monitored ? <Bookmark size={14} fill="currentColor" /> : <BookmarkPlus size={14} />}
</button>
{item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && (
<button
onClick={(e) => {
e.stopPropagation();
onDownload(item.id);
}}
title="Download"
aria-label={`Download ${item.title}`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-muted)',
transition: 'color var(--transition-fast)',
}}
>
<Download size={14} />
</button>
)}
{item.status === 'downloaded' && item.filePath && (
<a
href={`/api/v1/content/${item.id}/download`}
onClick={(e) => e.stopPropagation()}
title="Save to device"
aria-label={`Save ${item.title} to device`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-muted)',
transition: 'color var(--transition-fast)',
}}
>
<HardDriveDownload size={14} />
</a>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title="Open on YouTube"
aria-label={`Open ${item.title} on YouTube`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-muted)',
transition: 'color var(--transition-fast)',
}}
>
<ExternalLink size={14} />
</a>
</div>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, type FormEvent } from 'react';
import { useState, useCallback, type FormEvent } from 'react';
import { Loader } from 'lucide-react';
import type { FormatProfile } from '@shared/types/index';
@ -9,17 +9,6 @@ const CODEC_OPTIONS = ['Any', 'AAC', 'MP3', 'OPUS', 'FLAC'] as const;
const BITRATE_OPTIONS = ['Any', 'Best', '320k', '256k', '192k', '128k'] as const;
const CONTAINER_OPTIONS = ['Any', 'MP4', 'MKV', 'WEBM', 'MP3'] as const;
const SPONSORBLOCK_CATEGORIES = [
{ value: 'sponsor', label: 'Sponsor' },
{ value: 'selfpromo', label: 'Self-Promotion' },
{ value: 'interaction', label: 'Interaction' },
{ value: 'intro', label: 'Intro' },
{ value: 'outro', label: 'Outro' },
{ value: 'preview', label: 'Preview' },
{ value: 'music_offtopic', label: 'Music (Off-Topic)' },
{ value: 'filler', label: 'Filler' },
] as const;
// ── Types ──
export interface FormatProfileFormValues {
@ -31,10 +20,6 @@ export interface FormatProfileFormValues {
isDefault: boolean;
subtitleLanguages: string | null;
embedSubtitles: boolean;
embedChapters: boolean;
embedThumbnail: boolean;
sponsorBlockRemove: string | null;
outputTemplate: string | null;
}
interface FormatProfileFormProps {
@ -107,45 +92,6 @@ export function FormatProfileForm({
const [isDefault, setIsDefault] = useState(profile?.isDefault ?? false);
const [subtitleLanguages, setSubtitleLanguages] = useState(profile?.subtitleLanguages ?? '');
const [embedSubtitles, setEmbedSubtitles] = useState(profile?.embedSubtitles ?? false);
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
const [sponsorBlockCategories, setSponsorBlockCategories] = useState<Set<string>>(() => {
const raw = profile?.sponsorBlockRemove ?? '';
if (!raw.trim()) return new Set<string>();
return new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
});
const [outputTemplate, setOutputTemplate] = useState(profile?.outputTemplate ?? '');
const TEMPLATE_VARIABLES = ['platform', 'channel', 'title', 'date', 'year', 'month', 'contentType', 'id', 'ext'] as const;
const templatePreview = useMemo(() => {
if (!outputTemplate.trim()) return '';
const exampleVars: Record<string, string> = {
platform: 'youtube',
channel: 'TechChannel',
title: 'How to Build a Server',
date: '2026-04-04',
year: '2026',
month: '04',
contentType: 'video',
id: 'dQw4w9WgXcQ',
ext: 'mp4',
};
return outputTemplate.replace(/\{([a-zA-Z]+)\}/g, (_m, v: string) => exampleVars[v] ?? `{${v}}`);
}, [outputTemplate]);
const templateErrors = useMemo(() => {
if (!outputTemplate.trim()) return []; // empty = use system default
const errors: string[] = [];
if (!outputTemplate.includes('{ext}')) errors.push('Must contain {ext}');
const matches = [...outputTemplate.matchAll(/\{([a-zA-Z]+)\}/g)];
for (const m of matches) {
if (!(TEMPLATE_VARIABLES as readonly string[]).includes(m[1])) {
errors.push(`Unknown variable: {${m[1]}}`);
}
}
return errors;
}, [outputTemplate]);
const handleSubmit = useCallback(
(e: FormEvent) => {
@ -160,13 +106,9 @@ export function FormatProfileForm({
isDefault,
subtitleLanguages: subtitleLanguages.trim() || null,
embedSubtitles,
embedChapters,
embedThumbnail,
sponsorBlockRemove: sponsorBlockCategories.size > 0 ? [...sponsorBlockCategories].join(',') : null,
outputTemplate: outputTemplate.trim() || null,
});
},
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockCategories, outputTemplate, onSubmit],
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, onSubmit],
);
return (
@ -305,132 +247,6 @@ export function FormatProfileForm({
</label>
</div>
{/* Embed Chapters checkbox */}
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<input
id="fp-embed-chapters"
type="checkbox"
checked={embedChapters}
onChange={(e) => setEmbedChapters(e.target.checked)}
style={{
width: 16,
height: 16,
accentColor: 'var(--accent)',
cursor: 'pointer',
}}
/>
<label htmlFor="fp-embed-chapters" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
Embed chapter markers in downloaded files
</label>
</div>
{/* Embed Thumbnail checkbox */}
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<input
id="fp-embed-thumbnail"
type="checkbox"
checked={embedThumbnail}
onChange={(e) => setEmbedThumbnail(e.target.checked)}
style={{
width: 16,
height: 16,
accentColor: 'var(--accent)',
cursor: 'pointer',
}}
/>
<label htmlFor="fp-embed-thumbnail" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
Embed thumbnail as cover art
</label>
</div>
{/* SponsorBlock Remove — checkbox group */}
<div style={fieldGroupStyle}>
<label style={labelStyle}>
SponsorBlock Remove Segments
</label>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 'var(--space-2)',
padding: 'var(--space-3)',
backgroundColor: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
}}>
{SPONSORBLOCK_CATEGORIES.map(({ value, label }) => (
<label
key={value}
htmlFor={`fp-sb-${value}`}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
fontSize: 'var(--font-size-sm)',
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
<input
id={`fp-sb-${value}`}
type="checkbox"
checked={sponsorBlockCategories.has(value)}
onChange={(e) => {
setSponsorBlockCategories((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(value);
else next.delete(value);
return next;
});
}}
style={{
width: 16,
height: 16,
accentColor: 'var(--accent)',
cursor: 'pointer',
}}
/>
{label}
</label>
))}
</div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
Selected segments will be removed from downloaded videos using SponsorBlock data.
</span>
</div>
{/* Output Template (per-profile override) */}
<div style={fieldGroupStyle}>
<label htmlFor="fp-output-template" style={labelStyle}>
Output Template Override
</label>
<input
id="fp-output-template"
type="text"
value={outputTemplate}
onChange={(e) => setOutputTemplate(e.target.value)}
placeholder="Leave blank to use system default"
style={{
...inputStyle,
fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)',
fontSize: 'var(--font-size-sm)',
borderColor: templateErrors.length > 0 ? 'var(--danger)' : undefined,
}}
/>
{templateErrors.length > 0 && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--danger)', marginTop: 'var(--space-1)', display: 'block' }}>
{templateErrors.join('. ')}
</span>
)}
{templatePreview && templateErrors.length === 0 && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block', fontFamily: 'var(--font-mono, "SF Mono", "Fira Code", monospace)' }}>
Preview: {templatePreview}
</span>
)}
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
Variables: {'{platform}'}, {'{channel}'}, {'{title}'}, {'{date}'}, {'{year}'}, {'{month}'}, {'{contentType}'}, {'{id}'}, {'{ext}'}
</span>
</div>
{/* Is Default checkbox */}
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<input

View file

@ -1,10 +1,6 @@
import { useState } from 'react';
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
import { AlertTriangle, CheckCircle2, HardDrive, Play, Square, Terminal } from 'lucide-react';
import type { ComponentHealth } from '@shared/types/api';
import { formatBytes, formatLocalDateTime } from '../utils/format';
import { useTimezone } from '../hooks/useTimezone';
import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
import type { UseMutationResult } from '@tanstack/react-query';
import { formatBytes } from '../utils/format';
// ── Status → color mapping ──
@ -31,13 +27,9 @@ const COMPONENT_LABELS: Record<string, string> = {
interface HealthStatusProps {
components: ComponentHealth[];
overallStatus: 'healthy' | 'degraded' | 'unhealthy';
ytdlpStatus?: YtDlpStatusResponse | null;
ytdlpLoading?: boolean;
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
}
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
const timezone = useTimezone();
export function HealthStatus({ components, overallStatus }: HealthStatusProps) {
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
@ -82,13 +74,7 @@ export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoad
}}
>
{components.map((comp) => (
<ComponentCard
key={comp.name}
component={comp}
ytdlpStatus={comp.name === 'ytDlp' ? ytdlpStatus : undefined}
ytdlpLoading={comp.name === 'ytDlp' ? ytdlpLoading : undefined}
updateYtDlp={comp.name === 'ytDlp' ? updateYtDlp : undefined}
/>
<ComponentCard key={comp.name} component={comp} />
))}
</div>
@ -112,12 +98,7 @@ export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoad
// ── Component Card ──
function ComponentCard({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
component: ComponentHealth;
ytdlpStatus?: YtDlpStatusResponse | null;
ytdlpLoading?: boolean;
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
}) {
function ComponentCard({ component }: { component: ComponentHealth }) {
const colors = STATUS_COLORS[component.status] ?? DEFAULT_COLORS;
const label = COMPONENT_LABELS[component.name] ?? component.name;
@ -161,24 +142,20 @@ function ComponentCard({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
</span>
</div>
<ComponentDetail component={component} ytdlpStatus={ytdlpStatus} ytdlpLoading={ytdlpLoading} updateYtDlp={updateYtDlp} />
{/* Custom detail rendering per component type */}
<ComponentDetail component={component} />
</div>
);
}
// ── Detail Renderers ──
function ComponentDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
component: ComponentHealth;
ytdlpStatus?: YtDlpStatusResponse | null;
ytdlpLoading?: boolean;
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
}) {
function ComponentDetail({ component }: { component: ComponentHealth }) {
switch (component.name) {
case 'diskSpace':
return <DiskSpaceDetail component={component} />;
case 'ytDlp':
return <YtDlpDetail component={component} ytdlpStatus={ytdlpStatus} ytdlpLoading={ytdlpLoading} updateYtDlp={updateYtDlp} />;
return <YtDlpDetail component={component} />;
case 'scheduler':
return <SchedulerDetail component={component} />;
case 'recentErrors':
@ -256,101 +233,30 @@ function DiskSpaceDetail({ component }: { component: ComponentHealth }) {
// ── yt-dlp ──
function YtDlpDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
component: ComponentHealth;
ytdlpStatus?: YtDlpStatusResponse | null;
ytdlpLoading?: boolean;
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
}) {
const [updateMessage, setUpdateMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
function YtDlpDetail({ component }: { component: ComponentHealth }) {
const details = component.details as { version?: string } | undefined;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
{/* Version */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<Terminal size={14} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
{details?.version ? (
<span
style={{
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-mono)',
color: 'var(--text-secondary)',
padding: '1px var(--space-2)',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'var(--bg-hover)',
}}
>
v{details.version}
</span>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
<AlertTriangle size={12} aria-hidden="true" />
Not installed
</span>
)}
</div>
{/* Last Updated */}
{!ytdlpLoading && ytdlpStatus && (
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
Last checked: {ytdlpStatus.lastUpdated ? formatLocalDateTime(ytdlpStatus.lastUpdated, timezone) : 'Never'}
</div>
)}
{/* Update button */}
{updateYtDlp && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<button
className="btn btn-ghost"
style={{ fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)' }}
disabled={updateYtDlp.isPending}
onClick={() => {
setUpdateMessage(null);
updateYtDlp.mutate(undefined, {
onSuccess: (data) => {
setUpdateMessage({
type: 'success',
text: data.updated
? `Updated to ${data.version}`
: `Up to date`,
});
},
onError: (err) => {
setUpdateMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Update failed',
});
},
});
}}
>
{updateYtDlp.isPending ? (
<>
<Loader2 size={12} className="animate-spin" />
Checking
</>
) : (
<>
<RefreshCw size={12} />
Check for Updates
</>
)}
</button>
{updateMessage && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: 'var(--font-size-xs)',
color: updateMessage.type === 'success' ? 'var(--success)' : 'var(--danger)',
}}>
{updateMessage.type === 'success' ? <CheckCircle size={12} /> : <AlertCircle size={12} />}
{updateMessage.text}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<Terminal size={14} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
{details?.version ? (
<span
style={{
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-mono)',
color: 'var(--text-secondary)',
padding: '1px var(--space-2)',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'var(--bg-hover)',
}}
>
v{details.version}
</span>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
<AlertTriangle size={12} aria-hidden="true" />
Not installed
</span>
)}
</div>
);

View file

@ -1,399 +0,0 @@
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

@ -28,16 +28,9 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp
useEffect(() => {
if (open) {
previousFocus.current = document.activeElement as HTMLElement | null;
// Focus first input/textarea inside the modal, or fall back to the modal container
// Focus the modal container after render
requestAnimationFrame(() => {
const firstInput = modalRef.current?.querySelector<HTMLElement>(
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled])'
);
if (firstInput) {
firstInput.focus();
} else {
modalRef.current?.focus();
}
modalRef.current?.focus();
});
} else if (previousFocus.current) {
previousFocus.current.focus();

View file

@ -5,7 +5,6 @@ import type { Platform } from '@shared/types/index';
const PLATFORM_STYLES: Record<string, { color: string; label: string }> = {
youtube: { color: '#ff0000', label: 'YouTube' },
soundcloud: { color: '#ff7700', label: 'SoundCloud' },
generic: { color: '#6366f1', label: 'Generic' },
};
const DEFAULT_STYLE = { color: 'var(--text-secondary)', label: 'Unknown' };

View file

@ -14,8 +14,6 @@ export interface PlatformSettingsFormValues {
scanLimit: number;
rateLimitDelay: number;
defaultMonitoringMode: string;
nfoEnabled: boolean;
defaultView: 'list' | 'poster' | 'table';
}
interface PlatformSettingsFormProps {
@ -91,8 +89,6 @@ export function PlatformSettingsForm({
const [scanLimit, setScanLimit] = useState(settings?.scanLimit ?? 100);
const [rateLimitDelay, setRateLimitDelay] = useState(settings?.rateLimitDelay ?? 1000);
const [defaultMonitoringMode, setDefaultMonitoringMode] = useState(settings?.defaultMonitoringMode ?? 'all');
const [nfoEnabled, setNfoEnabled] = useState(settings?.nfoEnabled ?? false);
const [defaultView, setDefaultView] = useState<'list' | 'poster' | 'table'>(settings?.defaultView ?? 'list');
const handleSubmit = useCallback(
(e: FormEvent) => {
@ -107,14 +103,12 @@ export function PlatformSettingsForm({
scanLimit,
rateLimitDelay,
defaultMonitoringMode,
nfoEnabled,
defaultView,
});
},
[defaultFormatProfileId, checkInterval, concurrencyLimit, subtitleLanguages, grabAllEnabled, grabAllOrder, scanLimit, rateLimitDelay, defaultMonitoringMode, nfoEnabled, defaultView, onSubmit],
[defaultFormatProfileId, checkInterval, concurrencyLimit, subtitleLanguages, grabAllEnabled, grabAllOrder, scanLimit, rateLimitDelay, defaultMonitoringMode, onSubmit],
);
const platformLabel = platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : platform === 'generic' ? 'Generic' : platform;
const platformLabel = platform === 'youtube' ? 'YouTube' : platform === 'soundcloud' ? 'SoundCloud' : platform;
return (
<form onSubmit={handleSubmit}>
@ -286,41 +280,6 @@ export function PlatformSettingsForm({
<span style={hintStyle}>Default monitoring mode for new channels from this platform.</span>
</div>
{/* Default View */}
<div style={fieldGroupStyle}>
<label htmlFor="ps-default-view" style={labelStyle}>Default View</label>
<select
id="ps-default-view"
value={defaultView}
onChange={(e) => setDefaultView(e.target.value as 'list' | 'poster' | 'table')}
style={selectStyle}
>
<option value="list">List</option>
<option value="poster">Poster</option>
<option value="table">Table</option>
</select>
<span style={hintStyle}>Default content view when browsing channels on this platform.</span>
</div>
{/* NFO Enabled */}
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<input
id="ps-nfo-enabled"
type="checkbox"
checked={nfoEnabled}
onChange={(e) => setNfoEnabled(e.target.checked)}
style={{
width: 16,
height: 16,
accentColor: 'var(--accent)',
cursor: 'pointer',
}}
/>
<label htmlFor="ps-nfo-enabled" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
Generate NFO sidecar files
</label>
</div>
{/* Action buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', marginTop: 'var(--space-5)' }}>
<button

View file

@ -1,113 +0,0 @@
// ── 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

@ -11,7 +11,6 @@ import {
} from 'lucide-react';
import { useState, useEffect } from 'react';
import { TubearrLogo } from './TubearrLogo';
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
const NAV_ITEMS = [
{ to: '/', icon: Radio, label: 'Channels' },
@ -23,7 +22,6 @@ const NAV_ITEMS = [
] as const;
export function Sidebar() {
const wsConnected = useDownloadProgressConnection();
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
@ -128,42 +126,6 @@ export function Sidebar() {
</NavLink>
))}
</div>
{/* WebSocket connection status */}
<div
style={{
padding: collapsed ? 'var(--space-3)' : 'var(--space-3) var(--space-4)',
borderTop: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
title={wsConnected ? 'WebSocket connected' : 'WebSocket disconnected'}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: wsConnected ? 'var(--success)' : 'var(--text-muted)',
flexShrink: 0,
transition: 'background-color var(--transition-fast)',
}}
/>
{!collapsed && (
<span
style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--text-muted)',
whiteSpace: 'nowrap',
}}
>
{wsConnected ? 'Connected' : 'Disconnected'}
</span>
)}
</div>
</nav>
);
}

View file

@ -62,27 +62,20 @@ export function SkeletonChannelHeader() {
return (
<div
style={{
display: 'flex',
gap: 'var(--space-5)',
padding: 'var(--space-5)',
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
marginBottom: 'var(--space-6)',
overflow: 'hidden',
}}
>
{/* Banner placeholder */}
<Skeleton width="100%" height={160} borderRadius="0" />
{/* Identity + controls */}
<div style={{ padding: 'var(--space-5)', paddingTop: 'var(--space-4)' }}>
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center', marginBottom: 'var(--space-4)' }}>
<Skeleton width={64} height={64} borderRadius="50%" style={{ marginTop: -32, border: '3px solid var(--bg-card)', flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<Skeleton width={200} height={24} />
<Skeleton width={120} height={14} />
</div>
</div>
<Skeleton width="80%" height={14} style={{ marginBottom: 'var(--space-2)' }} />
<Skeleton width="50%" height={14} style={{ marginBottom: 'var(--space-4)' }} />
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
<Skeleton width={80} height={80} borderRadius="50%" />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<Skeleton width={200} height={24} />
<Skeleton width={300} height={14} />
<div style={{ display: 'flex', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
<Skeleton width={100} height={32} borderRadius="var(--radius-md)" />

View file

@ -1,155 +0,0 @@
import { ArrowDown, ArrowUp } from 'lucide-react';
export type SortKey = 'publishedAt' | 'title' | 'duration' | 'fileSize' | 'status';
export type GroupByKey = 'none' | 'playlist' | 'year' | 'type';
interface SortButton {
key: SortKey;
label: string;
}
const SORT_BUTTONS: SortButton[] = [
{ key: 'publishedAt', label: 'Date' },
{ key: 'title', label: 'Title' },
{ key: 'duration', label: 'Duration' },
{ key: 'fileSize', label: 'Size' },
{ key: 'status', label: 'Status' },
];
const GROUP_BY_OPTIONS: { value: GroupByKey; label: string; youtubeOnly?: boolean }[] = [
{ value: 'none', label: 'No Grouping' },
{ value: 'playlist', label: 'Playlist', youtubeOnly: true },
{ value: 'year', label: 'Year' },
{ value: 'type', label: 'Type' },
];
interface SortGroupBarProps {
sortKey: string | null;
sortDirection: 'asc' | 'desc';
onSort: (key: string, direction: 'asc' | 'desc') => void;
groupBy: GroupByKey;
onGroupByChange: (groupBy: GroupByKey) => void;
isYouTube: boolean;
}
export function SortGroupBar({
sortKey,
sortDirection,
onSort,
groupBy,
onGroupByChange,
isYouTube,
}: SortGroupBarProps) {
const handleSortClick = (key: SortKey) => {
if (sortKey === key) {
// Toggle direction
onSort(key, sortDirection === 'asc' ? 'desc' : 'asc');
} else {
// New sort key — default to descending
onSort(key, 'desc');
}
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-5)',
borderBottom: '1px solid var(--border)',
flexWrap: 'wrap',
}}
>
{/* Sort label */}
<span
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginRight: 'var(--space-1)',
flexShrink: 0,
}}
>
Sort
</span>
{/* Sort buttons */}
{SORT_BUTTONS.map((btn) => {
const isActive = sortKey === btn.key;
return (
<button
key={btn.key}
onClick={() => handleSortClick(btn.key)}
aria-label={`Sort by ${btn.label}${isActive ? `, currently ${sortDirection === 'asc' ? 'ascending' : 'descending'}` : ''}`}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: 'var(--space-1) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
fontWeight: isActive ? 600 : 400,
backgroundColor: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? '#fff' : 'var(--text-secondary)',
border: isActive ? 'none' : '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
lineHeight: 1.4,
}}
>
{btn.label}
{isActive && (
sortDirection === 'asc'
? <ArrowUp size={12} />
: <ArrowDown size={12} />
)}
</button>
);
})}
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Group by */}
<span
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginRight: 'var(--space-1)',
flexShrink: 0,
}}
>
Group
</span>
<select
value={groupBy}
onChange={(e) => onGroupByChange(e.target.value as GroupByKey)}
aria-label="Group by"
style={{
padding: 'var(--space-1) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 110,
backgroundColor: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
>
{GROUP_BY_OPTIONS.filter(
(opt) => !opt.youtubeOnly || isYouTube,
).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}

View file

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

View file

@ -75,7 +75,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
boxShadow: 'var(--shadow-lg)',
pointerEvents: 'auto',
cursor: 'pointer',
backgroundColor: t.variant === 'error' ? 'var(--toast-danger-bg)' : t.variant === 'success' ? 'var(--toast-success-bg)' : 'var(--toast-info-bg)',
backgroundColor: t.variant === 'error' ? 'var(--danger-bg)' : t.variant === 'success' ? 'var(--success-bg)' : 'var(--bg-card)',
border: `1px solid ${t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--border)'}`,
color: t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--text-primary)',
animation: 'toast-slide-in 0.25s ease-out',

View file

@ -1,10 +1,7 @@
import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react';
import { useQueryClient, type QueryClient } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useSyncExternalStore } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { contentKeys } from '../api/hooks/useContent';
import type { ContentItem } from '@shared/types/index';
import type { PaginatedResponse } from '@shared/types/api';
// ── Types ──
@ -35,39 +32,7 @@ interface DownloadFailedEvent {
type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent;
// ── Scan Event Types ──
interface ScanStartedEvent {
type: 'scan:started';
channelId: number;
channelName: string;
}
interface ScanItemDiscoveredEvent {
type: 'scan:item-discovered';
channelId: number;
channelName: string;
item: ContentItem;
}
interface ScanCompleteEvent {
type: 'scan:complete';
channelId: number;
channelName: string;
newItems: number;
totalFetched: number;
}
interface ScanErrorEvent {
type: 'scan:error';
channelId: number;
channelName: string;
error: string;
}
type ScanEvent = ScanStartedEvent | ScanItemDiscoveredEvent | ScanCompleteEvent | ScanErrorEvent;
// ── Download Progress Store (external to React for zero unnecessary re-renders) ──
// ── Store (external to React for zero unnecessary re-renders) ──
class ProgressStore {
private _map = new Map<number, ProgressInfo>();
@ -98,58 +63,6 @@ class ProgressStore {
}
}
// ── Scan Progress Store ──
export interface ScanProgress {
scanning: boolean;
newItemCount: number;
}
class ScanStore {
private _map = new Map<number, ScanProgress>();
private _listeners = new Set<() => void>();
subscribe = (listener: () => void) => {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
};
getSnapshot = () => this._map;
startScan(channelId: number) {
this._map = new Map(this._map);
this._map.set(channelId, { scanning: true, newItemCount: 0 });
this._notify();
}
incrementItems(channelId: number) {
this._map = new Map(this._map);
const current = this._map.get(channelId) ?? { scanning: true, newItemCount: 0 };
this._map.set(channelId, { ...current, newItemCount: current.newItemCount + 1 });
this._notify();
}
completeScan(channelId: number) {
this._map = new Map(this._map);
const current = this._map.get(channelId);
if (current) {
this._map.set(channelId, { scanning: false, newItemCount: current.newItemCount });
}
this._notify();
}
clearScan(channelId: number) {
if (!this._map.has(channelId)) return;
this._map = new Map(this._map);
this._map.delete(channelId);
this._notify();
}
private _notify() {
for (const listener of this._listeners) listener();
}
}
// ── Context ──
interface DownloadProgressContextValue {
@ -157,12 +70,6 @@ interface DownloadProgressContextValue {
getProgress: (contentItemId: number) => ProgressInfo | undefined;
/** Whether the WebSocket is connected */
isConnected: boolean;
/** Subscribe to scan store changes */
scanStoreSubscribe: (listener: () => void) => () => void;
/** Get scan store snapshot */
scanStoreGetSnapshot: () => Map<number, ScanProgress>;
/** Clear scan state for a channel (optimistic update) */
clearScan: (channelId: number) => void;
}
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null);
@ -173,15 +80,13 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
const queryClient = useQueryClient();
const storeRef = useRef(new ProgressStore());
const store = storeRef.current;
const scanStoreRef = useRef(new ScanStore());
const scanStore = scanStoreRef.current;
// Subscribe to the store with useSyncExternalStore for optimal re-renders
const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot);
const handleMessage = useCallback(
(data: unknown) => {
const event = data as DownloadEvent | ScanEvent;
const event = data as DownloadEvent;
if (!event?.type) return;
switch (event.type) {
@ -195,12 +100,9 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
case 'download:complete':
store.delete(event.contentItemId);
// Invalidate queries so the UI refreshes with updated status
// Invalidate content queries so the UI refreshes with updated status
queryClient.invalidateQueries({ queryKey: ['content'] });
queryClient.invalidateQueries({ queryKey: ['queue'] });
queryClient.invalidateQueries({ queryKey: ['activity'] });
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['library'] });
break;
case 'download:failed':
@ -208,34 +110,10 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
// Invalidate to show updated status (failed)
queryClient.invalidateQueries({ queryKey: ['content'] });
queryClient.invalidateQueries({ queryKey: ['queue'] });
queryClient.invalidateQueries({ queryKey: ['activity'] });
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['library'] });
break;
case 'scan:started':
scanStore.startScan(event.channelId);
break;
case 'scan:item-discovered':
scanStore.incrementItems(event.channelId);
injectContentItemIntoCache(queryClient, event.channelId, event.item);
break;
case 'scan:complete':
scanStore.completeScan(event.channelId);
// Safety net: reconcile any missed items
queryClient.invalidateQueries({
queryKey: contentKeys.byChannel(event.channelId),
});
break;
case 'scan:error':
scanStore.completeScan(event.channelId);
break;
}
},
[store, scanStore, queryClient],
[store, queryClient],
);
const { isConnected } = useWebSocket({ onMessage: handleMessage });
@ -248,15 +126,7 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
);
return (
<DownloadProgressContext.Provider
value={{
getProgress,
isConnected,
scanStoreSubscribe: scanStore.subscribe,
scanStoreGetSnapshot: scanStore.getSnapshot,
clearScan: scanStore.clearScan.bind(scanStore),
}}
>
<DownloadProgressContext.Provider value={{ getProgress, isConnected }}>
{children}
</DownloadProgressContext.Provider>
);
@ -283,58 +153,3 @@ export function useDownloadProgressConnection(): boolean {
const context = useContext(DownloadProgressContext);
return context?.isConnected ?? false;
}
// ── Scan Progress Hook ──
/**
* Get scan progress for a specific channel.
* Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore.
* Only re-renders components that use this hook when the scan store changes.
*/
export function useScanProgress(channelId: number): ScanProgress & { clearScan: () => void } {
const context = useContext(DownloadProgressContext);
if (!context) {
throw new Error('useScanProgress must be used within a DownloadProgressProvider');
}
const scanMap = useSyncExternalStore(
context.scanStoreSubscribe,
context.scanStoreGetSnapshot,
);
return {
scanning: scanMap.get(channelId)?.scanning ?? false,
newItemCount: scanMap.get(channelId)?.newItemCount ?? 0,
clearScan: () => context.clearScan(channelId),
};
}
// ── Cache Injection Helper ──
/**
* Inject a newly discovered content item into all matching TanStack Query caches
* for the given channel. Prepends the item to page 1 queries and increments pagination counts.
*/
function injectContentItemIntoCache(
queryClient: QueryClient,
channelId: number,
item: ContentItem,
) {
queryClient.setQueriesData<PaginatedResponse<ContentItem>>(
{ queryKey: contentKeys.byChannel(channelId) },
(oldData) => {
if (!oldData?.data) return oldData;
// Avoid duplicates
if (oldData.data.some((existing) => existing.id === item.id)) return oldData;
return {
...oldData,
data: [item, ...oldData.data],
pagination: {
...oldData.pagination,
totalItems: oldData.pagination.totalItems + 1,
totalPages: Math.ceil(
(oldData.pagination.totalItems + 1) / oldData.pagination.pageSize,
),
},
};
},
);
}

View file

@ -1,28 +0,0 @@
import { useState, useCallback } from 'react';
/**
* Manages a set of selected IDs with toggle, select-all, and clear operations.
*/
export function useBulkSelection(allIds: number[]) {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const toggleSelect = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
const toggleSelectAll = useCallback(() => {
if (allIds.length === 0) return;
setSelectedIds((prev) => prev.size === allIds.length ? new Set() : new Set(allIds));
}, [allIds]);
const isAllSelected = allIds.length > 0 && selectedIds.size === allIds.length;
return { selectedIds, toggleSelect, clearSelection, toggleSelectAll, isAllSelected };
}

View file

@ -1,32 +0,0 @@
import { useState, useCallback } from 'react';
/**
* useState backed by localStorage. Reads initial value from storage,
* writes on every update. Swallows storage errors silently.
*/
export function usePersistedState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [state, setState] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
if (stored === null) return defaultValue;
return JSON.parse(stored) as T;
} catch {
return defaultValue;
}
});
const setPersistedState = useCallback(
(value: T | ((prev: T) => T)) => {
setState((prev) => {
const next = typeof value === 'function' ? (value as (prev: T) => T)(prev) : value;
try {
localStorage.setItem(key, JSON.stringify(next));
} catch { /* storage full or unavailable */ }
return next;
});
},
[key],
);
return [state, setPersistedState];
}

View file

@ -1,21 +0,0 @@
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

@ -1,13 +0,0 @@
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,10 +6,33 @@ import { Pagination } from '../components/Pagination';
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
import { SkeletonActivityList } from '../components/Skeleton';
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
import { formatRelativeTime, formatTimestamp } from '../utils/format';
import { useTimezone } from '../hooks/useTimezone';
import type { DownloadHistoryRecord } from '@shared/types/index';
// ── Helpers ──
function formatTimestamp(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatRelativeTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
if (diffMs < 60_000) return 'just now';
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`;
if (diffMs < 604800_000) return `${Math.floor(diffMs / 86400_000)}d ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function formatEventType(type: string): string {
return type
.split('_')
@ -46,7 +69,6 @@ const EVENT_TYPES = [
// ── Component ──
export function ActivityPage() {
const timezone = useTimezone();
const [activeTab, setActiveTab] = useState<'history' | 'recent'>('history');
const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState<Record<string, string>>({
@ -165,7 +187,7 @@ export function ActivityPage() {
fontFamily: 'var(--font-mono)',
}}
>
{formatTimestamp(item.createdAt, timezone)}
{formatTimestamp(item.createdAt)}
</span>
),
},

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Plus, Loader, RefreshCw, Download, Link2, Search } from 'lucide-react';
import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
import { useCollectAllMonitored } from '../api/hooks/useContent';
import { Table, type Column } from '../components/Table';
@ -8,45 +8,30 @@ import { PlatformBadge } from '../components/PlatformBadge';
import { StatusBadge } from '../components/StatusBadge';
import { ProgressBar } from '../components/ProgressBar';
import { AddChannelModal } from '../components/AddChannelModal';
import { AddUrlModal } from '../components/AddUrlModal';
import { SkeletonChannelsList } from '../components/Skeleton';
import { useToast } from '../components/Toast';
import { formatRelativeTime } from '../utils/format';
import type { ChannelWithCounts } from '@shared/types/api';
// ── Channel list sort/group types ──
// ── Helpers ──
type ChannelSortKey = 'name' | 'platform' | 'lastCheckedAt' | 'contentCount';
type ChannelGroupBy = 'none' | 'platform';
interface ChannelSortButton {
key: ChannelSortKey;
label: string;
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return '—';
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const CHANNEL_SORT_BUTTONS: ChannelSortButton[] = [
{ key: 'name', label: 'Name' },
{ key: 'platform', label: 'Platform' },
{ key: 'lastCheckedAt', label: 'Last Checked' },
{ key: 'contentCount', label: 'Content Count' },
];
const CHANNEL_GROUP_OPTIONS: { value: ChannelGroupBy; label: string }[] = [
{ value: 'none', label: 'No Grouping' },
{ value: 'platform', label: 'Platform' },
];
// ── Component ──
export function Channels() {
const navigate = useNavigate();
const [showAddModal, setShowAddModal] = useState(false);
const [showAddUrl, setShowAddUrl] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortKey, setSortKey] = useState<ChannelSortKey | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [groupBy, setGroupBy] = useState<ChannelGroupBy>('none');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const { toast } = useToast();
const { data: channels, isLoading, error, refetch } = useChannels();
@ -91,97 +76,6 @@ export function Channels() {
[navigate],
);
// ── Sort handler ──
const handleSortClick = useCallback((key: ChannelSortKey) => {
setSortKey((prev) => {
if (prev === key) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
return key;
}
setSortDirection('asc');
return key;
});
}, []);
// ── Toggle group expand/collapse ──
const toggleGroup = useCallback((id: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// ── Filtered, sorted, grouped channels ──
const filteredChannels = useMemo(() => {
let result = channels ?? [];
// Text search — case-insensitive on channel name
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
result = result.filter((c) => c.name.toLowerCase().includes(q));
}
// Sort
if (sortKey) {
result = [...result].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'platform':
cmp = a.platform.localeCompare(b.platform);
break;
case 'lastCheckedAt': {
const aTime = a.lastCheckedAt ? new Date(a.lastCheckedAt).getTime() : 0;
const bTime = b.lastCheckedAt ? new Date(b.lastCheckedAt).getTime() : 0;
cmp = aTime - bTime;
break;
}
case 'contentCount': {
const aCount = a.contentCounts?.total ?? 0;
const bCount = b.contentCounts?.total ?? 0;
cmp = aCount - bCount;
break;
}
}
return sortDirection === 'asc' ? cmp : -cmp;
});
}
return result;
}, [channels, searchQuery, sortKey, sortDirection]);
const groupedChannels = useMemo<{ id: string; title: string; platform?: string; items: ChannelWithCounts[] }[] | null>(() => {
if (groupBy === 'none') return null;
// Group by platform
const platformMap = new Map<string, ChannelWithCounts[]>();
for (const ch of filteredChannels) {
const key = ch.platform;
const arr = platformMap.get(key);
if (arr) {
arr.push(ch);
} else {
platformMap.set(key, [ch]);
}
}
return Array.from(platformMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([platform, items]) => ({
id: platform,
title: platform.charAt(0).toUpperCase() + platform.slice(1),
platform,
items,
}));
}, [groupBy, filteredChannels]);
const columns = useMemo<Column<ChannelWithCounts>[]>(
() => [
{
@ -360,19 +254,10 @@ export function Channels() {
{collectAll.isPending ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Download size={16} />
<Search size={16} />
)}
{collectAll.isPending ? 'Collecting...' : 'Collect All Monitored'}
</button>
{/* Add URL button */}
<button
onClick={() => setShowAddUrl(true)}
className="btn btn-ghost"
title="Download URL"
>
<Link2 size={16} />
Add URL
</button>
{/* Add Channel button */}
<button
onClick={() => setShowAddModal(true)}
@ -386,241 +271,26 @@ export function Channels() {
</div>
</div>
{/* Filter bar */}
{/* Channel table */}
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
marginBottom: 'var(--space-4)',
overflow: 'hidden',
}}
>
{/* Search row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-5)',
borderBottom: '1px solid var(--border)',
}}
>
<Search size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search channels..."
aria-label="Search channels by name"
style={{
flex: 1,
padding: 'var(--space-1) var(--space-2)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-input)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
outline: 'none',
}}
/>
</div>
{/* Sort + Group row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-5)',
flexWrap: 'wrap',
}}
>
{/* Sort label */}
<span
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginRight: 'var(--space-1)',
flexShrink: 0,
}}
>
Sort
</span>
{/* Sort buttons */}
{CHANNEL_SORT_BUTTONS.map((btn) => {
const isActive = sortKey === btn.key;
return (
<button
key={btn.key}
onClick={() => handleSortClick(btn.key)}
aria-label={`Sort by ${btn.label}${isActive ? `, currently ${sortDirection === 'asc' ? 'ascending' : 'descending'}` : ''}`}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: 'var(--space-1) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
fontWeight: isActive ? 600 : 400,
backgroundColor: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? '#fff' : 'var(--text-secondary)',
border: isActive ? 'none' : '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
lineHeight: 1.4,
}}
>
{btn.label}
{isActive && (
sortDirection === 'asc'
? <ArrowUp size={12} />
: <ArrowDown size={12} />
)}
</button>
);
})}
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Group by */}
<span
style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginRight: 'var(--space-1)',
flexShrink: 0,
}}
>
Group
</span>
<select
value={groupBy}
onChange={(e) => {
setGroupBy(e.target.value as ChannelGroupBy);
setExpandedGroups(new Set());
}}
aria-label="Group channels by"
style={{
padding: 'var(--space-1) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 110,
backgroundColor: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
>
{CHANNEL_GROUP_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<Table
columns={columns}
data={channels ?? []}
keyExtractor={(c) => c.id}
onRowClick={handleRowClick}
emptyMessage="No channels added yet. Add a YouTube channel or SoundCloud artist to get started."
/>
</div>
{/* Channel table / grouped view */}
{groupedChannels ? (
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
>
{groupedChannels.length === 0 ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--text-muted)' }}>
No channels match your search.
</div>
) : (
groupedChannels.map((group) => {
const isExpanded = expandedGroups.has(group.id);
return (
<div key={group.id} style={{ borderBottom: '1px solid var(--border)' }}>
<button
onClick={() => toggleGroup(group.id)}
aria-expanded={isExpanded}
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
width: '100%',
padding: 'var(--space-3) var(--space-5)',
backgroundColor: 'var(--bg-hover)',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
transition: 'background-color var(--transition-fast)',
}}
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{group.platform && <PlatformBadge platform={group.platform as ChannelWithCounts['platform']} />}
<span style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: 'var(--font-size-sm)' }}>
{group.title}
</span>
<span
style={{
padding: '1px 8px',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'var(--bg-card)',
color: 'var(--text-secondary)',
fontSize: 'var(--font-size-xs)',
fontWeight: 500,
border: '1px solid var(--border)',
}}
>
{group.items.length}
</span>
</button>
{isExpanded && (
<Table
columns={columns}
data={group.items}
keyExtractor={(c) => c.id}
onRowClick={handleRowClick}
emptyMessage="No channels in this group."
/>
)}
</div>
);
})
)}
</div>
) : (
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
>
<Table
columns={columns}
data={filteredChannels}
keyExtractor={(c) => c.id}
onRowClick={handleRowClick}
emptyMessage={searchQuery.trim() ? 'No channels match your search.' : 'No channels added yet. Add a YouTube channel or SoundCloud artist to get started.'}
/>
</div>
)}
{/* Add Channel modal */}
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
{/* Add URL modal */}
<AddUrlModal open={showAddUrl} onClose={() => setShowAddUrl(false)} />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more