Compare commits
No commits in common. "master" and "milestone/M008-performance-polish-downloads" have entirely different histories.
master
...
milestone/
143 changed files with 1096 additions and 22867 deletions
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -1 +0,0 @@
|
|||
blank_issues_enabled: true
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
18
.mcp.json
18
.mcp.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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?
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE `queue_items` ADD `error_category` text;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE `format_profiles` ADD `output_template` text;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE `channels` ADD `include_keywords` text;--> statement-breakpoint
|
||||
ALTER TABLE `channels` ADD `exclude_keywords` text;
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE `channels` ADD `content_rating` text;--> statement-breakpoint
|
||||
ALTER TABLE `content_items` ADD `content_rating` text;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('<tags>');
|
||||
expect(res.body).toContain('&');
|
||||
expect(res.body).toContain('"quotes"');
|
||||
expect(res.body).not.toContain('<tags>');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Media Serving Tests ──
|
||||
|
||||
describe('GET /api/v1/media/:id/:filename', () => {
|
||||
it('serves a downloaded media file', async () => {
|
||||
// Create a real file on disk
|
||||
const subDir = join(mediaDir, 'test-channel');
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
const filePath = join(subDir, 'served-file.mp3');
|
||||
const fileContent = Buffer.alloc(1024, 0xff); // 1KB dummy audio
|
||||
writeFileSync(filePath, fileContent);
|
||||
|
||||
const item = await createTestContent({
|
||||
title: 'Served Audio',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
fileSize: 1024,
|
||||
});
|
||||
// Update filePath to the absolute path
|
||||
await updateContentItem(db, item.id, { filePath });
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/served-file.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('audio/mpeg');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(res.rawPayload.length).toBe(1024);
|
||||
});
|
||||
|
||||
it('does not require authentication', async () => {
|
||||
// Create a file
|
||||
const subDir = join(mediaDir, 'noauth-test');
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
const filePath = join(subDir, 'noauth.mp3');
|
||||
writeFileSync(filePath, Buffer.alloc(512));
|
||||
|
||||
const item = await createTestContent({
|
||||
title: 'No Auth Test',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
});
|
||||
await updateContentItem(db, item.id, { filePath });
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/noauth.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent content item', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/media/99999/nothing.mp3',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid ID', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/media/abc/nothing.mp3',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for content item without downloaded file', async () => {
|
||||
const item = await createTestContent({
|
||||
title: 'Not Downloaded',
|
||||
contentType: 'audio',
|
||||
// no status: 'downloaded' update
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/something.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when file is missing from disk', async () => {
|
||||
const item = await createTestContent({
|
||||
title: 'File Missing From Disk',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
});
|
||||
// Set filePath to a non-existent file
|
||||
await updateContentItem(db, item.id, {
|
||||
filePath: '/tmp/nonexistent-file-abc123.mp3',
|
||||
});
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/missing.mp3`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().message).toContain('not found on disk');
|
||||
});
|
||||
|
||||
it('supports range requests for seeking', async () => {
|
||||
const subDir = join(mediaDir, 'range-test');
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
const filePath = join(subDir, 'range.mp3');
|
||||
const content = Buffer.alloc(2048, 0xab);
|
||||
writeFileSync(filePath, content);
|
||||
|
||||
const item = await createTestContent({
|
||||
title: 'Range Test Audio',
|
||||
status: 'downloaded',
|
||||
format: 'mp3',
|
||||
fileSize: 2048,
|
||||
});
|
||||
await updateContentItem(db, item.id, { filePath });
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/media/${item.id}/range.mp3`,
|
||||
headers: { range: 'bytes=0-511' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 0-511/2048');
|
||||
expect(res.rawPayload.length).toBe(512);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
|||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { FileOrganizer, 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 & Jerry <"Special"></title>');
|
||||
});
|
||||
|
||||
it('uses Music genre for SoundCloud', () => {
|
||||
const item = makeContentItem();
|
||||
const channel = makeChannel({ platform: 'soundcloud' });
|
||||
const nfo = generateNfo(item, channel);
|
||||
|
||||
expect(nfo).toContain('<genre>Music</genre>');
|
||||
});
|
||||
|
||||
it('uses Online Media genre for generic platform', () => {
|
||||
const item = makeContentItem();
|
||||
const channel = makeChannel({ platform: 'generic' });
|
||||
const nfo = generateNfo(item, channel);
|
||||
|
||||
expect(nfo).toContain('<genre>Online Media</genre>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nfoPathForMedia', () => {
|
||||
it('replaces .mp4 with .nfo', () => {
|
||||
expect(nfoPathForMedia('/media/youtube/chan/video.mp4')).toBe(
|
||||
'/media/youtube/chan/video.nfo'
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces .webm with .nfo', () => {
|
||||
expect(nfoPathForMedia('/media/youtube/chan/video.webm')).toBe(
|
||||
'/media/youtube/chan/video.nfo'
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces .opus with .nfo', () => {
|
||||
expect(nfoPathForMedia('/media/soundcloud/artist/track.opus')).toBe(
|
||||
'/media/soundcloud/artist/track.nfo'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles files with dots in the name', () => {
|
||||
expect(nfoPathForMedia('/media/youtube/chan/video.v2.final.mp4')).toBe(
|
||||
'/media/youtube/chan/video.v2.final.nfo'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeNfoFile', () => {
|
||||
it('writes .nfo file alongside media file', async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-'));
|
||||
const mediaPath = join(tmpDir, 'youtube', 'TestChannel', 'video.mp4');
|
||||
const nfoContent = '<episodedetails><title>Test</title></episodedetails>';
|
||||
|
||||
const writtenPath = await writeNfoFile(nfoContent, mediaPath);
|
||||
|
||||
expect(writtenPath).toBe(join(tmpDir, 'youtube', 'TestChannel', 'video.nfo'));
|
||||
expect(existsSync(writtenPath)).toBe(true);
|
||||
expect(readFileSync(writtenPath, 'utf-8')).toBe(nfoContent);
|
||||
});
|
||||
|
||||
it('creates parent directories if needed', async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-'));
|
||||
const mediaPath = join(tmpDir, 'deep', 'nested', 'dir', 'video.mp4');
|
||||
const nfoContent = '<episodedetails><title>Test</title></episodedetails>';
|
||||
|
||||
const writtenPath = await writeNfoFile(nfoContent, mediaPath);
|
||||
|
||||
expect(existsSync(writtenPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('overwrites existing .nfo file', async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-'));
|
||||
const mediaPath = join(tmpDir, 'video.mp4');
|
||||
|
||||
await writeNfoFile('old content', mediaPath);
|
||||
await writeNfoFile('new content', mediaPath);
|
||||
|
||||
const nfoPath = join(tmpDir, 'video.nfo');
|
||||
expect(readFileSync(nfoPath, 'utf-8')).toBe('new content');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -145,7 +145,7 @@ describe('Platform Settings API', () => {
|
|||
// Defaults
|
||||
expect(body.grabAllEnabled).toBe(false);
|
||||
expect(body.grabAllOrder).toBe('newest');
|
||||
expect(body.scanLimit).toBe(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', () => {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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() ──
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'))`),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'))`),
|
||||
});
|
||||
|
|
@ -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'))`),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
});
|
||||
}
|
||||
|
|
@ -41,8 +41,6 @@ export interface UpdatePlatformSettingsInput {
|
|||
scanLimit?: number;
|
||||
rateLimitDelay?: number;
|
||||
defaultMonitoringMode?: 'all' | 'future' | 'existing' | 'none';
|
||||
nfoEnabled?: boolean;
|
||||
defaultView?: 'list' | 'poster' | 'table';
|
||||
}
|
||||
|
||||
// ── Mutations ──
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
Loading…
Add table
Reference in a new issue