Compare commits
No commits in common. "21a458f5001855e432708fd359f6e7cc37b7a556" and "1dd1e992c6aaa291c1df31449efc56d71d123aa0" have entirely different histories.
21a458f500
...
1dd1e992c6
75 changed files with 1810 additions and 6250 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -52,4 +52,3 @@ venv/
|
||||||
target/
|
target/
|
||||||
vendor/
|
vendor/
|
||||||
config/
|
config/
|
||||||
media/
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -6,7 +6,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8989:8989"
|
- "8989:8989"
|
||||||
volumes:
|
volumes:
|
||||||
- tubearr-config:/config
|
- ./config:/config
|
||||||
- ./media:/media
|
- ./media:/media
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
|
@ -18,6 +18,3 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
tubearr-config:
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
ALTER TABLE format_profiles ADD COLUMN embed_thumbnail INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE format_profiles ADD COLUMN sponsor_block_remove TEXT; -- comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
|
|
||||||
|
|
@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -64,27 +64,6 @@
|
||||||
"when": 1774839000000,
|
"when": 1774839000000,
|
||||||
"tag": "0008_add_default_monitoring_mode",
|
"tag": "0008_add_default_monitoring_mode",
|
||||||
"breakpoints": true
|
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -334,7 +334,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: 'mkv',
|
containerFormat: 'mkv',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -344,7 +344,7 @@ describe('DownloadService', () => {
|
||||||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
expect(args).toContain('-f');
|
expect(args).toContain('-f');
|
||||||
const fIdx = args.indexOf('-f');
|
const fIdx = args.indexOf('-f');
|
||||||
expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]*+bestaudio/best[height<=1080]/bestvideo+bestaudio/best');
|
expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/best[height<=1080]');
|
||||||
expect(args).toContain('--merge-output-format');
|
expect(args).toContain('--merge-output-format');
|
||||||
const moIdx = args.indexOf('--merge-output-format');
|
const moIdx = args.indexOf('--merge-output-format');
|
||||||
expect(args[moIdx + 1]).toBe('mkv');
|
expect(args[moIdx + 1]).toBe('mkv');
|
||||||
|
|
@ -388,7 +388,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -431,7 +431,7 @@ describe('DownloadService', () => {
|
||||||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
expect(args).toContain('-f');
|
expect(args).toContain('-f');
|
||||||
const fIdx = args.indexOf('-f');
|
const fIdx = args.indexOf('-f');
|
||||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
|
expect(args[fIdx + 1]).toBe('best');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to -f "bestaudio" for audio when no format profile', async () => {
|
it('falls back to -f "bestaudio" for audio when no format profile', async () => {
|
||||||
|
|
@ -642,7 +642,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -652,7 +652,7 @@ describe('DownloadService', () => {
|
||||||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
const fIdx = args.indexOf('-f');
|
const fIdx = args.indexOf('-f');
|
||||||
expect(fIdx).toBeGreaterThanOrEqual(0);
|
expect(fIdx).toBeGreaterThanOrEqual(0);
|
||||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best');
|
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
|
||||||
// Should default to mp4 merge format when containerFormat is null
|
// Should default to mp4 merge format when containerFormat is null
|
||||||
expect(args).toContain('--merge-output-format');
|
expect(args).toContain('--merge-output-format');
|
||||||
const moIdx = args.indexOf('--merge-output-format');
|
const moIdx = args.indexOf('--merge-output-format');
|
||||||
|
|
@ -686,7 +686,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: 'mkv',
|
containerFormat: 'mkv',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
@ -695,7 +695,7 @@ describe('DownloadService', () => {
|
||||||
|
|
||||||
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
const args = execYtDlpMock.mock.calls[0][0] as string[];
|
||||||
const fIdx = args.indexOf('-f');
|
const fIdx = args.indexOf('-f');
|
||||||
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best');
|
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
|
||||||
const moIdx = args.indexOf('--merge-output-format');
|
const moIdx = args.indexOf('--merge-output-format');
|
||||||
expect(args[moIdx + 1]).toBe('mkv');
|
expect(args[moIdx + 1]).toBe('mkv');
|
||||||
});
|
});
|
||||||
|
|
@ -738,7 +738,7 @@ describe('DownloadService', () => {
|
||||||
containerFormat: null,
|
containerFormat: null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
createContentItem,
|
createContentItem,
|
||||||
getContentItemById,
|
getContentItemById,
|
||||||
updateContentItem,
|
updateContentItem,
|
||||||
|
getContentItemsByStatus,
|
||||||
} from '../db/repositories/content-repository';
|
} from '../db/repositories/content-repository';
|
||||||
import type { Platform } from '../types/index';
|
import type { Platform } from '../types/index';
|
||||||
|
|
||||||
|
|
@ -439,6 +440,95 @@ describe('Content Item Update & Query Functions', () => {
|
||||||
const result = await updateContentItem(db, 999, { status: 'failed' });
|
const result = await updateContentItem(db, 999, { status: 'failed' });
|
||||||
expect(result).toBeNull();
|
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 ──
|
// ── Config ──
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
getPendingQueueItems,
|
getPendingQueueItems,
|
||||||
updateQueueItemStatus,
|
updateQueueItemStatus,
|
||||||
countQueueItemsByStatus,
|
countQueueItemsByStatus,
|
||||||
|
deleteQueueItem,
|
||||||
getQueueItemByContentItemId,
|
getQueueItemByContentItemId,
|
||||||
} from '../db/repositories/queue-repository';
|
} from '../db/repositories/queue-repository';
|
||||||
import type { Channel, ContentItem } from '../types/index';
|
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', () => {
|
describe('getQueueItemByContentItemId', () => {
|
||||||
it('returns queue item for a given content item ID', async () => {
|
it('returns queue item for a given content item ID', async () => {
|
||||||
const item = await createQueueItem(db, {
|
const item = await createQueueItem(db, {
|
||||||
|
|
|
||||||
|
|
@ -135,9 +135,6 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
|
||||||
metadata: null,
|
metadata: null,
|
||||||
formatProfileId: null,
|
formatProfileId: null,
|
||||||
monitoringMode: 'all',
|
monitoringMode: 'all',
|
||||||
bannerUrl: null,
|
|
||||||
description: null,
|
|
||||||
subscriberCount: null,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
lastCheckedAt: null,
|
lastCheckedAt: null,
|
||||||
|
|
@ -240,9 +237,6 @@ describe('YouTubeSource', () => {
|
||||||
imageUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
|
imageUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
|
||||||
url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw',
|
url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw',
|
||||||
platform: 'youtube',
|
platform: 'youtube',
|
||||||
bannerUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
|
|
||||||
description: null,
|
|
||||||
subscriberCount: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify yt-dlp was called with correct args
|
// Verify yt-dlp was called with correct args
|
||||||
|
|
@ -675,9 +669,6 @@ describe('SoundCloudSource', () => {
|
||||||
imageUrl: 'https://i1.sndcdn.com/avatars-large.jpg',
|
imageUrl: 'https://i1.sndcdn.com/avatars-large.jpg',
|
||||||
url: 'https://soundcloud.com/deadmau5',
|
url: 'https://soundcloud.com/deadmau5',
|
||||||
platform: 'soundcloud',
|
platform: 'soundcloud',
|
||||||
bannerUrl: null,
|
|
||||||
description: null,
|
|
||||||
subscriberCount: null,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ function makeProfile(overrides: Partial<FormatProfile> = {}): FormatProfile {
|
||||||
containerFormat: 'mp4',
|
containerFormat: 'mp4',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
subtitleLanguages: null,
|
subtitleLanguages: null,
|
||||||
embedSubtitles: false, embedChapters: false, embedThumbnail: false, sponsorBlockRemove: null,
|
embedSubtitles: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
...overrides,
|
...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,17 +9,12 @@ import type { Channel, Platform, MonitoringMode } from '../../types/index';
|
||||||
/** Fields needed to create a new channel (auto-generated fields excluded). */
|
/** Fields needed to create a new channel (auto-generated fields excluded). */
|
||||||
export type CreateChannelData = Omit<
|
export type CreateChannelData = Omit<
|
||||||
Channel,
|
Channel,
|
||||||
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount'
|
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode'
|
||||||
> & {
|
> & { monitoringMode?: Channel['monitoringMode'] };
|
||||||
monitoringMode?: Channel['monitoringMode'];
|
|
||||||
bannerUrl?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
subscriberCount?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Fields that can be updated on an existing channel. */
|
/** Fields that can be updated on an existing channel. */
|
||||||
export type UpdateChannelData = Partial<
|
export type UpdateChannelData = Partial<
|
||||||
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount'>
|
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode'>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
@ -44,9 +39,6 @@ export async function createChannel(
|
||||||
metadata: data.metadata,
|
metadata: data.metadata,
|
||||||
formatProfileId: data.formatProfileId,
|
formatProfileId: data.formatProfileId,
|
||||||
monitoringMode: data.monitoringMode ?? 'all',
|
monitoringMode: data.monitoringMode ?? 'all',
|
||||||
bannerUrl: data.bannerUrl ?? null,
|
|
||||||
description: data.description ?? null,
|
|
||||||
subscriberCount: data.subscriberCount ?? null,
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -193,9 +185,6 @@ function mapRow(row: typeof channels.$inferSelect): Channel {
|
||||||
metadata: row.metadata as Record<string, unknown> | null,
|
metadata: row.metadata as Record<string, unknown> | null,
|
||||||
formatProfileId: row.formatProfileId,
|
formatProfileId: row.formatProfileId,
|
||||||
monitoringMode: (row.monitoringMode ?? 'all') as Channel['monitoringMode'],
|
monitoringMode: (row.monitoringMode ?? 'all') as Channel['monitoringMode'],
|
||||||
bannerUrl: row.bannerUrl ?? null,
|
|
||||||
description: row.description ?? null,
|
|
||||||
subscriberCount: row.subscriberCount ?? null,
|
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
lastCheckedAt: row.lastCheckedAt,
|
lastCheckedAt: row.lastCheckedAt,
|
||||||
|
|
|
||||||
|
|
@ -92,81 +92,6 @@ export async function getContentByChannelId(
|
||||||
return rows.map(mapRow);
|
return rows.map(mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Optional filters for channel content queries. */
|
|
||||||
export interface ChannelContentFilters {
|
|
||||||
search?: string;
|
|
||||||
status?: ContentStatus;
|
|
||||||
contentType?: ContentType;
|
|
||||||
sortBy?: 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt';
|
|
||||||
sortDirection?: 'asc' | 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get paginated content items for a channel with optional search, filter, and sort.
|
|
||||||
* Returns items and total count for pagination.
|
|
||||||
*/
|
|
||||||
export async function getChannelContentPaginated(
|
|
||||||
db: Db,
|
|
||||||
channelId: number,
|
|
||||||
filters?: ChannelContentFilters,
|
|
||||||
page = 1,
|
|
||||||
pageSize = 50
|
|
||||||
): Promise<PaginatedContentResult> {
|
|
||||||
const conditions = [eq(contentItems.channelId, channelId)];
|
|
||||||
|
|
||||||
if (filters?.search) {
|
|
||||||
conditions.push(like(contentItems.title, `%${filters.search}%`));
|
|
||||||
}
|
|
||||||
if (filters?.status) {
|
|
||||||
conditions.push(eq(contentItems.status, filters.status));
|
|
||||||
}
|
|
||||||
if (filters?.contentType) {
|
|
||||||
conditions.push(eq(contentItems.contentType, filters.contentType));
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = and(...conditions);
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
// Count total matching records
|
|
||||||
const countResult = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(contentItems)
|
|
||||||
.where(whereClause);
|
|
||||||
|
|
||||||
const total = Number(countResult[0].count);
|
|
||||||
|
|
||||||
// Build sort order
|
|
||||||
const sortCol = resolveSortColumn(filters?.sortBy);
|
|
||||||
const sortDir = filters?.sortDirection === 'asc' ? sortCol : desc(sortCol);
|
|
||||||
|
|
||||||
// Fetch paginated results
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(contentItems)
|
|
||||||
.where(whereClause)
|
|
||||||
.orderBy(sortDir, desc(contentItems.id))
|
|
||||||
.limit(pageSize)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: rows.map(mapRow),
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve sort column name to Drizzle column reference. */
|
|
||||||
function resolveSortColumn(sortBy?: string) {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'title': return contentItems.title;
|
|
||||||
case 'publishedAt': return contentItems.publishedAt;
|
|
||||||
case 'status': return contentItems.status;
|
|
||||||
case 'duration': return contentItems.duration;
|
|
||||||
case 'fileSize': return contentItems.fileSize;
|
|
||||||
case 'downloadedAt': return contentItems.downloadedAt;
|
|
||||||
default: return contentItems.createdAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a specific content item exists for a channel. Returns the item or null. */
|
/** Check if a specific content item exists for a channel. Returns the item or null. */
|
||||||
export async function getContentByPlatformContentId(
|
export async function getContentByPlatformContentId(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
|
@ -288,6 +213,24 @@ export async function bulkSetMonitored(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get content items by status, ordered by creation date (oldest first). */
|
/** 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 ──
|
// ── Paginated Listing ──
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@ export interface CreateFormatProfileData {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
embedChapters?: boolean;
|
|
||||||
embedThumbnail?: boolean;
|
|
||||||
sponsorBlockRemove?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fields that can be updated on an existing format profile. */
|
/** Fields that can be updated on an existing format profile. */
|
||||||
|
|
@ -31,9 +28,6 @@ export interface UpdateFormatProfileData {
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean;
|
embedSubtitles?: boolean;
|
||||||
embedChapters?: boolean;
|
|
||||||
embedThumbnail?: boolean;
|
|
||||||
sponsorBlockRemove?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Db = LibSQLDatabase<typeof schema>;
|
type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
@ -66,9 +60,6 @@ export async function createFormatProfile(
|
||||||
isDefault: data.isDefault ?? false,
|
isDefault: data.isDefault ?? false,
|
||||||
subtitleLanguages: data.subtitleLanguages ?? null,
|
subtitleLanguages: data.subtitleLanguages ?? null,
|
||||||
embedSubtitles: data.embedSubtitles ?? false,
|
embedSubtitles: data.embedSubtitles ?? false,
|
||||||
embedChapters: data.embedChapters ?? false,
|
|
||||||
embedThumbnail: data.embedThumbnail ?? false,
|
|
||||||
sponsorBlockRemove: data.sponsorBlockRemove ?? null,
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -189,9 +180,6 @@ function mapRow(row: typeof formatProfiles.$inferSelect): FormatProfile {
|
||||||
isDefault: row.isDefault,
|
isDefault: row.isDefault,
|
||||||
subtitleLanguages: row.subtitleLanguages ?? null,
|
subtitleLanguages: row.subtitleLanguages ?? null,
|
||||||
embedSubtitles: row.embedSubtitles,
|
embedSubtitles: row.embedSubtitles,
|
||||||
embedChapters: row.embedChapters,
|
|
||||||
embedThumbnail: row.embedThumbnail,
|
|
||||||
sponsorBlockRemove: row.sponsorBlockRemove ?? null,
|
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export async function upsertPlatformSettings(
|
||||||
subtitleLanguages: data.subtitleLanguages ?? null,
|
subtitleLanguages: data.subtitleLanguages ?? null,
|
||||||
grabAllEnabled: data.grabAllEnabled ?? false,
|
grabAllEnabled: data.grabAllEnabled ?? false,
|
||||||
grabAllOrder: data.grabAllOrder ?? 'newest',
|
grabAllOrder: data.grabAllOrder ?? 'newest',
|
||||||
scanLimit: data.scanLimit ?? 500,
|
scanLimit: data.scanLimit ?? 100,
|
||||||
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|
@ -79,7 +79,7 @@ export async function upsertPlatformSettings(
|
||||||
subtitleLanguages: data.subtitleLanguages ?? null,
|
subtitleLanguages: data.subtitleLanguages ?? null,
|
||||||
grabAllEnabled: data.grabAllEnabled ?? false,
|
grabAllEnabled: data.grabAllEnabled ?? false,
|
||||||
grabAllOrder: data.grabAllOrder ?? 'newest',
|
grabAllOrder: data.grabAllOrder ?? 'newest',
|
||||||
scanLimit: data.scanLimit ?? 500,
|
scanLimit: data.scanLimit ?? 100,
|
||||||
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
rateLimitDelay: data.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
defaultMonitoringMode: data.defaultMonitoringMode ?? 'all',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
@ -114,7 +114,7 @@ function mapRow(row: typeof platformSettings.$inferSelect): PlatformSettings {
|
||||||
subtitleLanguages: row.subtitleLanguages,
|
subtitleLanguages: row.subtitleLanguages,
|
||||||
grabAllEnabled: row.grabAllEnabled,
|
grabAllEnabled: row.grabAllEnabled,
|
||||||
grabAllOrder: row.grabAllOrder as 'newest' | 'oldest',
|
grabAllOrder: row.grabAllOrder as 'newest' | 'oldest',
|
||||||
scanLimit: row.scanLimit ?? 500,
|
scanLimit: row.scanLimit ?? 100,
|
||||||
rateLimitDelay: row.rateLimitDelay ?? 1000,
|
rateLimitDelay: row.rateLimitDelay ?? 1000,
|
||||||
defaultMonitoringMode: (row.defaultMonitoringMode ?? 'all') as MonitoringMode,
|
defaultMonitoringMode: (row.defaultMonitoringMode ?? 'all') as MonitoringMode,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,12 @@ export async function getContentPlaylistMappings(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete all playlists for a channel. Cascade handles junction rows. */
|
/** 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 ──
|
// ── Row Mapping ──
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ export interface CreateQueueItemData {
|
||||||
/** Optional fields when updating queue item status. */
|
/** Optional fields when updating queue item status. */
|
||||||
export interface UpdateQueueItemFields {
|
export interface UpdateQueueItemFields {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
errorCategory?: string | null;
|
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
completedAt?: string | null;
|
completedAt?: string | null;
|
||||||
attempts?: number;
|
attempts?: number;
|
||||||
|
|
@ -73,7 +72,6 @@ export async function getQueueItemsByStatus(
|
||||||
attempts: queueItems.attempts,
|
attempts: queueItems.attempts,
|
||||||
maxAttempts: queueItems.maxAttempts,
|
maxAttempts: queueItems.maxAttempts,
|
||||||
error: queueItems.error,
|
error: queueItems.error,
|
||||||
errorCategory: queueItems.errorCategory,
|
|
||||||
startedAt: queueItems.startedAt,
|
startedAt: queueItems.startedAt,
|
||||||
completedAt: queueItems.completedAt,
|
completedAt: queueItems.completedAt,
|
||||||
createdAt: queueItems.createdAt,
|
createdAt: queueItems.createdAt,
|
||||||
|
|
@ -103,7 +101,6 @@ export async function getAllQueueItems(
|
||||||
attempts: queueItems.attempts,
|
attempts: queueItems.attempts,
|
||||||
maxAttempts: queueItems.maxAttempts,
|
maxAttempts: queueItems.maxAttempts,
|
||||||
error: queueItems.error,
|
error: queueItems.error,
|
||||||
errorCategory: queueItems.errorCategory,
|
|
||||||
startedAt: queueItems.startedAt,
|
startedAt: queueItems.startedAt,
|
||||||
completedAt: queueItems.completedAt,
|
completedAt: queueItems.completedAt,
|
||||||
createdAt: queueItems.createdAt,
|
createdAt: queueItems.createdAt,
|
||||||
|
|
@ -157,7 +154,6 @@ export async function updateQueueItemStatus(
|
||||||
};
|
};
|
||||||
|
|
||||||
if (updates?.error !== undefined) setData.error = updates.error;
|
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?.startedAt !== undefined) setData.startedAt = updates.startedAt;
|
||||||
if (updates?.completedAt !== undefined) setData.completedAt = updates.completedAt;
|
if (updates?.completedAt !== undefined) setData.completedAt = updates.completedAt;
|
||||||
if (updates?.attempts !== undefined) setData.attempts = updates.attempts;
|
if (updates?.attempts !== undefined) setData.attempts = updates.attempts;
|
||||||
|
|
@ -202,6 +198,17 @@ export async function countQueueItemsByStatus(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a queue item by ID. Returns true if a row was deleted. */
|
/** 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).
|
* Get a queue item by content item ID (for dedup checking before enqueue).
|
||||||
|
|
@ -232,7 +239,6 @@ function mapRow(row: typeof queueItems.$inferSelect): QueueItem {
|
||||||
attempts: row.attempts,
|
attempts: row.attempts,
|
||||||
maxAttempts: row.maxAttempts,
|
maxAttempts: row.maxAttempts,
|
||||||
error: row.error,
|
error: row.error,
|
||||||
errorCategory: row.errorCategory,
|
|
||||||
startedAt: row.startedAt,
|
startedAt: row.startedAt,
|
||||||
completedAt: row.completedAt,
|
completedAt: row.completedAt,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
|
|
@ -249,7 +255,6 @@ interface JoinedQueueRow {
|
||||||
attempts: number;
|
attempts: number;
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
errorCategory: string | null;
|
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -268,7 +273,6 @@ function mapJoinedRow(row: JoinedQueueRow): QueueItem {
|
||||||
attempts: row.attempts,
|
attempts: row.attempts,
|
||||||
maxAttempts: row.maxAttempts,
|
maxAttempts: row.maxAttempts,
|
||||||
error: row.error,
|
error: row.error,
|
||||||
errorCategory: row.errorCategory,
|
|
||||||
startedAt: row.startedAt,
|
startedAt: row.startedAt,
|
||||||
completedAt: row.completedAt,
|
completedAt: row.completedAt,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ type Db = LibSQLDatabase<typeof schema>;
|
||||||
|
|
||||||
export const APP_CHECK_INTERVAL = 'app.check_interval';
|
export const APP_CHECK_INTERVAL = 'app.check_interval';
|
||||||
export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads';
|
export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads';
|
||||||
export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated';
|
|
||||||
|
|
||||||
// ── Read / Write ──
|
// ── Read / Write ──
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,4 @@ export const channels = sqliteTable('channels', {
|
||||||
lastCheckedAt: text('last_checked_at'), // null until first monitoring check
|
lastCheckedAt: text('last_checked_at'), // null until first monitoring check
|
||||||
lastCheckStatus: text('last_check_status'), // 'success' | 'error' | 'rate_limited'
|
lastCheckStatus: text('last_check_status'), // 'success' | 'error' | 'rate_limited'
|
||||||
monitoringMode: text('monitoring_mode').notNull().default('all'), // 'all' | 'future' | 'existing' | 'none'
|
monitoringMode: text('monitoring_mode').notNull().default('all'), // 'all' | 'future' | 'existing' | 'none'
|
||||||
bannerUrl: text('banner_url'),
|
|
||||||
description: text('description'),
|
|
||||||
subscriberCount: integer('subscriber_count'),
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,6 @@ export const formatProfiles = sqliteTable('format_profiles', {
|
||||||
isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
|
isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
|
||||||
subtitleLanguages: text('subtitle_languages'),
|
subtitleLanguages: text('subtitle_languages'),
|
||||||
embedSubtitles: integer('embed_subtitles', { mode: 'boolean' }).notNull().default(false),
|
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'
|
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(datetime('now'))`),
|
.default(sql`(datetime('now'))`),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ export const queueItems = sqliteTable('queue_items', {
|
||||||
attempts: integer('attempts').notNull().default(0),
|
attempts: integer('attempts').notNull().default(0),
|
||||||
maxAttempts: integer('max_attempts').notNull().default(3),
|
maxAttempts: integer('max_attempts').notNull().default(3),
|
||||||
error: text('error'),
|
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'),
|
startedAt: text('started_at'),
|
||||||
completedAt: text('completed_at'),
|
completedAt: text('completed_at'),
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { ToastProvider } from './components/Toast';
|
|
||||||
import { Channels } from './pages/Channels';
|
import { Channels } from './pages/Channels';
|
||||||
import { ChannelDetail } from './pages/ChannelDetail';
|
import { ChannelDetail } from './pages/ChannelDetail';
|
||||||
import { Library } from './pages/Library';
|
import { Library } from './pages/Library';
|
||||||
|
|
@ -38,10 +37,8 @@ function AuthenticatedLayout() {
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/*" element={<AuthenticatedLayout />} />
|
||||||
<Route path="/*" element={<AuthenticatedLayout />} />
|
</Routes>
|
||||||
</Routes>
|
|
||||||
</ToastProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ interface CreateChannelInput {
|
||||||
monitoringEnabled?: boolean;
|
monitoringEnabled?: boolean;
|
||||||
monitoringMode?: string;
|
monitoringMode?: string;
|
||||||
formatProfileId?: number;
|
formatProfileId?: number;
|
||||||
|
grabAll?: boolean;
|
||||||
|
grabAllOrder?: 'newest' | 'oldest';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new channel by URL (resolves metadata via backend). */
|
/** Create a new channel by URL (resolves metadata via backend). */
|
||||||
|
|
@ -86,9 +88,9 @@ export function useDeleteChannel() {
|
||||||
export interface ScanChannelResult {
|
export interface ScanChannelResult {
|
||||||
channelId: number;
|
channelId: number;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
newItems?: number;
|
newItems: number;
|
||||||
totalFetched?: number;
|
totalFetched: number;
|
||||||
status: 'started' | 'success' | 'error' | 'rate_limited' | 'already_running';
|
status: 'success' | 'error' | 'rate_limited' | 'already_running';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanAllResult {
|
export interface ScanAllResult {
|
||||||
|
|
@ -98,22 +100,18 @@ export interface ScanAllResult {
|
||||||
|
|
||||||
// ── Scan Mutations ──
|
// ── Scan Mutations ──
|
||||||
|
|
||||||
/**
|
/** Trigger a manual scan for a single channel. */
|
||||||
* Trigger a manual scan for a single channel.
|
|
||||||
* Returns immediately with status 'started' — progress is streamed via WebSocket.
|
|
||||||
*/
|
|
||||||
export function useScanChannel(id: number) {
|
export function useScanChannel(id: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiClient.post<ScanChannelResult>(`/api/v1/channel/${id}/scan`),
|
apiClient.post<ScanChannelResult>(`/api/v1/channel/${id}/scan`),
|
||||||
});
|
onSuccess: () => {
|
||||||
}
|
queryClient.invalidateQueries({ queryKey: channelKeys.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: channelKeys.detail(id) });
|
||||||
/** Cancel an in-progress scan for a channel. */
|
queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(id) });
|
||||||
export function useCancelScan(id: number) {
|
},
|
||||||
return useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
apiClient.post<{ channelId: number; cancelled: boolean }>(`/api/v1/channel/${id}/scan-cancel`),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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). */
|
/** Set the monitoring mode for a channel (cascades to content items). */
|
||||||
export function useSetMonitoringMode(channelId: number) {
|
export function useSetMonitoringMode(channelId: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client';
|
||||||
import { queueKeys } from './useQueue';
|
import { queueKeys } from './useQueue';
|
||||||
import type { ContentItem } from '@shared/types/index';
|
import type { ContentItem } from '@shared/types/index';
|
||||||
import type { ApiResponse, PaginatedResponse } from '@shared/types/api';
|
import type { ApiResponse } from '@shared/types/api';
|
||||||
|
|
||||||
// ── Collect Types ──
|
// ── Collect Types ──
|
||||||
|
|
||||||
|
|
@ -13,49 +13,25 @@ export interface CollectResult {
|
||||||
items: Array<{ contentItemId: number; status: string }>;
|
items: Array<{ contentItemId: number; status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Channel Content Filter Types ──
|
|
||||||
|
|
||||||
export interface ChannelContentFilters {
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
contentType?: string;
|
|
||||||
sortBy?: string;
|
|
||||||
sortDirection?: 'asc' | 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Query Keys ──
|
// ── Query Keys ──
|
||||||
|
|
||||||
export const contentKeys = {
|
export const contentKeys = {
|
||||||
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
|
||||||
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
|
|
||||||
['content', 'channel', channelId, 'paginated', filters] as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
||||||
/** Fetch paginated content items for a channel with search/filter/sort. */
|
/** Fetch content items for a specific channel. */
|
||||||
export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) {
|
export function useChannelContent(channelId: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: contentKeys.byChannelPaginated(channelId, filters),
|
queryKey: contentKeys.byChannel(channelId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const response = await apiClient.get<ApiResponse<ContentItem[]>>(
|
||||||
if (filters.page) params.set('page', String(filters.page));
|
`/api/v1/channel/${channelId}/content`,
|
||||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
|
||||||
if (filters.search) params.set('search', filters.search);
|
|
||||||
if (filters.status) params.set('status', filters.status);
|
|
||||||
if (filters.contentType) params.set('contentType', filters.contentType);
|
|
||||||
if (filters.sortBy) params.set('sortBy', filters.sortBy);
|
|
||||||
if (filters.sortDirection) params.set('sortDirection', filters.sortDirection);
|
|
||||||
|
|
||||||
const response = await apiClient.get<PaginatedResponse<ContentItem>>(
|
|
||||||
`/api/v1/channel/${channelId}/content?${params.toString()}`,
|
|
||||||
);
|
);
|
||||||
return response;
|
return response.data;
|
||||||
},
|
},
|
||||||
enabled: channelId > 0,
|
enabled: channelId > 0,
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../client';
|
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 ──
|
// ── Query Keys ──
|
||||||
|
|
||||||
|
|
@ -9,7 +9,6 @@ export const systemKeys = {
|
||||||
health: ['system', 'health'] as const,
|
health: ['system', 'health'] as const,
|
||||||
apiKey: ['system', 'apikey'] as const,
|
apiKey: ['system', 'apikey'] as const,
|
||||||
appSettings: ['system', 'appSettings'] as const,
|
appSettings: ['system', 'appSettings'] as const,
|
||||||
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
@ -71,23 +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 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,12 @@ function detectPlatform(url: string): Platform | null {
|
||||||
return 'soundcloud';
|
return 'soundcloud';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any valid URL → Generic (yt-dlp supports 1000+ sites)
|
|
||||||
if (/^https?:\/\/.+/.test(url)) {
|
|
||||||
return 'generic';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLATFORM_LABELS: Record<Platform, string> = {
|
const PLATFORM_LABELS: Record<Platform, string> = {
|
||||||
youtube: 'YouTube',
|
youtube: 'YouTube',
|
||||||
soundcloud: 'SoundCloud',
|
soundcloud: 'SoundCloud',
|
||||||
generic: 'Generic',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
@ -56,7 +50,9 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [checkInterval, setCheckInterval] = useState('');
|
const [checkInterval, setCheckInterval] = useState('');
|
||||||
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
|
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 createChannel = useCreateChannel();
|
||||||
const { data: platformSettingsList } = usePlatformSettings();
|
const { data: platformSettingsList } = usePlatformSettings();
|
||||||
|
|
@ -86,6 +82,16 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
if (settings.defaultMonitoringMode) {
|
if (settings.defaultMonitoringMode) {
|
||||||
setMonitoringMode(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
|
}, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
|
@ -98,6 +104,8 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
|
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
|
||||||
monitoringMode,
|
monitoringMode,
|
||||||
formatProfileId: formatProfileId ?? undefined,
|
formatProfileId: formatProfileId ?? undefined,
|
||||||
|
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
|
||||||
|
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (newChannel) => {
|
onSuccess: (newChannel) => {
|
||||||
|
|
@ -119,7 +127,9 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
setUrl('');
|
setUrl('');
|
||||||
setCheckInterval('');
|
setCheckInterval('');
|
||||||
setFormatProfileId(undefined);
|
setFormatProfileId(undefined);
|
||||||
setMonitoringMode('none');
|
setMonitoringMode('all');
|
||||||
|
setGrabAll(false);
|
||||||
|
setGrabAllOrder('newest');
|
||||||
createChannel.reset();
|
createChannel.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -132,29 +142,7 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Add Channel" open={open} onClose={handleClose}>
|
<Modal title="Add Channel" open={open} onClose={handleClose}>
|
||||||
<form onSubmit={handleSubmit} style={{ position: 'relative' }}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
{/* URL input */}
|
{/* URL input */}
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
<label
|
<label
|
||||||
|
|
@ -200,34 +188,31 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monitoring Mode — shown when platform detected */}
|
{/* Check interval (optional) */}
|
||||||
{detectedPlatform && (
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
<label
|
||||||
<label
|
htmlFor="check-interval"
|
||||||
htmlFor="monitoring-mode"
|
style={{
|
||||||
style={{
|
display: 'block',
|
||||||
display: 'block',
|
marginBottom: 'var(--space-1)',
|
||||||
marginBottom: 'var(--space-1)',
|
fontSize: 'var(--font-size-sm)',
|
||||||
fontSize: 'var(--font-size-sm)',
|
fontWeight: 500,
|
||||||
fontWeight: 500,
|
color: 'var(--text-secondary)',
|
||||||
color: 'var(--text-secondary)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Check Interval (minutes)
|
||||||
Monitoring Mode
|
</label>
|
||||||
</label>
|
<input
|
||||||
<select
|
id="check-interval"
|
||||||
id="monitoring-mode"
|
type="number"
|
||||||
value={monitoringMode}
|
min={1}
|
||||||
onChange={(e) => setMonitoringMode(e.target.value)}
|
value={checkInterval}
|
||||||
disabled={createChannel.isPending}
|
onChange={(e) => setCheckInterval(e.target.value)}
|
||||||
style={{ width: '100%' }}
|
placeholder="360 (default: 6 hours)"
|
||||||
>
|
disabled={createChannel.isPending}
|
||||||
<option value="all">Monitor All</option>
|
style={{ width: '100%' }}
|
||||||
<option value="future">Future Only</option>
|
/>
|
||||||
<option value="none">None</option>
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Format Profile (optional, shown when platform detected) */}
|
{/* Format Profile (optional, shown when platform detected) */}
|
||||||
{detectedPlatform && formatProfiles && formatProfiles.length > 0 && (
|
{detectedPlatform && formatProfiles && formatProfiles.length > 0 && (
|
||||||
|
|
@ -263,31 +248,105 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Check interval (optional) */}
|
{/* Monitoring Mode — shown when platform detected */}
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
{detectedPlatform && (
|
||||||
<label
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
htmlFor="check-interval"
|
<label
|
||||||
style={{
|
htmlFor="monitoring-mode"
|
||||||
display: 'block',
|
style={{
|
||||||
marginBottom: 'var(--space-1)',
|
display: 'block',
|
||||||
fontSize: 'var(--font-size-sm)',
|
marginBottom: 'var(--space-1)',
|
||||||
fontWeight: 500,
|
fontSize: 'var(--font-size-sm)',
|
||||||
color: 'var(--text-secondary)',
|
fontWeight: 500,
|
||||||
}}
|
color: 'var(--text-secondary)',
|
||||||
>
|
}}
|
||||||
Check Interval (minutes)
|
>
|
||||||
</label>
|
Monitoring Mode
|
||||||
<input
|
</label>
|
||||||
id="check-interval"
|
<select
|
||||||
type="number"
|
id="monitoring-mode"
|
||||||
min={1}
|
value={monitoringMode}
|
||||||
value={checkInterval}
|
onChange={(e) => setMonitoringMode(e.target.value)}
|
||||||
onChange={(e) => setCheckInterval(e.target.value)}
|
disabled={createChannel.isPending}
|
||||||
placeholder="360 (default: 6 hours)"
|
style={{ width: '100%' }}
|
||||||
disabled={createChannel.isPending}
|
>
|
||||||
style={{ width: '100%' }}
|
<option value="all">Monitor All</option>
|
||||||
/>
|
<option value="future">Future Only</option>
|
||||||
</div>
|
<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 */}
|
{/* Error display */}
|
||||||
{createChannel.isError && (
|
{createChannel.isError && (
|
||||||
|
|
@ -303,12 +362,9 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
|
||||||
color: 'var(--danger)',
|
color: 'var(--danger)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createChannel.error instanceof Error &&
|
{createChannel.error instanceof Error
|
||||||
createChannel.error.message.toLowerCase().includes('already exists')
|
? createChannel.error.message
|
||||||
? 'This channel has already been added.'
|
: 'Failed to add channel'}
|
||||||
: createChannel.error instanceof Error
|
|
||||||
? createChannel.error.message
|
|
||||||
: 'Failed to add channel'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
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';
|
|
||||||
|
|
||||||
// ── Component ──
|
|
||||||
|
|
||||||
interface ContentCardProps {
|
|
||||||
item: ContentItem;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
onToggleMonitored: (id: number, monitored: boolean) => void;
|
|
||||||
onDownload: (id: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentCardProps) {
|
|
||||||
const progress = useDownloadProgress(item.id);
|
|
||||||
const duration = formatDuration(item.duration);
|
|
||||||
const published = formatRelativeTime(item.publishedAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
backgroundColor: selected ? 'var(--bg-selected)' : 'var(--bg-card-solid)',
|
|
||||||
border: selected ? '1px solid var(--accent)' : '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-xl)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'all var(--transition-fast)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onClick={() => onSelect(item.id)}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div style={{ position: 'relative', aspectRatio: '16/9', 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={32} /> : <Film size={32} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Duration badge */}
|
|
||||||
{duration && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 6,
|
|
||||||
right: 6,
|
|
||||||
padding: '1px 6px',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 'var(--font-size-xs)',
|
|
||||||
fontWeight: 500,
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{duration}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Selection checkbox */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 6,
|
|
||||||
left: 6,
|
|
||||||
opacity: selected ? 1 : 0,
|
|
||||||
transition: 'opacity var(--transition-fast)',
|
|
||||||
}}
|
|
||||||
className="card-checkbox"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect(item.id);
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
aria-label={`Select ${item.title}`}
|
|
||||||
style={{
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
cursor: 'pointer',
|
|
||||||
accentColor: 'var(--accent)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Download progress overlay */}
|
|
||||||
{item.status === 'downloading' && progress && (
|
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}>
|
|
||||||
<DownloadProgressBar progress={progress} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card body */}
|
|
||||||
<div style={{ padding: 'var(--space-3)' }}>
|
|
||||||
{/* Title */}
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
title={item.title}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Meta row */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginTop: 'var(--space-2)',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StatusBadge status={item.status} />
|
|
||||||
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-xs)' }}>
|
|
||||||
{published}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action row */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
marginTop: 'var(--space-2)',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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,268 +0,0 @@
|
||||||
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';
|
|
||||||
|
|
||||||
// ── Component ──
|
|
||||||
|
|
||||||
interface ContentListItemProps {
|
|
||||||
item: ContentItem;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
onToggleMonitored: (id: number, monitored: boolean) => void;
|
|
||||||
onDownload: (id: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentListItemProps) {
|
|
||||||
const progress = useDownloadProgress(item.id);
|
|
||||||
const duration = formatDuration(item.duration);
|
|
||||||
const published = formatRelativeTime(item.publishedAt);
|
|
||||||
|
|
||||||
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)';
|
|
||||||
// Reveal checkbox on hover
|
|
||||||
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)';
|
|
||||||
// Hide checkbox if not selected
|
|
||||||
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 */}
|
|
||||||
<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: published · duration · content type */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
fontSize: 'var(--font-size-xs)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{published && <span>{published}</span>}
|
|
||||||
{published && duration && <span style={{ opacity: 0.5 }}>·</span>}
|
|
||||||
{duration && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{duration}</span>}
|
|
||||||
{(published || duration) && <span style={{ opacity: 0.5 }}>·</span>}
|
|
||||||
<span style={{ textTransform: 'capitalize' }}>{item.contentType}</span>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -20,9 +20,6 @@ export interface FormatProfileFormValues {
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
subtitleLanguages: string | null;
|
subtitleLanguages: string | null;
|
||||||
embedSubtitles: boolean;
|
embedSubtitles: boolean;
|
||||||
embedChapters: boolean;
|
|
||||||
embedThumbnail: boolean;
|
|
||||||
sponsorBlockRemove: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatProfileFormProps {
|
interface FormatProfileFormProps {
|
||||||
|
|
@ -95,9 +92,6 @@ export function FormatProfileForm({
|
||||||
const [isDefault, setIsDefault] = useState(profile?.isDefault ?? false);
|
const [isDefault, setIsDefault] = useState(profile?.isDefault ?? false);
|
||||||
const [subtitleLanguages, setSubtitleLanguages] = useState(profile?.subtitleLanguages ?? '');
|
const [subtitleLanguages, setSubtitleLanguages] = useState(profile?.subtitleLanguages ?? '');
|
||||||
const [embedSubtitles, setEmbedSubtitles] = useState(profile?.embedSubtitles ?? false);
|
const [embedSubtitles, setEmbedSubtitles] = useState(profile?.embedSubtitles ?? false);
|
||||||
const [embedChapters, setEmbedChapters] = useState(profile?.embedChapters ?? false);
|
|
||||||
const [embedThumbnail, setEmbedThumbnail] = useState(profile?.embedThumbnail ?? false);
|
|
||||||
const [sponsorBlockRemove, setSponsorBlockRemove] = useState(profile?.sponsorBlockRemove ?? '');
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent) => {
|
(e: FormEvent) => {
|
||||||
|
|
@ -112,12 +106,9 @@ export function FormatProfileForm({
|
||||||
isDefault,
|
isDefault,
|
||||||
subtitleLanguages: subtitleLanguages.trim() || null,
|
subtitleLanguages: subtitleLanguages.trim() || null,
|
||||||
embedSubtitles,
|
embedSubtitles,
|
||||||
embedChapters,
|
|
||||||
embedThumbnail,
|
|
||||||
sponsorBlockRemove: sponsorBlockRemove.trim() || null,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, embedChapters, embedThumbnail, sponsorBlockRemove, onSubmit],
|
[name, videoResolution, audioCodec, audioBitrate, containerFormat, isDefault, subtitleLanguages, embedSubtitles, onSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -256,62 +247,6 @@ export function FormatProfileForm({
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 */}
|
|
||||||
<div style={fieldGroupStyle}>
|
|
||||||
<label htmlFor="fp-sponsorblock" style={labelStyle}>
|
|
||||||
SponsorBlock — Remove Segments
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="fp-sponsorblock"
|
|
||||||
type="text"
|
|
||||||
value={sponsorBlockRemove}
|
|
||||||
onChange={(e) => setSponsorBlockRemove(e.target.value)}
|
|
||||||
placeholder="e.g. sponsor,selfpromo,intro,outro"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
|
|
||||||
Comma-separated categories: sponsor, selfpromo, interaction, intro, outro, preview, music_offtopic, filler
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Is Default checkbox */}
|
{/* Is Default checkbox */}
|
||||||
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { AlertTriangle, CheckCircle2, HardDrive, Play, Square, Terminal } from 'lucide-react';
|
||||||
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
|
|
||||||
import type { ComponentHealth } from '@shared/types/api';
|
import type { ComponentHealth } from '@shared/types/api';
|
||||||
import { formatBytes } from '../utils/format';
|
import { formatBytes } from '../utils/format';
|
||||||
import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
|
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
// ── Status → color mapping ──
|
// ── Status → color mapping ──
|
||||||
|
|
||||||
|
|
@ -30,12 +27,9 @@ const COMPONENT_LABELS: Record<string, string> = {
|
||||||
interface HealthStatusProps {
|
interface HealthStatusProps {
|
||||||
components: ComponentHealth[];
|
components: ComponentHealth[];
|
||||||
overallStatus: 'healthy' | 'degraded' | 'unhealthy';
|
overallStatus: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
|
||||||
ytdlpLoading?: boolean;
|
|
||||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
|
export function HealthStatus({ components, overallStatus }: HealthStatusProps) {
|
||||||
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
|
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
|
||||||
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
|
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
|
||||||
|
|
||||||
|
|
@ -80,13 +74,7 @@ export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoad
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{components.map((comp) => (
|
{components.map((comp) => (
|
||||||
<ComponentCard
|
<ComponentCard key={comp.name} component={comp} />
|
||||||
key={comp.name}
|
|
||||||
component={comp}
|
|
||||||
ytdlpStatus={comp.name === 'ytDlp' ? ytdlpStatus : undefined}
|
|
||||||
ytdlpLoading={comp.name === 'ytDlp' ? ytdlpLoading : undefined}
|
|
||||||
updateYtDlp={comp.name === 'ytDlp' ? updateYtDlp : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -110,12 +98,7 @@ export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoad
|
||||||
|
|
||||||
// ── Component Card ──
|
// ── Component Card ──
|
||||||
|
|
||||||
function ComponentCard({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
function ComponentCard({ component }: { component: ComponentHealth }) {
|
||||||
component: ComponentHealth;
|
|
||||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
|
||||||
ytdlpLoading?: boolean;
|
|
||||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
|
||||||
}) {
|
|
||||||
const colors = STATUS_COLORS[component.status] ?? DEFAULT_COLORS;
|
const colors = STATUS_COLORS[component.status] ?? DEFAULT_COLORS;
|
||||||
const label = COMPONENT_LABELS[component.name] ?? component.name;
|
const label = COMPONENT_LABELS[component.name] ?? component.name;
|
||||||
|
|
||||||
|
|
@ -159,24 +142,20 @@ function ComponentCard({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComponentDetail component={component} ytdlpStatus={ytdlpStatus} ytdlpLoading={ytdlpLoading} updateYtDlp={updateYtDlp} />
|
{/* Custom detail rendering per component type */}
|
||||||
|
<ComponentDetail component={component} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Detail Renderers ──
|
// ── Detail Renderers ──
|
||||||
|
|
||||||
function ComponentDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
function ComponentDetail({ component }: { component: ComponentHealth }) {
|
||||||
component: ComponentHealth;
|
|
||||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
|
||||||
ytdlpLoading?: boolean;
|
|
||||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
|
||||||
}) {
|
|
||||||
switch (component.name) {
|
switch (component.name) {
|
||||||
case 'diskSpace':
|
case 'diskSpace':
|
||||||
return <DiskSpaceDetail component={component} />;
|
return <DiskSpaceDetail component={component} />;
|
||||||
case 'ytDlp':
|
case 'ytDlp':
|
||||||
return <YtDlpDetail component={component} ytdlpStatus={ytdlpStatus} ytdlpLoading={ytdlpLoading} updateYtDlp={updateYtDlp} />;
|
return <YtDlpDetail component={component} />;
|
||||||
case 'scheduler':
|
case 'scheduler':
|
||||||
return <SchedulerDetail component={component} />;
|
return <SchedulerDetail component={component} />;
|
||||||
case 'recentErrors':
|
case 'recentErrors':
|
||||||
|
|
@ -254,101 +233,30 @@ function DiskSpaceDetail({ component }: { component: ComponentHealth }) {
|
||||||
|
|
||||||
// ── yt-dlp ──
|
// ── yt-dlp ──
|
||||||
|
|
||||||
function YtDlpDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
function YtDlpDetail({ component }: { component: ComponentHealth }) {
|
||||||
component: ComponentHealth;
|
|
||||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
|
||||||
ytdlpLoading?: boolean;
|
|
||||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
|
||||||
}) {
|
|
||||||
const [updateMessage, setUpdateMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
const details = component.details as { version?: string } | undefined;
|
const details = component.details as { version?: string } | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
{/* Version */}
|
<Terminal size={14} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
{details?.version ? (
|
||||||
<Terminal size={14} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
|
<span
|
||||||
{details?.version ? (
|
style={{
|
||||||
<span
|
fontSize: 'var(--font-size-sm)',
|
||||||
style={{
|
fontFamily: 'var(--font-mono)',
|
||||||
fontSize: 'var(--font-size-sm)',
|
color: 'var(--text-secondary)',
|
||||||
fontFamily: 'var(--font-mono)',
|
padding: '1px var(--space-2)',
|
||||||
color: 'var(--text-secondary)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
padding: '1px var(--space-2)',
|
backgroundColor: 'var(--bg-hover)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
}}
|
||||||
backgroundColor: 'var(--bg-hover)',
|
>
|
||||||
}}
|
v{details.version}
|
||||||
>
|
</span>
|
||||||
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" />
|
||||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
|
Not installed
|
||||||
<AlertTriangle size={12} aria-hidden="true" />
|
</span>
|
||||||
Not installed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last Updated */}
|
|
||||||
{!ytdlpLoading && ytdlpStatus && (
|
|
||||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
|
||||||
Last checked: {ytdlpStatus.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,6 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp
|
||||||
border: '1px solid var(--border-light)',
|
border: '1px solid var(--border-light)',
|
||||||
borderRadius: 'var(--radius-lg)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
boxShadow: 'var(--shadow-lg)',
|
boxShadow: 'var(--shadow-lg)',
|
||||||
animation: 'modal-enter 200ms ease-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -138,7 +137,24 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
className="btn-icon"
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-primary)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type { Platform } from '@shared/types/index';
|
||||||
const PLATFORM_STYLES: Record<string, { color: string; label: string }> = {
|
const PLATFORM_STYLES: Record<string, { color: string; label: string }> = {
|
||||||
youtube: { color: '#ff0000', label: 'YouTube' },
|
youtube: { color: '#ff0000', label: 'YouTube' },
|
||||||
soundcloud: { color: '#ff7700', label: 'SoundCloud' },
|
soundcloud: { color: '#ff7700', label: 'SoundCloud' },
|
||||||
generic: { color: '#6366f1', label: 'Generic' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_STYLE = { color: 'var(--text-secondary)', label: 'Unknown' };
|
const DEFAULT_STYLE = { color: 'var(--text-secondary)', label: 'Unknown' };
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { TubearrLogo } from './TubearrLogo';
|
import { TubearrLogo } from './TubearrLogo';
|
||||||
import { useDownloadProgressConnection } from '../contexts/DownloadProgressContext';
|
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', icon: Radio, label: 'Channels' },
|
{ to: '/', icon: Radio, label: 'Channels' },
|
||||||
|
|
@ -23,7 +22,6 @@ const NAV_ITEMS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const wsConnected = useDownloadProgressConnection();
|
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
|
return localStorage.getItem('tubearr-sidebar-collapsed') === 'true';
|
||||||
|
|
@ -80,11 +78,26 @@ export function Sidebar() {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 'var(--space-1)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||||
|
|
@ -128,41 +141,6 @@ export function Sidebar() {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
/**
|
|
||||||
* Skeleton loading placeholder components.
|
|
||||||
* Uses the .skeleton CSS class for shimmer animation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface SkeletonProps {
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
borderRadius?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generic skeleton block. */
|
|
||||||
export function Skeleton({ width = '100%', height = 16, borderRadius, style }: SkeletonProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="skeleton"
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
borderRadius: borderRadius ?? 'var(--radius-md)',
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for a table row. */
|
|
||||||
export function SkeletonRow({ columns = 6 }: { columns?: number }) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
{Array.from({ length: columns }).map((_, i) => (
|
|
||||||
<td key={i}>
|
|
||||||
<Skeleton
|
|
||||||
width={i === 0 ? 32 : i === 1 ? '70%' : '50%'}
|
|
||||||
height={i === 0 ? 32 : 14}
|
|
||||||
borderRadius={i === 0 ? '50%' : undefined}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the full content table. */
|
|
||||||
export function SkeletonTable({ rows = 8, columns = 6 }: { rows?: number; columns?: number }) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-4)' }}>
|
|
||||||
<table style={{ width: '100%' }}>
|
|
||||||
<tbody>
|
|
||||||
{Array.from({ length: rows }).map((_, i) => (
|
|
||||||
<SkeletonRow key={i} columns={columns} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the channel detail header. */
|
|
||||||
export function SkeletonChannelHeader() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
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={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)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the queue list page. */
|
|
||||||
export function SkeletonQueueList({ rows = 6 }: { rows?: number }) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-4)' }}>
|
|
||||||
{/* Tab row placeholder */}
|
|
||||||
<div style={{ display: 'flex', gap: '2px', marginBottom: 'var(--space-4)' }}>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} width={80} height={32} borderRadius="var(--radius-md) var(--radius-md) 0 0" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<SkeletonTable rows={rows} columns={7} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the library page. */
|
|
||||||
export function SkeletonLibrary({ rows = 8 }: { rows?: number }) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-4)' }}>
|
|
||||||
{/* Search + filters placeholder */}
|
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}>
|
|
||||||
<Skeleton width={240} height={36} borderRadius="var(--radius-md)" />
|
|
||||||
<Skeleton width={120} height={36} borderRadius="var(--radius-md)" />
|
|
||||||
<Skeleton width={100} height={36} borderRadius="var(--radius-md)" />
|
|
||||||
<Skeleton width={130} height={36} borderRadius="var(--radius-md)" />
|
|
||||||
</div>
|
|
||||||
<SkeletonTable rows={rows} columns={9} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the activity page. */
|
|
||||||
export function SkeletonActivityList({ rows = 6 }: { rows?: number }) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-4)' }}>
|
|
||||||
{/* Tab row placeholder */}
|
|
||||||
<div style={{ display: 'flex', gap: '2px', marginBottom: 'var(--space-4)' }}>
|
|
||||||
<Skeleton width={80} height={32} borderRadius="var(--radius-md) var(--radius-md) 0 0" />
|
|
||||||
<Skeleton width={80} height={32} borderRadius="var(--radius-md) var(--radius-md) 0 0" />
|
|
||||||
</div>
|
|
||||||
<SkeletonTable rows={rows} columns={5} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the system page. */
|
|
||||||
export function SkeletonSystem() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-4)' }}>
|
|
||||||
<Skeleton width={120} height={24} style={{ marginBottom: 'var(--space-6)' }} />
|
|
||||||
{/* Health card placeholder */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-card)',
|
|
||||||
borderRadius: 'var(--radius-lg)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: 'var(--space-4)',
|
|
||||||
marginBottom: 'var(--space-6)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', marginBottom: i < 2 ? 'var(--space-3)' : 0 }}>
|
|
||||||
<Skeleton width={14} height={14} borderRadius="50%" />
|
|
||||||
<Skeleton width={100} height={14} />
|
|
||||||
<Skeleton width={60} height={20} borderRadius="var(--radius-md)" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Status table placeholder */}
|
|
||||||
<Skeleton width={100} height={24} style={{ marginBottom: 'var(--space-4)' }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-card)',
|
|
||||||
borderRadius: 'var(--radius-lg)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: 'var(--space-4)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: i < 4 ? 'var(--space-3)' : 0 }}>
|
|
||||||
<Skeleton width={140} height={14} />
|
|
||||||
<Skeleton width={200} height={14} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the settings page. */
|
|
||||||
export function SkeletonSettings() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Skeleton width={120} height={28} style={{ marginBottom: 'var(--space-6)' }} />
|
|
||||||
|
|
||||||
{/* General section */}
|
|
||||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
|
||||||
<Skeleton width={20} height={20} borderRadius="var(--radius-sm)" />
|
|
||||||
<Skeleton width={80} height={20} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-card)',
|
|
||||||
borderRadius: 'var(--radius-xl)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: 'var(--space-4)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: i < 3 ? 'var(--space-4)' : 0, alignItems: 'center' }}>
|
|
||||||
<Skeleton width={140} height={14} />
|
|
||||||
<Skeleton width={250} height={32} borderRadius="var(--radius-md)" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Platform Settings section */}
|
|
||||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-4)' }}>
|
|
||||||
<Skeleton width={20} height={20} borderRadius="var(--radius-sm)" />
|
|
||||||
<Skeleton width={140} height={20} />
|
|
||||||
</div>
|
|
||||||
<SkeletonTable rows={2} columns={6} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Format Profiles section */}
|
|
||||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
|
||||||
<Skeleton width={130} height={20} />
|
|
||||||
<Skeleton width={110} height={32} borderRadius="var(--radius-md)" />
|
|
||||||
</div>
|
|
||||||
<SkeletonTable rows={3} columns={6} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications section */}
|
|
||||||
<div style={{ marginBottom: 'var(--space-8)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
|
||||||
<Skeleton width={120} height={20} />
|
|
||||||
<Skeleton width={110} height={32} borderRadius="var(--radius-md)" />
|
|
||||||
</div>
|
|
||||||
<SkeletonTable rows={2} columns={5} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton for the channels list page. */
|
|
||||||
export function SkeletonChannelsList({ rows = 4 }: { rows?: number }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-6)' }}>
|
|
||||||
<Skeleton width={120} height={28} />
|
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
|
||||||
<Skeleton width={110} height={34} borderRadius="var(--radius-md)" />
|
|
||||||
<Skeleton width={140} height={34} borderRadius="var(--radius-md)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-card)',
|
|
||||||
borderRadius: 'var(--radius-xl)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SkeletonTable rows={rows} columns={7} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { createContext, useContext, useState, useCallback, useRef } from 'react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
// ── Types ──
|
|
||||||
|
|
||||||
type ToastVariant = 'success' | 'error' | 'info';
|
|
||||||
|
|
||||||
interface ToastEntry {
|
|
||||||
id: number;
|
|
||||||
message: string;
|
|
||||||
variant: ToastVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastContextValue {
|
|
||||||
toast: (message: string, variant?: ToastVariant) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Context ──
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
||||||
|
|
||||||
export function useToast(): ToastContextValue {
|
|
||||||
const ctx = useContext(ToastContext);
|
|
||||||
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Provider ──
|
|
||||||
|
|
||||||
const TOAST_DURATION = 5000;
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [toasts, setToasts] = useState<ToastEntry[]>([]);
|
|
||||||
const nextId = useRef(0);
|
|
||||||
|
|
||||||
const toast = useCallback((message: string, variant: ToastVariant = 'info') => {
|
|
||||||
const id = ++nextId.current;
|
|
||||||
setToasts((prev) => [...prev, { id, message, variant }]);
|
|
||||||
setTimeout(() => {
|
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
||||||
}, TOAST_DURATION);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dismiss = useCallback((id: number) => {
|
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={{ toast }}>
|
|
||||||
{children}
|
|
||||||
{/* Toast container */}
|
|
||||||
{toasts.length > 0 && (
|
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 'var(--space-6)',
|
|
||||||
right: 'var(--space-6)',
|
|
||||||
zIndex: 1100,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{toasts.map((t) => (
|
|
||||||
<div
|
|
||||||
key={t.id}
|
|
||||||
role={t.variant === 'error' ? 'alert' : 'status'}
|
|
||||||
className="toast-enter"
|
|
||||||
style={{
|
|
||||||
padding: 'var(--space-3) var(--space-4)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
cursor: 'pointer',
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
onClick={() => dismiss(t.id)}
|
|
||||||
>
|
|
||||||
{t.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react';
|
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 { useSyncExternalStore } from 'react';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
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 ──
|
// ── Types ──
|
||||||
|
|
||||||
|
|
@ -35,39 +32,7 @@ interface DownloadFailedEvent {
|
||||||
|
|
||||||
type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent;
|
type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent;
|
||||||
|
|
||||||
// ── Scan Event Types ──
|
// ── Store (external to React for zero unnecessary re-renders) ──
|
||||||
|
|
||||||
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) ──
|
|
||||||
|
|
||||||
class ProgressStore {
|
class ProgressStore {
|
||||||
private _map = new Map<number, ProgressInfo>();
|
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 ──
|
// ── Context ──
|
||||||
|
|
||||||
interface DownloadProgressContextValue {
|
interface DownloadProgressContextValue {
|
||||||
|
|
@ -157,10 +70,6 @@ interface DownloadProgressContextValue {
|
||||||
getProgress: (contentItemId: number) => ProgressInfo | undefined;
|
getProgress: (contentItemId: number) => ProgressInfo | undefined;
|
||||||
/** Whether the WebSocket is connected */
|
/** Whether the WebSocket is connected */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/** Subscribe to scan store changes */
|
|
||||||
scanStoreSubscribe: (listener: () => void) => () => void;
|
|
||||||
/** Get scan store snapshot */
|
|
||||||
scanStoreGetSnapshot: () => Map<number, ScanProgress>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null);
|
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null);
|
||||||
|
|
@ -171,15 +80,13 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const storeRef = useRef(new ProgressStore());
|
const storeRef = useRef(new ProgressStore());
|
||||||
const store = storeRef.current;
|
const store = storeRef.current;
|
||||||
const scanStoreRef = useRef(new ScanStore());
|
|
||||||
const scanStore = scanStoreRef.current;
|
|
||||||
|
|
||||||
// Subscribe to the store with useSyncExternalStore for optimal re-renders
|
// Subscribe to the store with useSyncExternalStore for optimal re-renders
|
||||||
const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
(data: unknown) => {
|
(data: unknown) => {
|
||||||
const event = data as DownloadEvent | ScanEvent;
|
const event = data as DownloadEvent;
|
||||||
if (!event?.type) return;
|
if (!event?.type) return;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|
@ -193,12 +100,9 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
|
|
||||||
case 'download:complete':
|
case 'download:complete':
|
||||||
store.delete(event.contentItemId);
|
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: ['content'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['activity'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['library'] });
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'download:failed':
|
case 'download:failed':
|
||||||
|
|
@ -206,34 +110,10 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
// Invalidate to show updated status (failed)
|
// Invalidate to show updated status (failed)
|
||||||
queryClient.invalidateQueries({ queryKey: ['content'] });
|
queryClient.invalidateQueries({ queryKey: ['content'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[store, scanStore, queryClient],
|
[store, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isConnected } = useWebSocket({ onMessage: handleMessage });
|
const { isConnected } = useWebSocket({ onMessage: handleMessage });
|
||||||
|
|
@ -246,14 +126,7 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DownloadProgressContext.Provider
|
<DownloadProgressContext.Provider value={{ getProgress, isConnected }}>
|
||||||
value={{
|
|
||||||
getProgress,
|
|
||||||
isConnected,
|
|
||||||
scanStoreSubscribe: scanStore.subscribe,
|
|
||||||
scanStoreGetSnapshot: scanStore.getSnapshot,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</DownloadProgressContext.Provider>
|
</DownloadProgressContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
@ -280,54 +153,3 @@ export function useDownloadProgressConnection(): boolean {
|
||||||
const context = useContext(DownloadProgressContext);
|
const context = useContext(DownloadProgressContext);
|
||||||
return context?.isConnected ?? false;
|
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 {
|
|
||||||
const context = useContext(DownloadProgressContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useScanProgress must be used within a DownloadProgressProvider');
|
|
||||||
}
|
|
||||||
const scanMap = useSyncExternalStore(
|
|
||||||
context.scanStoreSubscribe,
|
|
||||||
context.scanStoreGetSnapshot,
|
|
||||||
);
|
|
||||||
return scanMap.get(channelId) ?? { scanning: false, newItemCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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,12 +1,10 @@
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { ActivityIcon, Clock, RefreshCw } from 'lucide-react';
|
import { ActivityIcon, Clock, Loader, RefreshCw } from 'lucide-react';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||||
import { SkeletonActivityList } from '../components/Skeleton';
|
|
||||||
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
|
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
|
||||||
import { formatRelativeTime } from '../utils/format';
|
|
||||||
import type { DownloadHistoryRecord } from '@shared/types/index';
|
import type { DownloadHistoryRecord } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
@ -22,6 +20,18 @@ function formatTimestamp(iso: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function formatEventType(type: string): string {
|
||||||
return type
|
return type
|
||||||
.split('_')
|
.split('_')
|
||||||
|
|
@ -268,7 +278,18 @@ export function ActivityPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetchHistory()}
|
onClick={() => refetchHistory()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
|
|
@ -277,7 +298,12 @@ export function ActivityPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{historyLoading && <SkeletonActivityList />}
|
{historyLoading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||||
|
Loading history…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{!historyLoading && (
|
{!historyLoading && (
|
||||||
|
|
@ -325,7 +351,18 @@ export function ActivityPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetchRecent()}
|
onClick={() => refetchRecent()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
|
|
@ -334,7 +371,12 @@ export function ActivityPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{recentLoading && <SkeletonActivityList />}
|
{recentLoading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||||
|
Loading recent activity…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Activity feed */}
|
{/* Activity feed */}
|
||||||
{!recentLoading && (
|
{!recentLoading && (
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
|
import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
|
||||||
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
|
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
|
||||||
|
|
@ -8,22 +8,41 @@ import { PlatformBadge } from '../components/PlatformBadge';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { ProgressBar } from '../components/ProgressBar';
|
import { ProgressBar } from '../components/ProgressBar';
|
||||||
import { AddChannelModal } from '../components/AddChannelModal';
|
import { AddChannelModal } from '../components/AddChannelModal';
|
||||||
import { SkeletonChannelsList } from '../components/Skeleton';
|
|
||||||
import { useToast } from '../components/Toast';
|
|
||||||
import { formatRelativeTime } from '../utils/format';
|
|
||||||
import type { ChannelWithCounts } from '@shared/types/api';
|
import type { ChannelWithCounts } from '@shared/types/api';
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Channels() {
|
export function Channels() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const { toast } = useToast();
|
const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null);
|
||||||
|
|
||||||
const { data: channels, isLoading, error, refetch } = useChannels();
|
const { data: channels, isLoading, error, refetch } = useChannels();
|
||||||
const scanAll = useScanAllChannels();
|
const scanAll = useScanAllChannels();
|
||||||
const collectAll = useCollectAllMonitored();
|
const collectAll = useCollectAllMonitored();
|
||||||
|
|
||||||
|
// Auto-dismiss scan result toast after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scanResult) return;
|
||||||
|
const timer = setTimeout(() => setScanResult(null), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [scanResult]);
|
||||||
|
|
||||||
const handleScanAll = useCallback(() => {
|
const handleScanAll = useCallback(() => {
|
||||||
scanAll.mutate(undefined, {
|
scanAll.mutate(undefined, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
|
@ -31,13 +50,16 @@ export function Channels() {
|
||||||
if (result.summary.errors > 0) {
|
if (result.summary.errors > 0) {
|
||||||
msg += ` (${result.summary.errors} error${result.summary.errors === 1 ? '' : 's'})`;
|
msg += ` (${result.summary.errors} error${result.summary.errors === 1 ? '' : 's'})`;
|
||||||
}
|
}
|
||||||
toast(msg, result.summary.errors > 0 ? 'error' : 'success');
|
setScanResult({ message: msg, isError: result.summary.errors > 0 });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast(err instanceof Error ? err.message : 'Scan failed', 'error');
|
setScanResult({
|
||||||
|
message: err instanceof Error ? err.message : 'Scan failed',
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [scanAll, toast]);
|
}, [scanAll]);
|
||||||
|
|
||||||
const handleCollectAll = useCallback(() => {
|
const handleCollectAll = useCallback(() => {
|
||||||
collectAll.mutate(undefined, {
|
collectAll.mutate(undefined, {
|
||||||
|
|
@ -47,13 +69,16 @@ export function Channels() {
|
||||||
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
||||||
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
|
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
|
||||||
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect';
|
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect';
|
||||||
toast(msg, result.errors > 0 ? 'error' : 'success');
|
setScanResult({ message: msg, isError: result.errors > 0 });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast(err instanceof Error ? err.message : 'Collect failed', 'error');
|
setScanResult({
|
||||||
|
message: err instanceof Error ? err.message : 'Collect failed',
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [collectAll, toast]);
|
}, [collectAll]);
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(channel: ChannelWithCounts) => {
|
(channel: ChannelWithCounts) => {
|
||||||
|
|
@ -159,7 +184,12 @@ export function Channels() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SkeletonChannelsList />;
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||||
|
Loading channels...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -183,7 +213,18 @@ export function Channels() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
|
|
@ -219,8 +260,20 @@ export function Channels() {
|
||||||
onClick={handleScanAll}
|
onClick={handleScanAll}
|
||||||
disabled={scanAll.isPending}
|
disabled={scanAll.isPending}
|
||||||
title="Refresh All"
|
title="Refresh All"
|
||||||
className="btn btn-ghost"
|
style={{
|
||||||
style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
opacity: scanAll.isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{scanAll.isPending ? (
|
{scanAll.isPending ? (
|
||||||
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
|
@ -234,8 +287,20 @@ export function Channels() {
|
||||||
onClick={handleCollectAll}
|
onClick={handleCollectAll}
|
||||||
disabled={collectAll.isPending}
|
disabled={collectAll.isPending}
|
||||||
title="Collect All Monitored"
|
title="Collect All Monitored"
|
||||||
className="btn btn-ghost"
|
style={{
|
||||||
style={{ opacity: collectAll.isPending ? 0.6 : 1 }}
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
opacity: collectAll.isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{collectAll.isPending ? (
|
{collectAll.isPending ? (
|
||||||
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
|
@ -248,8 +313,21 @@ export function Channels() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(true)}
|
onClick={() => setShowAddModal(true)}
|
||||||
disabled={scanAll.isPending}
|
disabled={scanAll.isPending}
|
||||||
className="btn btn-primary"
|
style={{
|
||||||
style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
|
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,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
opacity: scanAll.isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!scanAll.isPending) e.currentTarget.style.backgroundColor = 'var(--accent-hover)'; }}
|
||||||
|
onMouseLeave={(e) => { if (!scanAll.isPending) e.currentTarget.style.backgroundColor = 'var(--accent)'; }}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add Channel
|
Add Channel
|
||||||
|
|
@ -261,7 +339,7 @@ export function Channels() {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
|
@ -277,6 +355,28 @@ export function Channels() {
|
||||||
|
|
||||||
{/* Add Channel modal */}
|
{/* Add Channel modal */}
|
||||||
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
||||||
|
|
||||||
|
{/* Scan result toast */}
|
||||||
|
{scanResult && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'var(--space-6)',
|
||||||
|
right: 'var(--space-6)',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
backgroundColor: scanResult.isError ? 'var(--danger-bg)' : 'var(--success-bg)',
|
||||||
|
border: `1px solid ${scanResult.isError ? 'var(--danger)' : 'var(--success)'}`,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: scanResult.isError ? 'var(--danger)' : 'var(--success)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
zIndex: 1001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scanResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Library as LibraryIcon, RefreshCw, Film, Music } from 'lucide-react';
|
import { Library as LibraryIcon, Loader, RefreshCw, Film, Music } from 'lucide-react';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { QualityLabel } from '../components/QualityLabel';
|
import { QualityLabel } from '../components/QualityLabel';
|
||||||
|
|
@ -8,12 +8,29 @@ import { PlatformBadge } from '../components/PlatformBadge';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { SearchBar } from '../components/SearchBar';
|
import { SearchBar } from '../components/SearchBar';
|
||||||
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
|
||||||
import { SkeletonLibrary } from '../components/Skeleton';
|
|
||||||
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
|
||||||
import { useChannels } from '../api/hooks/useChannels';
|
import { useChannels } from '../api/hooks/useChannels';
|
||||||
import { formatDuration, formatFileSize } from '../utils/format';
|
|
||||||
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
|
import type { ContentItem, ContentStatus, ContentType } 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 formatFileSize(bytes: number | null): string {
|
||||||
|
if (bytes == null) return '—';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Library() {
|
export function Library() {
|
||||||
|
|
@ -318,7 +335,18 @@ export function Library() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
|
|
@ -327,7 +355,12 @@ export function Library() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{isLoading && <SkeletonLibrary />}
|
{isLoading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||||
|
Loading library…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content table */}
|
{/* Content table */}
|
||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { ListOrdered, RotateCcw, X, RefreshCw } from 'lucide-react';
|
import { ListOrdered, RotateCcw, X, Loader, RefreshCw } from 'lucide-react';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { SkeletonQueueList } from '../components/Skeleton';
|
|
||||||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
|
||||||
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
|
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
|
||||||
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
import type { QueueItem, QueueStatus } from '@shared/types/index';
|
||||||
|
|
||||||
|
|
@ -32,18 +29,6 @@ const STATUS_TABS: { value: QueueStatus | ''; label: string }[] = [
|
||||||
{ value: 'failed', label: 'Failed' },
|
{ value: 'failed', label: 'Failed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Queue item progress wrapper ──
|
|
||||||
|
|
||||||
function QueueItemProgress({ item }: { item: QueueItem }) {
|
|
||||||
const progress = useDownloadProgress(item.contentItemId);
|
|
||||||
|
|
||||||
if (item.status === 'downloading' && progress) {
|
|
||||||
return <DownloadProgressBar progress={progress} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <StatusBadge status={item.status} pulse={item.status === 'downloading'} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function Queue() {
|
export function Queue() {
|
||||||
|
|
@ -90,9 +75,14 @@ export function Queue() {
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
width: '160px',
|
width: '130px',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (item) => <QueueItemProgress item={item} />,
|
render: (item) => (
|
||||||
|
<StatusBadge
|
||||||
|
status={item.status}
|
||||||
|
pulse={item.status === 'downloading'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'priority',
|
key: 'priority',
|
||||||
|
|
@ -173,8 +163,20 @@ export function Queue() {
|
||||||
disabled={retryMutation.isPending}
|
disabled={retryMutation.isPending}
|
||||||
title="Retry"
|
title="Retry"
|
||||||
aria-label="Retry failed item"
|
aria-label="Retry failed item"
|
||||||
className="btn-icon"
|
style={{
|
||||||
style={{ color: 'var(--warning)' }}
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
padding: 0,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-input)',
|
||||||
|
color: 'var(--warning)',
|
||||||
|
cursor: retryMutation.isPending ? 'wait' : 'pointer',
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -188,8 +190,20 @@ export function Queue() {
|
||||||
disabled={cancelMutation.isPending}
|
disabled={cancelMutation.isPending}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
aria-label="Cancel pending item"
|
aria-label="Cancel pending item"
|
||||||
className="btn-icon"
|
style={{
|
||||||
style={{ color: 'var(--danger)' }}
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
padding: 0,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-input)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
cursor: cancelMutation.isPending ? 'wait' : 'pointer',
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -280,7 +294,18 @@ export function Queue() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
|
|
@ -311,7 +336,12 @@ export function Queue() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{isLoading && <SkeletonQueueList />}
|
{isLoading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||||
|
Loading queue…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Queue table */}
|
{/* Queue table */}
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import { Modal } from '../components/Modal';
|
||||||
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
||||||
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
||||||
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
||||||
import { SkeletonSettings } from '../components/Skeleton';
|
|
||||||
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Badge styles ──
|
// ── Badge styles ──
|
||||||
|
|
@ -41,6 +40,17 @@ const badgeBase: React.CSSProperties = {
|
||||||
letterSpacing: '0.04em',
|
letterSpacing: '0.04em',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iconButtonBase: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||||
|
};
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
|
|
@ -287,7 +297,15 @@ export function SettingsPage() {
|
||||||
onClick={(e) => { e.stopPropagation(); setEditingPlatform(row.platform); }}
|
onClick={(e) => { e.stopPropagation(); setEditingPlatform(row.platform); }}
|
||||||
title={`Edit ${row.label} settings`}
|
title={`Edit ${row.label} settings`}
|
||||||
aria-label={`Edit ${row.label} settings`}
|
aria-label={`Edit ${row.label} settings`}
|
||||||
className="btn-icon btn-icon-edit"
|
style={iconButtonBase}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--accent)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -461,7 +479,15 @@ export function SettingsPage() {
|
||||||
onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
|
onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
|
||||||
title="Edit profile"
|
title="Edit profile"
|
||||||
aria-label={`Edit ${p.name}`}
|
aria-label={`Edit ${p.name}`}
|
||||||
className="btn-icon btn-icon-edit"
|
style={iconButtonBase}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--accent)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -470,7 +496,15 @@ export function SettingsPage() {
|
||||||
onClick={(e) => { e.stopPropagation(); setDeletingProfile(p); }}
|
onClick={(e) => { e.stopPropagation(); setDeletingProfile(p); }}
|
||||||
title="Delete profile"
|
title="Delete profile"
|
||||||
aria-label={`Delete ${p.name}`}
|
aria-label={`Delete ${p.name}`}
|
||||||
className="btn-icon btn-icon-delete"
|
style={iconButtonBase}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--danger)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--danger-bg)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -553,8 +587,18 @@ export function SettingsPage() {
|
||||||
title="Send test notification"
|
title="Send test notification"
|
||||||
aria-label={`Test ${n.name}`}
|
aria-label={`Test ${n.name}`}
|
||||||
disabled={result === 'loading'}
|
disabled={result === 'loading'}
|
||||||
className="btn-icon btn-icon-test"
|
style={{
|
||||||
style={{ opacity: result === 'loading' ? 0.5 : 1 }}
|
...iconButtonBase,
|
||||||
|
opacity: result === 'loading' ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--success)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--success-bg)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{result === 'loading'
|
{result === 'loading'
|
||||||
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
|
@ -567,7 +611,15 @@ export function SettingsPage() {
|
||||||
onClick={(e) => { e.stopPropagation(); setEditingNotification(n); }}
|
onClick={(e) => { e.stopPropagation(); setEditingNotification(n); }}
|
||||||
title="Edit channel"
|
title="Edit channel"
|
||||||
aria-label={`Edit ${n.name}`}
|
aria-label={`Edit ${n.name}`}
|
||||||
className="btn-icon btn-icon-edit"
|
style={iconButtonBase}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--accent)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -577,7 +629,15 @@ export function SettingsPage() {
|
||||||
onClick={(e) => { e.stopPropagation(); setDeletingNotification(n); }}
|
onClick={(e) => { e.stopPropagation(); setDeletingNotification(n); }}
|
||||||
title="Delete channel"
|
title="Delete channel"
|
||||||
aria-label={`Delete ${n.name}`}
|
aria-label={`Delete ${n.name}`}
|
||||||
className="btn-icon btn-icon-delete"
|
style={iconButtonBase}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--danger)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--danger-bg)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -592,7 +652,12 @@ export function SettingsPage() {
|
||||||
// ── Loading state ──
|
// ── Loading state ──
|
||||||
|
|
||||||
if (profilesLoading) {
|
if (profilesLoading) {
|
||||||
return <SkeletonSettings />;
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||||
|
Loading settings...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Error state ──
|
// ── Error state ──
|
||||||
|
|
@ -618,7 +683,17 @@ export function SettingsPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetchProfiles()}
|
onClick={() => refetchProfiles()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
|
|
@ -650,7 +725,7 @@ export function SettingsPage() {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
|
@ -699,7 +774,15 @@ export function SettingsPage() {
|
||||||
onClick={() => setShowApiKey((v) => !v)}
|
onClick={() => setShowApiKey((v) => !v)}
|
||||||
title={showApiKey ? 'Hide API key' : 'Show API key'}
|
title={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||||
className="btn-icon btn-icon-edit"
|
style={iconButtonBase}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--accent)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -709,8 +792,22 @@ export function SettingsPage() {
|
||||||
onClick={handleCopyApiKey}
|
onClick={handleCopyApiKey}
|
||||||
title={copySuccess ? 'Copied!' : 'Copy to clipboard'}
|
title={copySuccess ? 'Copied!' : 'Copy to clipboard'}
|
||||||
aria-label="Copy API key to clipboard"
|
aria-label="Copy API key to clipboard"
|
||||||
className="btn-icon btn-icon-edit"
|
style={{
|
||||||
style={copySuccess ? { color: 'var(--success)' } : undefined}
|
...iconButtonBase,
|
||||||
|
color: copySuccess ? 'var(--success)' : 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!copySuccess) {
|
||||||
|
e.currentTarget.style.color = 'var(--accent)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--accent-subtle)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!copySuccess) {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{copySuccess ? <CheckCircle size={14} /> : <Copy size={14} />}
|
{copySuccess ? <CheckCircle size={14} /> : <Copy size={14} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -720,7 +817,15 @@ export function SettingsPage() {
|
||||||
onClick={() => setShowRegenerateConfirm(true)}
|
onClick={() => setShowRegenerateConfirm(true)}
|
||||||
title="Regenerate API key"
|
title="Regenerate API key"
|
||||||
aria-label="Regenerate API key"
|
aria-label="Regenerate API key"
|
||||||
className="btn-icon btn-icon-warning"
|
style={iconButtonBase}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--warning)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--warning-bg)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-muted)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RotateCw size={14} />
|
<RotateCw size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -806,10 +911,17 @@ export function SettingsPage() {
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveSettings}
|
onClick={handleSaveSettings}
|
||||||
disabled={!settingsDirty || !settingsValid || updateAppSettingsMutation.isPending}
|
disabled={!settingsDirty || !settingsValid || updateAppSettingsMutation.isPending}
|
||||||
className={`btn ${settingsSaveFlash ? 'btn-primary' : 'btn-primary'}`}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: settingsSaveFlash ? 'var(--success)' : undefined,
|
display: 'inline-flex',
|
||||||
borderColor: settingsSaveFlash ? 'var(--success)' : undefined,
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: settingsSaveFlash ? 'var(--success)' : 'var(--accent)',
|
||||||
|
color: 'var(--text-inverse)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'background-color var(--transition-fast), opacity var(--transition-fast)',
|
||||||
opacity: !settingsDirty || !settingsValid ? 0.5 : 1,
|
opacity: !settingsDirty || !settingsValid ? 0.5 : 1,
|
||||||
cursor: !settingsDirty || !settingsValid ? 'not-allowed' : 'pointer',
|
cursor: !settingsDirty || !settingsValid ? 'not-allowed' : 'pointer',
|
||||||
}}
|
}}
|
||||||
|
|
@ -851,7 +963,7 @@ export function SettingsPage() {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
|
@ -880,7 +992,20 @@ export function SettingsPage() {
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateProfileModal(true)}
|
onClick={() => setShowCreateProfileModal(true)}
|
||||||
className="btn btn-primary"
|
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,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent)')}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add Profile
|
Add Profile
|
||||||
|
|
@ -890,7 +1015,7 @@ export function SettingsPage() {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
|
@ -920,7 +1045,20 @@ export function SettingsPage() {
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateNotifModal(true)}
|
onClick={() => setShowCreateNotifModal(true)}
|
||||||
className="btn btn-primary"
|
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,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--accent)')}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add Channel
|
Add Channel
|
||||||
|
|
@ -930,7 +1068,7 @@ export function SettingsPage() {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-card)',
|
backgroundColor: 'var(--bg-card)',
|
||||||
borderRadius: 'var(--radius-xl)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
|
@ -1010,15 +1148,36 @@ export function SettingsPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeletingProfile(null)}
|
onClick={() => setDeletingProfile(null)}
|
||||||
disabled={deleteProfileMutation.isPending}
|
disabled={deleteProfileMutation.isPending}
|
||||||
className="btn btn-ghost"
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteProfile}
|
onClick={handleDeleteProfile}
|
||||||
disabled={deleteProfileMutation.isPending}
|
disabled={deleteProfileMutation.isPending}
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
style={{ opacity: deleteProfileMutation.isPending ? 0.6 : 1 }}
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
opacity: deleteProfileMutation.isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{deleteProfileMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
{deleteProfileMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -1112,15 +1271,36 @@ export function SettingsPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeletingNotification(null)}
|
onClick={() => setDeletingNotification(null)}
|
||||||
disabled={deleteNotifMutation.isPending}
|
disabled={deleteNotifMutation.isPending}
|
||||||
className="btn btn-ghost"
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteNotification}
|
onClick={handleDeleteNotification}
|
||||||
disabled={deleteNotifMutation.isPending}
|
disabled={deleteNotifMutation.isPending}
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
style={{ opacity: deleteNotifMutation.isPending ? 0.6 : 1 }}
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
opacity: deleteNotifMutation.isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{deleteNotifMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
{deleteNotifMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -1158,15 +1338,36 @@ export function SettingsPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRegenerateConfirm(false)}
|
onClick={() => setShowRegenerateConfirm(false)}
|
||||||
disabled={regenerateApiKeyMutation.isPending}
|
disabled={regenerateApiKeyMutation.isPending}
|
||||||
className="btn btn-ghost"
|
style={{
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-selected)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'var(--bg-hover)')}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRegenerateApiKey}
|
onClick={handleRegenerateApiKey}
|
||||||
disabled={regenerateApiKeyMutation.isPending}
|
disabled={regenerateApiKeyMutation.isPending}
|
||||||
className="btn btn-warning"
|
style={{
|
||||||
style={{ opacity: regenerateApiKeyMutation.isPending ? 0.6 : 1 }}
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-4)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--warning)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'background-color var(--transition-fast)',
|
||||||
|
opacity: regenerateApiKeyMutation.isPending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{regenerateApiKeyMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
{regenerateApiKeyMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
Regenerate
|
Regenerate
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
import { Loader, RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
|
||||||
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem';
|
import { useSystemStatus, useHealth } from '../api/hooks/useSystem';
|
||||||
import { HealthStatus } from '../components/HealthStatus';
|
import { HealthStatus } from '../components/HealthStatus';
|
||||||
import { SkeletonSystem } from '../components/Skeleton';
|
|
||||||
import { formatBytes } from '../utils/format';
|
import { formatBytes } from '../utils/format';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
@ -23,13 +22,16 @@ function formatUptime(seconds: number): string {
|
||||||
export function SystemPage() {
|
export function SystemPage() {
|
||||||
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
|
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
|
||||||
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
|
||||||
const { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
|
||||||
const updateYtDlp = useUpdateYtDlp();
|
|
||||||
|
|
||||||
const isLoading = healthLoading || statusLoading;
|
const isLoading = healthLoading || statusLoading;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SkeletonSystem />;
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-12)', color: 'var(--text-muted)' }}>
|
||||||
|
<Loader size={20} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-3)' }} />
|
||||||
|
Loading system info...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -50,7 +52,25 @@ export function SystemPage() {
|
||||||
onClick={() => refetchHealth()}
|
onClick={() => refetchHealth()}
|
||||||
title="Refresh health status"
|
title="Refresh health status"
|
||||||
aria-label="Refresh health status"
|
aria-label="Refresh health status"
|
||||||
className="btn btn-ghost"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-primary)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-selected)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Refresh
|
Refresh
|
||||||
|
|
@ -75,20 +95,24 @@ export function SystemPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetchHealth()}
|
onClick={() => refetchHealth()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : health ? (
|
) : health ? (
|
||||||
<HealthStatus
|
<HealthStatus components={health.components} overallStatus={health.status} />
|
||||||
components={health.components}
|
|
||||||
overallStatus={health.status}
|
|
||||||
ytdlpStatus={ytdlpStatus ?? null}
|
|
||||||
ytdlpLoading={ytdlpLoading}
|
|
||||||
updateYtDlp={updateYtDlp}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -103,7 +127,25 @@ export function SystemPage() {
|
||||||
onClick={() => refetchStatus()}
|
onClick={() => refetchStatus()}
|
||||||
title="Refresh system status"
|
title="Refresh system status"
|
||||||
aria-label="Refresh system status"
|
aria-label="Refresh system status"
|
||||||
className="btn btn-ghost"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
transition: 'color var(--transition-fast), background-color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-primary)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-selected)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--text-secondary)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Refresh
|
Refresh
|
||||||
|
|
@ -128,7 +170,17 @@ export function SystemPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => refetchStatus()}
|
onClick={() => refetchStatus()}
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
className="btn btn-danger"
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Retry
|
Retry
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
/* ── Global Reset & Base Styles ── */
|
/* ── Global Reset & Base Styles ── */
|
||||||
@import './theme.css';
|
@import './theme.css';
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|
@ -14,7 +13,6 @@ html {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -24,7 +22,6 @@ body {
|
||||||
background-color: var(--bg-main);
|
background-color: var(--bg-main);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
|
@ -35,7 +32,6 @@ body {
|
||||||
a {
|
a {
|
||||||
color: var(--text-link);
|
color: var(--text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color var(--transition-fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
|
|
@ -46,26 +42,25 @@ a:hover {
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scrollbar styling ── */
|
/* ── Scrollbar styling ── */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 8px;
|
||||||
height: 6px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: var(--bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--border-light);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons base ── */
|
/* ── Buttons base ── */
|
||||||
|
|
@ -76,7 +71,6 @@ button {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Inputs base ── */
|
/* ── Inputs base ── */
|
||||||
|
|
@ -87,17 +81,15 @@ select {
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--bg-input);
|
background-color: var(--bg-input);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,10 +106,10 @@ table {
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.03em;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
@ -144,161 +136,6 @@ tr:hover {
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Glassmorphism card ── */
|
|
||||||
.glass-card {
|
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(var(--glass-blur));
|
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton loader ── */
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--bg-input) 25%,
|
|
||||||
rgba(255, 255, 255, 0.04) 50%,
|
|
||||||
var(--bg-input) 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeleton-shimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Status badge glow ── */
|
|
||||||
.badge-pulse {
|
|
||||||
animation: badge-glow 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes badge-glow {
|
|
||||||
0%, 100% { box-shadow: none; }
|
|
||||||
50% { box-shadow: 0 0 8px var(--accent-glow); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Button utility classes ── */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background-color: var(--accent-hover);
|
|
||||||
border-color: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background-color: var(--bg-hover);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
|
||||||
background-color: var(--bg-selected);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background-color: var(--danger);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background-color: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-edit:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
background-color: var(--accent-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-delete:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
background-color: var(--danger-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-test:hover {
|
|
||||||
color: var(--success);
|
|
||||||
background-color: var(--success-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-warning:hover {
|
|
||||||
color: var(--warning);
|
|
||||||
background-color: var(--warning-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background-color: var(--warning);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning:hover:not(:disabled) {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal animation ── */
|
|
||||||
@keyframes modal-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(8px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Animations ── */
|
/* ── Animations ── */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
|
|
@ -309,51 +146,3 @@ tr:hover {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-up {
|
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Page transition ── */
|
|
||||||
main {
|
|
||||||
animation: fade-in 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Table row transitions ── */
|
|
||||||
tbody tr {
|
|
||||||
transition: background-color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:hover {
|
|
||||||
background-color: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card checkbox visibility on hover ── */
|
|
||||||
div:hover > .card-checkbox,
|
|
||||||
.card-checkbox:has(input:checked) {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@keyframes toast-slide-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
:root {
|
|
||||||
--sidebar-width: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,56 @@
|
||||||
/* ── *arr Dark Theme ──
|
/* ── *arr Dark Theme ──
|
||||||
* Color palette matching Sonarr/Radarr aesthetic with modern glassmorphism touches.
|
* Color palette matching Sonarr/Radarr aesthetic.
|
||||||
* All UI components reference these custom properties.
|
* All UI components reference these custom properties.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ── Backgrounds ── */
|
/* ── Backgrounds ── */
|
||||||
--bg-main: #0f1117;
|
--bg-main: #1a1d23;
|
||||||
--bg-sidebar: #0a0c10;
|
--bg-sidebar: #14161a;
|
||||||
--bg-card: rgba(30, 33, 44, 0.8);
|
--bg-card: #242731;
|
||||||
--bg-card-solid: #1e212c;
|
--bg-input: #2a2e38;
|
||||||
--bg-input: #1a1d26;
|
--bg-hover: #2f3341;
|
||||||
--bg-hover: rgba(255, 255, 255, 0.04);
|
--bg-selected: #35394a;
|
||||||
--bg-selected: rgba(255, 255, 255, 0.08);
|
--bg-header: #1e2029;
|
||||||
--bg-header: #13151c;
|
--bg-toolbar: #1e2129;
|
||||||
--bg-toolbar: #13151c;
|
--bg-modal-overlay: rgba(0, 0, 0, 0.6);
|
||||||
--bg-modal-overlay: rgba(0, 0, 0, 0.7);
|
|
||||||
--bg-glass: rgba(20, 22, 30, 0.6);
|
|
||||||
|
|
||||||
/* ── Accent ── */
|
/* ── Accent ── */
|
||||||
--accent: #e05d44;
|
--accent: #e05d44;
|
||||||
--accent-hover: #f06a51;
|
--accent-hover: #c94e38;
|
||||||
--accent-subtle: rgba(224, 93, 68, 0.1);
|
--accent-subtle: rgba(224, 93, 68, 0.12);
|
||||||
--accent-glow: rgba(224, 93, 68, 0.25);
|
|
||||||
|
|
||||||
/* ── Text ── */
|
/* ── Text ── */
|
||||||
--text-primary: #e8e9ed;
|
--text-primary: #e1e2e6;
|
||||||
--text-secondary: #8b8d97;
|
--text-secondary: #8b8d97;
|
||||||
--text-muted: #4d5060;
|
--text-muted: #5d5f69;
|
||||||
--text-inverse: #0f1117;
|
--text-inverse: #14161a;
|
||||||
--text-link: #e05d44;
|
--text-link: #e05d44;
|
||||||
|
|
||||||
/* ── Status colors ── */
|
/* ── Status colors ── */
|
||||||
--success: #34d058;
|
--success: #27c24c;
|
||||||
--success-bg: rgba(52, 208, 88, 0.1);
|
--success-bg: rgba(39, 194, 76, 0.12);
|
||||||
--warning: #ff9f43;
|
--warning: #ff902b;
|
||||||
--warning-bg: rgba(255, 159, 67, 0.1);
|
--warning-bg: rgba(255, 144, 43, 0.12);
|
||||||
--danger: #f05050;
|
--danger: #f05050;
|
||||||
--danger-bg: rgba(240, 80, 80, 0.1);
|
--danger-bg: rgba(240, 80, 80, 0.12);
|
||||||
--info: #e05d44;
|
--info: #e05d44;
|
||||||
--info-bg: rgba(224, 93, 68, 0.1);
|
--info-bg: rgba(224, 93, 68, 0.12);
|
||||||
|
|
||||||
/* ── Borders ── */
|
/* ── Borders ── */
|
||||||
--border: rgba(255, 255, 255, 0.06);
|
--border: #2d3040;
|
||||||
--border-light: rgba(255, 255, 255, 0.1);
|
--border-light: #373b4e;
|
||||||
--border-accent: rgba(224, 93, 68, 0.3);
|
|
||||||
|
|
||||||
/* ── Typography ── */
|
/* ── Typography ── */
|
||||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
--font-size-xs: 0.6875rem;
|
--font-size-xs: 0.75rem;
|
||||||
--font-size-sm: 0.8125rem;
|
--font-size-sm: 0.8125rem;
|
||||||
--font-size-base: 0.875rem;
|
--font-size-base: 0.875rem;
|
||||||
--font-size-md: 1rem;
|
--font-size-md: 1rem;
|
||||||
--font-size-lg: 1.125rem;
|
--font-size-lg: 1.125rem;
|
||||||
--font-size-xl: 1.375rem;
|
--font-size-xl: 1.25rem;
|
||||||
--font-size-2xl: 1.75rem;
|
--font-size-2xl: 1.5rem;
|
||||||
|
|
||||||
/* ── Spacing ── */
|
/* ── Spacing ── */
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
|
|
@ -68,32 +64,22 @@
|
||||||
--space-12: 3rem;
|
--space-12: 3rem;
|
||||||
|
|
||||||
/* ── Border Radius ── */
|
/* ── Border Radius ── */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 3px;
|
||||||
--radius-md: 6px;
|
--radius-md: 4px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 6px;
|
||||||
--radius-xl: 14px;
|
--radius-xl: 8px;
|
||||||
--radius-2xl: 20px;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
|
|
||||||
/* ── Shadows ── */
|
/* ── Shadows ── */
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-glow: 0 0 20px rgba(224, 93, 68, 0.15);
|
|
||||||
|
|
||||||
/* ── Layout ── */
|
/* ── Layout ── */
|
||||||
--sidebar-width: 220px;
|
--sidebar-width: 210px;
|
||||||
--sidebar-collapsed: 56px;
|
--sidebar-collapsed: 50px;
|
||||||
--header-height: 56px;
|
--header-height: 55px;
|
||||||
|
|
||||||
/* ── Transitions ── */
|
/* ── Transitions ── */
|
||||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
--transition-fast: 150ms ease;
|
||||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
--transition-normal: 250ms ease;
|
||||||
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
|
|
||||||
/* ── Glassmorphism ── */
|
|
||||||
--glass-blur: 12px;
|
|
||||||
--glass-bg: rgba(20, 22, 30, 0.6);
|
|
||||||
--glass-border: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Shared formatting utilities for the Tubearr frontend.
|
* Format a byte count into a human-readable string (B, KB, MB, GB, TB).
|
||||||
*
|
|
||||||
* Consolidates format helpers that were previously duplicated across
|
|
||||||
* ChannelDetail, Library, ContentCard, ContentListItem, Channels, Activity.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Format a byte count into a human-readable string (B, KB, MB, GB, TB). */
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
|
@ -13,50 +8,3 @@ export function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||||
return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
|
return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a file size in bytes to a human-readable string with appropriate precision. */
|
|
||||||
export function formatFileSize(bytes: number | null): string {
|
|
||||||
if (bytes == null) return '—';
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format seconds into h:mm:ss or m:ss. */
|
|
||||||
export 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')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format an ISO date string as a relative time (e.g. "2h ago", "3d ago"). */
|
|
||||||
export 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`;
|
|
||||||
const years = Math.floor(months / 12);
|
|
||||||
return `${years}y ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a subscriber/follower count to a compact string (e.g. "6.7M"). */
|
|
||||||
export function formatSubscriberCount(count: number | null): string | null {
|
|
||||||
if (count == null) return null;
|
|
||||||
if (count < 1000) return `${count}`;
|
|
||||||
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`;
|
|
||||||
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
|
||||||
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
23
src/index.ts
23
src/index.ts
|
|
@ -22,9 +22,7 @@ import { HealthService } from './services/health';
|
||||||
import { PlatformRegistry } from './sources/platform-source';
|
import { PlatformRegistry } from './sources/platform-source';
|
||||||
import { YouTubeSource } from './sources/youtube';
|
import { YouTubeSource } from './sources/youtube';
|
||||||
import { SoundCloudSource } from './sources/soundcloud';
|
import { SoundCloudSource } from './sources/soundcloud';
|
||||||
import { GenericSource } from './sources/generic';
|
|
||||||
import { Platform } from './types/index';
|
import { Platform } from './types/index';
|
||||||
import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp';
|
|
||||||
import type { ViteDevServer } from 'vite';
|
import type { ViteDevServer } from 'vite';
|
||||||
|
|
||||||
const APP_NAME = 'Tubearr';
|
const APP_NAME = 'Tubearr';
|
||||||
|
|
@ -46,25 +44,6 @@ async function main(): Promise<void> {
|
||||||
await seedAppDefaults(db);
|
await seedAppDefaults(db);
|
||||||
console.log(`[${APP_NAME}] App settings seeded`);
|
console.log(`[${APP_NAME}] App settings seeded`);
|
||||||
|
|
||||||
// 2d. Check yt-dlp version and auto-update if configured
|
|
||||||
try {
|
|
||||||
const version = await getYtDlpVersion();
|
|
||||||
if (version) {
|
|
||||||
console.log(`[${APP_NAME}] yt-dlp version: ${version}`);
|
|
||||||
// Auto-update on startup (non-blocking — continue if it fails)
|
|
||||||
if (appConfig.nodeEnv === 'production') {
|
|
||||||
const result = await updateYtDlp();
|
|
||||||
if (result.updated) {
|
|
||||||
console.log(`[${APP_NAME}] yt-dlp updated: ${result.previousVersion} → ${result.version}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`[${APP_NAME}] yt-dlp not found on PATH — downloads will fail`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[${APP_NAME}] yt-dlp check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Build and configure Fastify server
|
// 3. Build and configure Fastify server
|
||||||
// In dev mode, embed Vite for HMR — single port, no separate frontend process
|
// In dev mode, embed Vite for HMR — single port, no separate frontend process
|
||||||
let vite: ViteDevServer | undefined;
|
let vite: ViteDevServer | undefined;
|
||||||
|
|
@ -139,7 +118,6 @@ async function main(): Promise<void> {
|
||||||
const platformRegistry = new PlatformRegistry();
|
const platformRegistry = new PlatformRegistry();
|
||||||
platformRegistry.register(Platform.YouTube, new YouTubeSource());
|
platformRegistry.register(Platform.YouTube, new YouTubeSource());
|
||||||
platformRegistry.register(Platform.SoundCloud, new SoundCloudSource());
|
platformRegistry.register(Platform.SoundCloud, new SoundCloudSource());
|
||||||
platformRegistry.register(Platform.Generic, new GenericSource());
|
|
||||||
|
|
||||||
scheduler = new SchedulerService(db, platformRegistry, rateLimiter, {
|
scheduler = new SchedulerService(db, platformRegistry, rateLimiter, {
|
||||||
onNewContent: (contentItemId: number) => {
|
onNewContent: (contentItemId: number) => {
|
||||||
|
|
@ -150,7 +128,6 @@ async function main(): Promise<void> {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
eventBus,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach scheduler to server so routes can notify it
|
// Attach scheduler to server so routes can notify it
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import { PlatformRegistry } from '../../sources/platform-source';
|
import { PlatformRegistry } from '../../sources/platform-source';
|
||||||
import { YouTubeSource } from '../../sources/youtube';
|
import { YouTubeSource } from '../../sources/youtube';
|
||||||
import { SoundCloudSource } from '../../sources/soundcloud';
|
import { SoundCloudSource } from '../../sources/soundcloud';
|
||||||
import { GenericSource } from '../../sources/generic';
|
|
||||||
import { YtDlpError } from '../../sources/yt-dlp';
|
import { YtDlpError } from '../../sources/yt-dlp';
|
||||||
import { Platform } from '../../types/index';
|
import { Platform } from '../../types/index';
|
||||||
import type { MonitoringMode } from '../../types/index';
|
import type { MonitoringMode } from '../../types/index';
|
||||||
|
|
@ -26,7 +24,6 @@ function buildDefaultRegistry(): PlatformRegistry {
|
||||||
const registry = new PlatformRegistry();
|
const registry = new PlatformRegistry();
|
||||||
registry.register(Platform.YouTube, new YouTubeSource());
|
registry.register(Platform.YouTube, new YouTubeSource());
|
||||||
registry.register(Platform.SoundCloud, new SoundCloudSource());
|
registry.register(Platform.SoundCloud, new SoundCloudSource());
|
||||||
registry.register(Platform.Generic, new GenericSource());
|
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,10 +154,10 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
// Derive monitoringEnabled from monitoringMode when provided;
|
// Derive monitoringEnabled from monitoringMode when provided;
|
||||||
// preserve backward compat when only monitoringEnabled is sent
|
// preserve backward compat when only monitoringEnabled is sent
|
||||||
const resolvedMode = monitoringMode ?? 'none';
|
const resolvedMode = monitoringMode ?? 'all';
|
||||||
const resolvedEnabled = monitoringMode
|
const resolvedEnabled = monitoringMode
|
||||||
? monitoringMode !== 'none'
|
? monitoringMode !== 'none'
|
||||||
: (monitoringEnabled ?? false);
|
: (monitoringEnabled ?? true);
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
{ resolvedMode, resolvedEnabled, platform: metadata.platform },
|
{ resolvedMode, resolvedEnabled, platform: metadata.platform },
|
||||||
|
|
@ -179,9 +176,6 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
imageUrl: metadata.imageUrl,
|
imageUrl: metadata.imageUrl,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
formatProfileId: formatProfileId ?? null,
|
formatProfileId: formatProfileId ?? null,
|
||||||
bannerUrl: metadata.bannerUrl ?? null,
|
|
||||||
description: metadata.description ?? null,
|
|
||||||
subscriberCount: metadata.subscriberCount ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify scheduler of new channel
|
// Notify scheduler of new channel
|
||||||
|
|
@ -233,8 +227,14 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id',
|
'/api/v1/channel/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
@ -260,8 +260,14 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
schema: { body: updateChannelBodySchema },
|
schema: { body: updateChannelBodySchema },
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await updateChannel(fastify.db, id, request.body);
|
const updated = await updateChannel(fastify.db, id, request.body);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|
@ -288,8 +294,14 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
'/api/v1/channel/:id/monitoring-mode',
|
'/api/v1/channel/:id/monitoring-mode',
|
||||||
{ schema: { body: monitoringModeBodySchema } },
|
{ schema: { body: monitoringModeBodySchema } },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await setMonitoringMode(
|
const result = await setMonitoringMode(
|
||||||
|
|
@ -326,8 +338,14 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id',
|
'/api/v1/channel/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Verify channel exists before deleting
|
// Verify channel exists before deleting
|
||||||
const existing = await getChannelById(fastify.db, id);
|
const existing = await getChannelById(fastify.db, id);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import { getCollectibleItems } from '../../db/repositories/content-repository';
|
import { getCollectibleItems } from '../../db/repositories/content-repository';
|
||||||
import { getChannelById } from '../../db/repositories/channel-repository';
|
import { getChannelById } from '../../db/repositories/channel-repository';
|
||||||
|
|
||||||
|
|
@ -58,8 +57,14 @@ export async function collectRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/collect',
|
'/api/v1/channel/:id/collect',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import {
|
import {
|
||||||
getAllContentItems,
|
getAllContentItems,
|
||||||
getContentByChannelId,
|
getContentByChannelId,
|
||||||
getChannelContentPaginated,
|
|
||||||
setMonitored,
|
setMonitored,
|
||||||
bulkSetMonitored,
|
bulkSetMonitored,
|
||||||
} from '../../db/repositories/content-repository';
|
} from '../../db/repositories/content-repository';
|
||||||
|
|
@ -156,8 +154,14 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
'/api/v1/content/:id/monitored',
|
'/api/v1/content/:id/monitored',
|
||||||
{ schema: { body: toggleMonitoredBodySchema } },
|
{ schema: { body: toggleMonitoredBodySchema } },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Content item ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Content item ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await setMonitored(
|
const result = await setMonitored(
|
||||||
|
|
@ -198,63 +202,23 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
fastify.get<{
|
fastify.get<{
|
||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Querystring: {
|
|
||||||
page?: string;
|
|
||||||
pageSize?: string;
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
contentType?: string;
|
|
||||||
sortBy?: string;
|
|
||||||
sortDirection?: string;
|
|
||||||
};
|
|
||||||
}>('/api/v1/channel/:id/content', async (request, reply) => {
|
}>('/api/v1/channel/:id/content', async (request, reply) => {
|
||||||
const channelId = parseIdParam(request.params.id, reply, 'Channel ID');
|
const channelId = parseInt(request.params.id, 10);
|
||||||
if (channelId === null) return;
|
|
||||||
|
|
||||||
const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1);
|
if (isNaN(channelId)) {
|
||||||
const pageSize = Math.min(
|
return reply.status(400).send({
|
||||||
200,
|
statusCode: 400,
|
||||||
Math.max(1, parseInt(request.query.pageSize ?? '50', 10) || 50)
|
error: 'Bad Request',
|
||||||
);
|
message: 'Invalid channel ID',
|
||||||
|
});
|
||||||
// If no pagination params provided, return all items (backwards-compatible)
|
}
|
||||||
const hasPaginationParams = request.query.page || request.query.pageSize || request.query.search || request.query.status || request.query.contentType || request.query.sortBy;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasPaginationParams) {
|
const items = await getContentByChannelId(fastify.db, channelId);
|
||||||
// Legacy mode: return all items as flat array (backwards-compatible)
|
|
||||||
const items = await getContentByChannelId(fastify.db, channelId);
|
|
||||||
const response: ApiResponse<ContentItem[]> = {
|
|
||||||
success: true,
|
|
||||||
data: items,
|
|
||||||
};
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paginated mode with filters
|
const response: ApiResponse<ContentItem[]> = {
|
||||||
const result = await getChannelContentPaginated(
|
|
||||||
fastify.db,
|
|
||||||
channelId,
|
|
||||||
{
|
|
||||||
search: request.query.search || undefined,
|
|
||||||
status: (request.query.status as ContentStatus) || undefined,
|
|
||||||
contentType: (request.query.contentType as ContentType) || undefined,
|
|
||||||
sortBy: request.query.sortBy as 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt' | undefined,
|
|
||||||
sortDirection: (request.query.sortDirection as 'asc' | 'desc') || undefined,
|
|
||||||
},
|
|
||||||
page,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
|
|
||||||
const response: PaginatedResponse<ContentItem> = {
|
|
||||||
success: true,
|
success: true,
|
||||||
data: result.items,
|
data: items,
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalItems: result.total,
|
|
||||||
totalPages: Math.ceil(result.total / pageSize),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import {
|
import {
|
||||||
createFormatProfile,
|
createFormatProfile,
|
||||||
getAllFormatProfiles,
|
getAllFormatProfiles,
|
||||||
|
|
@ -22,9 +21,6 @@ const createFormatProfileBodySchema = {
|
||||||
isDefault: { type: 'boolean' as const },
|
isDefault: { type: 'boolean' as const },
|
||||||
subtitleLanguages: { type: 'string' as const, nullable: true },
|
subtitleLanguages: { type: 'string' as const, nullable: true },
|
||||||
embedSubtitles: { type: 'boolean' as const },
|
embedSubtitles: { type: 'boolean' as const },
|
||||||
embedChapters: { type: 'boolean' as const },
|
|
||||||
embedThumbnail: { type: 'boolean' as const },
|
|
||||||
sponsorBlockRemove: { type: 'string' as const, nullable: true },
|
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -40,9 +36,6 @@ const updateFormatProfileBodySchema = {
|
||||||
isDefault: { type: 'boolean' as const },
|
isDefault: { type: 'boolean' as const },
|
||||||
subtitleLanguages: { type: 'string' as const, nullable: true },
|
subtitleLanguages: { type: 'string' as const, nullable: true },
|
||||||
embedSubtitles: { type: 'boolean' as const },
|
embedSubtitles: { type: 'boolean' as const },
|
||||||
embedChapters: { type: 'boolean' as const },
|
|
||||||
embedThumbnail: { type: 'boolean' as const },
|
|
||||||
sponsorBlockRemove: { type: 'string' as const, nullable: true },
|
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
@ -71,7 +64,7 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
containerFormat?: string | null;
|
containerFormat?: string | null;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean; embedChapters?: boolean; embedThumbnail?: boolean; sponsorBlockRemove?: string | null;
|
embedSubtitles?: boolean;
|
||||||
};
|
};
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/format-profile',
|
'/api/v1/format-profile',
|
||||||
|
|
@ -95,8 +88,14 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/format-profile/:id',
|
'/api/v1/format-profile/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Format profile ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Format profile ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const profile = await getFormatProfileById(fastify.db, id);
|
const profile = await getFormatProfileById(fastify.db, id);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
|
|
@ -123,7 +122,7 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
containerFormat?: string | null;
|
containerFormat?: string | null;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
subtitleLanguages?: string | null;
|
subtitleLanguages?: string | null;
|
||||||
embedSubtitles?: boolean; embedChapters?: boolean; embedThumbnail?: boolean; sponsorBlockRemove?: string | null;
|
embedSubtitles?: boolean;
|
||||||
};
|
};
|
||||||
}>(
|
}>(
|
||||||
'/api/v1/format-profile/:id',
|
'/api/v1/format-profile/:id',
|
||||||
|
|
@ -131,8 +130,14 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
schema: { body: updateFormatProfileBodySchema },
|
schema: { body: updateFormatProfileBodySchema },
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Format profile ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Format profile ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: prevent unsetting isDefault on the default profile
|
// Guard: prevent unsetting isDefault on the default profile
|
||||||
const existing = await getFormatProfileById(fastify.db, id);
|
const existing = await getFormatProfileById(fastify.db, id);
|
||||||
|
|
@ -169,8 +174,14 @@ export async function formatProfileRoutes(fastify: FastifyInstance): Promise<voi
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/format-profile/:id',
|
'/api/v1/format-profile/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Format profile ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Format profile ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: prevent deleting the default profile
|
// Guard: prevent deleting the default profile
|
||||||
const profile = await getFormatProfileById(fastify.db, id);
|
const profile = await getFormatProfileById(fastify.db, id);
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import type { FastifyReply } from 'fastify';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a numeric ID from route params.
|
|
||||||
* Returns the parsed number, or sends a 400 response and returns null.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const id = parseIdParam(request.params.id, reply);
|
|
||||||
* if (id === null) return;
|
|
||||||
*/
|
|
||||||
export function parseIdParam(
|
|
||||||
raw: string,
|
|
||||||
reply: FastifyReply,
|
|
||||||
label = 'ID',
|
|
||||||
): number | null {
|
|
||||||
const id = parseInt(raw, 10);
|
|
||||||
if (isNaN(id)) {
|
|
||||||
reply.status(400).send({
|
|
||||||
statusCode: 400,
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: `${label} must be a number`,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import {
|
import {
|
||||||
createNotificationSetting,
|
createNotificationSetting,
|
||||||
getAllNotificationSettings,
|
getAllNotificationSettings,
|
||||||
|
|
@ -123,8 +122,14 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/notification/:id',
|
'/api/v1/notification/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Notification setting ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const setting = await getNotificationSettingById(fastify.db, id);
|
const setting = await getNotificationSettingById(fastify.db, id);
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
|
|
@ -158,8 +163,14 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
schema: { body: updateNotificationBodySchema },
|
schema: { body: updateNotificationBodySchema },
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Notification setting ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await updateNotificationSetting(fastify.db, id, request.body);
|
const updated = await updateNotificationSetting(fastify.db, id, request.body);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|
@ -179,8 +190,14 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/notification/:id',
|
'/api/v1/notification/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Notification setting ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const deleted = await deleteNotificationSetting(fastify.db, id);
|
const deleted = await deleteNotificationSetting(fastify.db, id);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
|
|
@ -200,8 +217,14 @@ export async function notificationRoutes(fastify: FastifyInstance): Promise<void
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/notification/:id/test',
|
'/api/v1/notification/:id/test',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Notification setting ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Notification setting ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const setting = await getNotificationSettingById(fastify.db, id);
|
const setting = await getNotificationSettingById(fastify.db, id);
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import { PlatformRegistry } from '../../sources/platform-source';
|
import { PlatformRegistry } from '../../sources/platform-source';
|
||||||
import { YouTubeSource } from '../../sources/youtube';
|
import { YouTubeSource } from '../../sources/youtube';
|
||||||
import { SoundCloudSource } from '../../sources/soundcloud';
|
import { SoundCloudSource } from '../../sources/soundcloud';
|
||||||
|
|
@ -37,8 +36,14 @@ export async function playlistRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/playlists',
|
'/api/v1/channel/:id/playlists',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
@ -61,8 +66,14 @@ export async function playlistRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/playlists/refresh',
|
'/api/v1/channel/:id/playlists/refresh',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import {
|
import {
|
||||||
getQueueItemsByStatus,
|
getQueueItemsByStatus,
|
||||||
getQueueItemById,
|
getQueueItemById,
|
||||||
|
|
@ -58,8 +57,14 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/queue/:id',
|
'/api/v1/queue/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Queue item ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const item = await getQueueItemById(fastify.db, id);
|
const item = await getQueueItemById(fastify.db, id);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
|
|
@ -136,8 +141,14 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
fastify.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/queue/:id',
|
'/api/v1/queue/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Queue item ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!fastify.queueService) {
|
if (!fastify.queueService) {
|
||||||
return reply.status(503).send({
|
return reply.status(503).send({
|
||||||
|
|
@ -179,8 +190,14 @@ export async function queueRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/queue/:id/retry',
|
'/api/v1/queue/:id/retry',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Queue item ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Queue item ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!fastify.queueService) {
|
if (!fastify.queueService) {
|
||||||
return reply.status(503).send({
|
return reply.status(503).send({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { type FastifyInstance } from 'fastify';
|
import { type FastifyInstance } from 'fastify';
|
||||||
import { parseIdParam } from './helpers';
|
|
||||||
import type { CheckChannelResult } from '../../services/scheduler';
|
import type { CheckChannelResult } from '../../services/scheduler';
|
||||||
import {
|
import {
|
||||||
getChannelById,
|
getChannelById,
|
||||||
|
|
@ -76,8 +75,14 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post<{ Params: { id: string } }>(
|
fastify.post<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/scan',
|
'/api/v1/channel/:id/scan',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(fastify.db, id);
|
const channel = await getChannelById(fastify.db, id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
|
|
@ -96,55 +101,8 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already scanning — return immediately
|
const result = await fastify.scheduler.checkChannel(channel);
|
||||||
if (fastify.scheduler.isScanning(id)) {
|
return result;
|
||||||
return reply.status(200).send({
|
|
||||||
channelId: id,
|
|
||||||
channelName: channel.name,
|
|
||||||
newItems: 0,
|
|
||||||
totalFetched: 0,
|
|
||||||
status: 'already_running',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire-and-forget: start the scan in the background
|
|
||||||
// Progress is streamed via WebSocket scan events
|
|
||||||
console.log(`[scan] Fire-and-forget scan for channel ${id} — returning 202`);
|
|
||||||
fastify.scheduler.checkChannel(channel).catch((err) => {
|
|
||||||
fastify.log.error(
|
|
||||||
{ err, channelId: id },
|
|
||||||
'[scan] Background scan failed for channel %d',
|
|
||||||
id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
reply.status(202).send({
|
|
||||||
channelId: id,
|
|
||||||
channelName: channel.name,
|
|
||||||
status: 'started',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── POST /api/v1/channel/:id/scan-cancel ──
|
|
||||||
|
|
||||||
fastify.post<{ Params: { id: string } }>(
|
|
||||||
'/api/v1/channel/:id/scan-cancel',
|
|
||||||
async (request, reply) => {
|
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
|
||||||
if (id === null) return;
|
|
||||||
|
|
||||||
if (!fastify.scheduler) {
|
|
||||||
return reply.status(503).send({
|
|
||||||
statusCode: 503,
|
|
||||||
error: 'Service Unavailable',
|
|
||||||
message: 'Scheduler is not running',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelled = fastify.scheduler.cancelScan(id);
|
|
||||||
return { channelId: id, cancelled };
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -153,8 +111,14 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
'/api/v1/channel/:id/scan-status',
|
'/api/v1/channel/:id/scan-status',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseIdParam(request.params.id, reply, 'Channel ID');
|
const id = parseInt(request.params.id, 10);
|
||||||
if (id === null) return;
|
if (isNaN(id)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Channel ID must be a number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const scanning = fastify.scheduler?.isScanning(id) ?? false;
|
const scanning = fastify.scheduler?.isScanning(id) ?? false;
|
||||||
return { scanning };
|
return { scanning };
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,15 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { appConfig } from '../../config/index';
|
import { appConfig } from '../../config/index';
|
||||||
import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '../../types/api';
|
import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse } from '../../types/api';
|
||||||
import { systemConfig } from '../../db/schema/index';
|
import { systemConfig } from '../../db/schema/index';
|
||||||
import { API_KEY_DB_KEY } from '../middleware/auth';
|
import { API_KEY_DB_KEY } from '../middleware/auth';
|
||||||
import {
|
import {
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
getAppSetting,
|
|
||||||
setAppSetting,
|
setAppSetting,
|
||||||
APP_CHECK_INTERVAL,
|
APP_CHECK_INTERVAL,
|
||||||
APP_CONCURRENT_DOWNLOADS,
|
APP_CONCURRENT_DOWNLOADS,
|
||||||
YTDLP_LAST_UPDATED,
|
|
||||||
} from '../../db/repositories/system-config-repository';
|
} from '../../db/repositories/system-config-repository';
|
||||||
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -168,8 +165,8 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
await setAppSetting(db, APP_CONCURRENT_DOWNLOADS, body.concurrentDownloads.toString());
|
await setAppSetting(db, APP_CONCURRENT_DOWNLOADS, body.concurrentDownloads.toString());
|
||||||
|
|
||||||
// Update queue concurrency at runtime
|
// Update queue concurrency at runtime
|
||||||
if (fastify.queueService?.setConcurrency) {
|
if ((fastify as any).queueService?.setConcurrency) {
|
||||||
fastify.queueService.setConcurrency(body.concurrentDownloads);
|
(fastify as any).queueService.setConcurrency(body.concurrentDownloads);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,44 +179,4 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── yt-dlp Status & Update ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/v1/system/ytdlp/status — Current yt-dlp version and last-updated timestamp.
|
|
||||||
*/
|
|
||||||
fastify.get('/api/v1/system/ytdlp/status', async (_request, _reply) => {
|
|
||||||
const db = fastify.db;
|
|
||||||
const [version, lastUpdated] = await Promise.all([
|
|
||||||
getYtDlpVersion(),
|
|
||||||
getAppSetting(db, YTDLP_LAST_UPDATED),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const response: YtDlpStatusResponse = { version, lastUpdated };
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/v1/system/ytdlp/update — Trigger a yt-dlp update and persist the timestamp.
|
|
||||||
*/
|
|
||||||
fastify.post('/api/v1/system/ytdlp/update', async (request, _reply) => {
|
|
||||||
const db = fastify.db;
|
|
||||||
const result = await updateYtDlp();
|
|
||||||
|
|
||||||
const lastUpdated = new Date().toISOString();
|
|
||||||
await setAppSetting(db, YTDLP_LAST_UPDATED, lastUpdated);
|
|
||||||
|
|
||||||
request.log.info(
|
|
||||||
{ updated: result.updated, version: result.version, previousVersion: result.previousVersion },
|
|
||||||
'[system] yt-dlp update check completed'
|
|
||||||
);
|
|
||||||
|
|
||||||
const response: YtDlpUpdateResponse = {
|
|
||||||
updated: result.updated,
|
|
||||||
version: result.version,
|
|
||||||
previousVersion: result.previousVersion,
|
|
||||||
lastUpdated,
|
|
||||||
};
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import type {
|
import type { DownloadEventBus, DownloadProgressPayload, DownloadCompletePayload, DownloadFailedPayload } from '../../services/event-bus';
|
||||||
DownloadEventBus,
|
|
||||||
DownloadProgressPayload,
|
|
||||||
DownloadCompletePayload,
|
|
||||||
DownloadFailedPayload,
|
|
||||||
ScanStartedPayload,
|
|
||||||
ScanItemDiscoveredPayload,
|
|
||||||
ScanCompletePayload,
|
|
||||||
ScanErrorPayload,
|
|
||||||
} from '../../services/event-bus';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket route plugin.
|
* WebSocket route plugin.
|
||||||
|
|
@ -48,43 +39,16 @@ export async function websocketRoutes(
|
||||||
sendJson(socket, { type: 'download:failed', ...data });
|
sendJson(socket, { type: 'download:failed', ...data });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscribe to download events
|
// Subscribe to event bus
|
||||||
eventBus.onDownload('download:progress', onProgress);
|
eventBus.onDownload('download:progress', onProgress);
|
||||||
eventBus.onDownload('download:complete', onComplete);
|
eventBus.onDownload('download:complete', onComplete);
|
||||||
eventBus.onDownload('download:failed', onFailed);
|
eventBus.onDownload('download:failed', onFailed);
|
||||||
|
|
||||||
// Create listeners for scan event types
|
|
||||||
const onScanStarted = (data: ScanStartedPayload) => {
|
|
||||||
sendJson(socket, { type: 'scan:started', ...data });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScanItemDiscovered = (data: ScanItemDiscoveredPayload) => {
|
|
||||||
sendJson(socket, { type: 'scan:item-discovered', ...data });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScanComplete = (data: ScanCompletePayload) => {
|
|
||||||
sendJson(socket, { type: 'scan:complete', ...data });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScanError = (data: ScanErrorPayload) => {
|
|
||||||
sendJson(socket, { type: 'scan:error', ...data });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subscribe to scan events
|
|
||||||
eventBus.onScan('scan:started', onScanStarted);
|
|
||||||
eventBus.onScan('scan:item-discovered', onScanItemDiscovered);
|
|
||||||
eventBus.onScan('scan:complete', onScanComplete);
|
|
||||||
eventBus.onScan('scan:error', onScanError);
|
|
||||||
|
|
||||||
// Cleanup on disconnect
|
// Cleanup on disconnect
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
eventBus.offDownload('download:progress', onProgress);
|
eventBus.offDownload('download:progress', onProgress);
|
||||||
eventBus.offDownload('download:complete', onComplete);
|
eventBus.offDownload('download:complete', onComplete);
|
||||||
eventBus.offDownload('download:failed', onFailed);
|
eventBus.offDownload('download:failed', onFailed);
|
||||||
eventBus.offScan('scan:started', onScanStarted);
|
|
||||||
eventBus.offScan('scan:item-discovered', onScanItemDiscovered);
|
|
||||||
eventBus.offScan('scan:complete', onScanComplete);
|
|
||||||
eventBus.offScan('scan:error', onScanError);
|
|
||||||
console.log('[websocket] client disconnected');
|
console.log('[websocket] client disconnected');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { extname } from 'node:path';
|
||||||
import { createInterface } from 'node:readline';
|
import { createInterface } from 'node:readline';
|
||||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
import type * as schema from '../db/schema/index';
|
import type * as schema from '../db/schema/index';
|
||||||
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
|
import { execYtDlp, spawnYtDlp, YtDlpError } from '../sources/yt-dlp';
|
||||||
import { updateContentItem } from '../db/repositories/content-repository';
|
import { updateContentItem } from '../db/repositories/content-repository';
|
||||||
import { parseProgressLine } from './progress-parser';
|
import { parseProgressLine } from './progress-parser';
|
||||||
import type { DownloadEventBus } from './event-bus';
|
import type { DownloadEventBus } from './event-bus';
|
||||||
|
|
@ -137,22 +137,16 @@ export class DownloadService {
|
||||||
// Report error to rate limiter
|
// Report error to rate limiter
|
||||||
this.rateLimiter.reportError(channel.platform as Platform);
|
this.rateLimiter.reportError(channel.platform as Platform);
|
||||||
|
|
||||||
// Classify the error for better retry decisions
|
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
const stderr = err instanceof YtDlpError ? err.stderr : '';
|
|
||||||
const errorCategory = classifyYtDlpError(stderr || errorMsg);
|
|
||||||
|
|
||||||
// Update status to failed
|
// Update status to failed
|
||||||
await updateContentItem(this.db, contentItem.id, { status: 'failed' });
|
await updateContentItem(this.db, contentItem.id, { status: 'failed' });
|
||||||
|
|
||||||
console.log(
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
`${logPrefix} status=failed category=${errorCategory} error="${errorMsg.slice(0, 200)}"`
|
console.log(`${logPrefix} status=failed error="${errorMsg.slice(0, 200)}"`);
|
||||||
);
|
|
||||||
|
|
||||||
// Emit download:failed event with error category
|
// Emit download:failed event
|
||||||
this.eventBus?.emitDownload('download:failed', {
|
this.eventBus?.emitDownload('download:failed', {
|
||||||
contentItemId: contentItem.id,
|
contentItemId: contentItem.id,
|
||||||
error: `[${errorCategory}] ${errorMsg.slice(0, 200)}`,
|
error: errorMsg.slice(0, 200),
|
||||||
});
|
});
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
|
|
@ -271,24 +265,6 @@ export class DownloadService {
|
||||||
// Subtitle support
|
// Subtitle support
|
||||||
args.push(...this.buildSubtitleArgs(formatProfile));
|
args.push(...this.buildSubtitleArgs(formatProfile));
|
||||||
|
|
||||||
// Chapter embedding
|
|
||||||
if (formatProfile?.embedChapters) {
|
|
||||||
args.push('--embed-chapters');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thumbnail embedding
|
|
||||||
if (formatProfile?.embedThumbnail) {
|
|
||||||
args.push('--embed-thumbnail');
|
|
||||||
}
|
|
||||||
|
|
||||||
// SponsorBlock segment removal
|
|
||||||
if (formatProfile?.sponsorBlockRemove) {
|
|
||||||
const categories = formatProfile.sponsorBlockRemove.trim();
|
|
||||||
if (categories) {
|
|
||||||
args.push('--sponsorblock-remove', categories);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always include these flags
|
// Always include these flags
|
||||||
args.push('--no-playlist');
|
args.push('--no-playlist');
|
||||||
args.push('--print', 'after_move:filepath');
|
args.push('--print', 'after_move:filepath');
|
||||||
|
|
@ -312,32 +288,32 @@ export class DownloadService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build format args for video content.
|
* Build format args for video content.
|
||||||
* Uses a fallback chain: preferred resolution → best available → any.
|
|
||||||
* yt-dlp supports `/` as a fallback separator: `format1/format2/format3`.
|
|
||||||
*/
|
*/
|
||||||
private buildVideoArgs(formatProfile?: FormatProfile): string[] {
|
private buildVideoArgs(formatProfile?: FormatProfile): string[] {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
const container = formatProfile?.containerFormat ?? 'mp4';
|
|
||||||
|
|
||||||
if (formatProfile?.videoResolution === 'Best') {
|
if (formatProfile?.videoResolution === 'Best') {
|
||||||
// Best quality: separate streams merged
|
// "Best" selects separate best-quality video + audio streams, merged together.
|
||||||
args.push('-f', 'bestvideo+bestaudio/bestvideo*+bestaudio/best');
|
// This is higher quality than `-f best` which picks a single combined format.
|
||||||
|
args.push('-f', 'bestvideo+bestaudio/best');
|
||||||
|
const container = formatProfile.containerFormat ?? 'mp4';
|
||||||
args.push('--merge-output-format', container);
|
args.push('--merge-output-format', container);
|
||||||
} else if (formatProfile?.videoResolution) {
|
} else if (formatProfile?.videoResolution) {
|
||||||
const height = parseResolutionHeight(formatProfile.videoResolution);
|
const height = parseResolutionHeight(formatProfile.videoResolution);
|
||||||
if (height) {
|
if (height) {
|
||||||
// Fallback chain: exact res → best under res → single best stream → any
|
|
||||||
args.push(
|
args.push(
|
||||||
'-f',
|
'-f',
|
||||||
`bestvideo[height<=${height}]+bestaudio/bestvideo[height<=${height}]*+bestaudio/best[height<=${height}]/bestvideo+bestaudio/best`
|
`bestvideo[height<=${height}]+bestaudio/best[height<=${height}]`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
args.push('-f', 'bestvideo+bestaudio/best');
|
args.push('-f', 'best');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Container format for merge
|
||||||
|
const container = formatProfile.containerFormat ?? 'mp4';
|
||||||
args.push('--merge-output-format', container);
|
args.push('--merge-output-format', container);
|
||||||
} else {
|
} else {
|
||||||
args.push('-f', 'bestvideo+bestaudio/best');
|
args.push('-f', 'best');
|
||||||
args.push('--merge-output-format', container);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
|
|
@ -391,33 +367,18 @@ export class DownloadService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the final file path from yt-dlp stdout.
|
* Parse the final file path from yt-dlp stdout.
|
||||||
* The `--print after_move:filepath` flag makes yt-dlp output the final path.
|
* The `--print after_move:filepath` flag makes yt-dlp output the final path
|
||||||
*
|
* as the last line of stdout.
|
||||||
* Strategy: walk backwards through lines, skipping known yt-dlp output prefixes
|
|
||||||
* (e.g. [download], [Merger], [ExtractAudio], Deleting).
|
|
||||||
* A valid path line should be an absolute path or at least contain a file extension.
|
|
||||||
*/
|
*/
|
||||||
private parseFinalPath(stdout: string, fallbackPath: string): string {
|
private parseFinalPath(stdout: string, fallbackPath: string): string {
|
||||||
const lines = stdout.trim().split('\n');
|
const lines = stdout.trim().split('\n');
|
||||||
|
// The filepath from --print is typically the last non-empty line
|
||||||
// Known non-path prefixes from yt-dlp output
|
|
||||||
const NON_PATH_PREFIXES = ['[', 'Deleting', 'WARNING:', 'ERROR:', 'Merging', 'Post-process'];
|
|
||||||
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
if (!line) continue;
|
if (line && !line.startsWith('[') && !line.startsWith('Deleting')) {
|
||||||
|
|
||||||
// Skip known yt-dlp output lines
|
|
||||||
const isNonPath = NON_PATH_PREFIXES.some((prefix) => line.startsWith(prefix));
|
|
||||||
if (isNonPath) continue;
|
|
||||||
|
|
||||||
// A valid path should have a file extension or start with /
|
|
||||||
if (line.startsWith('/') || /\.\w{2,5}$/.test(line)) {
|
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('[download] Could not parse final path from yt-dlp output, using fallback');
|
|
||||||
return fallbackPath;
|
return fallbackPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { ContentItem } from '../types/index';
|
|
||||||
|
|
||||||
// ── Download Event Payload Types ──
|
// ── Event Payload Types ──
|
||||||
|
|
||||||
export interface DownloadProgressPayload {
|
export interface DownloadProgressPayload {
|
||||||
contentItemId: number;
|
contentItemId: number;
|
||||||
|
|
@ -19,33 +18,7 @@ export interface DownloadFailedPayload {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scan Event Payload Types ──
|
// ── Event Map ──
|
||||||
|
|
||||||
export interface ScanStartedPayload {
|
|
||||||
channelId: number;
|
|
||||||
channelName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScanItemDiscoveredPayload {
|
|
||||||
channelId: number;
|
|
||||||
channelName: string;
|
|
||||||
item: ContentItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScanCompletePayload {
|
|
||||||
channelId: number;
|
|
||||||
channelName: string;
|
|
||||||
newItems: number;
|
|
||||||
totalFetched: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScanErrorPayload {
|
|
||||||
channelId: number;
|
|
||||||
channelName: string;
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event Maps ──
|
|
||||||
|
|
||||||
export interface DownloadEventMap {
|
export interface DownloadEventMap {
|
||||||
'download:progress': [DownloadProgressPayload];
|
'download:progress': [DownloadProgressPayload];
|
||||||
|
|
@ -53,23 +26,17 @@ export interface DownloadEventMap {
|
||||||
'download:failed': [DownloadFailedPayload];
|
'download:failed': [DownloadFailedPayload];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanEventMap {
|
|
||||||
'scan:started': [ScanStartedPayload];
|
|
||||||
'scan:item-discovered': [ScanItemDiscoveredPayload];
|
|
||||||
'scan:complete': [ScanCompletePayload];
|
|
||||||
'scan:error': [ScanErrorPayload];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Typed Event Bus ──
|
// ── Typed Event Bus ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typed EventEmitter for download and scan events.
|
* Typed EventEmitter for download events.
|
||||||
* Decouples event producers (DownloadService, SchedulerService) from
|
* Decouples download progress producers (DownloadService) from
|
||||||
* consumers (WebSocket route, logging, etc).
|
* consumers (WebSocket route, logging, etc).
|
||||||
*/
|
*/
|
||||||
export class EventBus extends EventEmitter {
|
export class DownloadEventBus extends EventEmitter {
|
||||||
// ── Download events ──
|
/**
|
||||||
|
* Emit a typed download event.
|
||||||
|
*/
|
||||||
emitDownload<K extends keyof DownloadEventMap>(
|
emitDownload<K extends keyof DownloadEventMap>(
|
||||||
event: K,
|
event: K,
|
||||||
...args: DownloadEventMap[K]
|
...args: DownloadEventMap[K]
|
||||||
|
|
@ -77,6 +44,9 @@ export class EventBus extends EventEmitter {
|
||||||
return this.emit(event, ...args);
|
return this.emit(event, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a typed download event.
|
||||||
|
*/
|
||||||
onDownload<K extends keyof DownloadEventMap>(
|
onDownload<K extends keyof DownloadEventMap>(
|
||||||
event: K,
|
event: K,
|
||||||
listener: (...args: DownloadEventMap[K]) => void
|
listener: (...args: DownloadEventMap[K]) => void
|
||||||
|
|
@ -84,38 +54,13 @@ export class EventBus extends EventEmitter {
|
||||||
return this.on(event, listener as (...args: unknown[]) => void);
|
return this.on(event, listener as (...args: unknown[]) => void);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from a typed download event.
|
||||||
|
*/
|
||||||
offDownload<K extends keyof DownloadEventMap>(
|
offDownload<K extends keyof DownloadEventMap>(
|
||||||
event: K,
|
event: K,
|
||||||
listener: (...args: DownloadEventMap[K]) => void
|
listener: (...args: DownloadEventMap[K]) => void
|
||||||
): this {
|
): this {
|
||||||
return this.off(event, listener as (...args: unknown[]) => void);
|
return this.off(event, listener as (...args: unknown[]) => void);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scan events ──
|
|
||||||
|
|
||||||
emitScan<K extends keyof ScanEventMap>(
|
|
||||||
event: K,
|
|
||||||
...args: ScanEventMap[K]
|
|
||||||
): boolean {
|
|
||||||
return this.emit(event, ...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
onScan<K extends keyof ScanEventMap>(
|
|
||||||
event: K,
|
|
||||||
listener: (...args: ScanEventMap[K]) => void
|
|
||||||
): this {
|
|
||||||
return this.on(event, listener as (...args: unknown[]) => void);
|
|
||||||
}
|
|
||||||
|
|
||||||
offScan<K extends keyof ScanEventMap>(
|
|
||||||
event: K,
|
|
||||||
listener: (...args: ScanEventMap[K]) => void
|
|
||||||
): this {
|
|
||||||
return this.off(event, listener as (...args: unknown[]) => void);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use EventBus instead. */
|
|
||||||
export const DownloadEventBus = EventBus;
|
|
||||||
/** @deprecated Use EventBus instead. */
|
|
||||||
export type DownloadEventBus = EventBus;
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ import { Cron } from 'croner';
|
||||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
import type * as schema from '../db/schema/index';
|
import type * as schema from '../db/schema/index';
|
||||||
import type { Channel, Platform, PlatformContentMetadata } from '../types/index';
|
import type { Channel, Platform, PlatformContentMetadata } from '../types/index';
|
||||||
import type { PlatformRegistry, PlatformSource, FetchRecentContentOptions } from '../sources/platform-source';
|
import type { PlatformRegistry, FetchRecentContentOptions } from '../sources/platform-source';
|
||||||
import type { RateLimiter } from './rate-limiter';
|
import type { RateLimiter } from './rate-limiter';
|
||||||
import { YtDlpError } from '../sources/yt-dlp';
|
import { YtDlpError } from '../sources/yt-dlp';
|
||||||
import type { EventBus } from './event-bus';
|
|
||||||
import {
|
import {
|
||||||
getEnabledChannels,
|
getEnabledChannels,
|
||||||
updateChannel,
|
updateChannel,
|
||||||
|
|
@ -13,8 +12,6 @@ import {
|
||||||
import {
|
import {
|
||||||
createContentItem,
|
createContentItem,
|
||||||
getRecentContentIds,
|
getRecentContentIds,
|
||||||
getContentByPlatformContentId,
|
|
||||||
updateContentItem,
|
|
||||||
} from '../db/repositories/content-repository';
|
} from '../db/repositories/content-repository';
|
||||||
import { getPlatformSettings } from '../db/repositories/platform-settings-repository';
|
import { getPlatformSettings } from '../db/repositories/platform-settings-repository';
|
||||||
|
|
||||||
|
|
@ -48,8 +45,6 @@ export interface CheckChannelResult {
|
||||||
export interface SchedulerOptions {
|
export interface SchedulerOptions {
|
||||||
/** Called when a new content item is inserted — used to auto-enqueue for download. */
|
/** Called when a new content item is inserted — used to auto-enqueue for download. */
|
||||||
onNewContent?: (contentItemId: number) => void;
|
onNewContent?: (contentItemId: number) => void;
|
||||||
/** Event bus for broadcasting scan lifecycle events to WebSocket clients. */
|
|
||||||
eventBus?: EventBus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scheduler Service ──
|
// ── Scheduler Service ──
|
||||||
|
|
@ -66,11 +61,9 @@ export class SchedulerService {
|
||||||
private readonly platformRegistry: PlatformRegistry;
|
private readonly platformRegistry: PlatformRegistry;
|
||||||
private readonly rateLimiter: RateLimiter;
|
private readonly rateLimiter: RateLimiter;
|
||||||
private readonly onNewContent?: (contentItemId: number) => void;
|
private readonly onNewContent?: (contentItemId: number) => void;
|
||||||
private readonly eventBus?: EventBus;
|
|
||||||
private readonly jobs = new Map<number, Cron>();
|
private readonly jobs = new Map<number, Cron>();
|
||||||
private readonly channelCache = new Map<number, Channel>();
|
private readonly channelCache = new Map<number, Channel>();
|
||||||
private readonly activeChecks = new Set<number>();
|
private readonly activeChecks = new Set<number>();
|
||||||
private readonly activeAbortControllers = new Map<number, AbortController>();
|
|
||||||
private running = false;
|
private running = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -83,7 +76,6 @@ export class SchedulerService {
|
||||||
this.platformRegistry = platformRegistry;
|
this.platformRegistry = platformRegistry;
|
||||||
this.rateLimiter = rateLimiter;
|
this.rateLimiter = rateLimiter;
|
||||||
this.onNewContent = options?.onNewContent;
|
this.onNewContent = options?.onNewContent;
|
||||||
this.eventBus = options?.eventBus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -157,7 +149,7 @@ export class SchedulerService {
|
||||||
*
|
*
|
||||||
* Returns a structured result with item counts and status.
|
* Returns a structured result with item counts and status.
|
||||||
*/
|
*/
|
||||||
async checkChannel(channel: Channel, signal?: AbortSignal): Promise<CheckChannelResult> {
|
async checkChannel(channel: Channel): Promise<CheckChannelResult> {
|
||||||
// Per-channel lock — reject overlap before any async work
|
// Per-channel lock — reject overlap before any async work
|
||||||
if (this.activeChecks.has(channel.id)) {
|
if (this.activeChecks.has(channel.id)) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -172,22 +164,7 @@ export class SchedulerService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create AbortController for this scan if no external signal provided
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const effectiveSignal = signal ?? abortController.signal;
|
|
||||||
// Link external signal to our controller if provided
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener('abort', () => abortController.abort(signal.reason), { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeChecks.add(channel.id);
|
this.activeChecks.add(channel.id);
|
||||||
this.activeAbortControllers.set(channel.id, abortController);
|
|
||||||
|
|
||||||
// Emit scan:started before any async work
|
|
||||||
this.eventBus?.emitScan('scan:started', {
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[scheduler] Checking channel ${channel.id} ("${channel.name}") on ${channel.platform}`
|
`[scheduler] Checking channel ${channel.id} ("${channel.name}") on ${channel.platform}`
|
||||||
|
|
@ -207,26 +184,19 @@ export class SchedulerService {
|
||||||
|
|
||||||
// 3. Load platform settings for scan limit and rate limit delay
|
// 3. Load platform settings for scan limit and rate limit delay
|
||||||
const platformSettingsRow = await getPlatformSettings(this.db, channel.platform);
|
const platformSettingsRow = await getPlatformSettings(this.db, channel.platform);
|
||||||
const baseScanLimit = platformSettingsRow?.scanLimit ?? 500;
|
const scanLimit = platformSettingsRow?.scanLimit ?? 100;
|
||||||
const rateLimitDelay = platformSettingsRow?.rateLimitDelay ?? 1000;
|
const rateLimitDelay = platformSettingsRow?.rateLimitDelay ?? 1000;
|
||||||
|
|
||||||
// First scan (lastCheckedAt === null) → grab full catalog up to 999
|
|
||||||
const scanLimit = channel.lastCheckedAt === null
|
|
||||||
? Math.max(baseScanLimit, 999)
|
|
||||||
: baseScanLimit;
|
|
||||||
|
|
||||||
// 4. Load existing content IDs for dedup gating
|
// 4. Load existing content IDs for dedup gating
|
||||||
const existingIds = new Set(
|
const existingIds = new Set(
|
||||||
await getRecentContentIds(this.db, channel.id)
|
await getRecentContentIds(this.db, channel.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Fetch content — discovery-only (fast Phase 1, skip slow enrichment)
|
// 5. Fetch recent content with hybrid options
|
||||||
const fetchOptions: FetchRecentContentOptions = {
|
const fetchOptions: FetchRecentContentOptions = {
|
||||||
limit: scanLimit,
|
limit: scanLimit,
|
||||||
existingIds,
|
existingIds,
|
||||||
rateLimitDelay,
|
rateLimitDelay,
|
||||||
discoveryOnly: true,
|
|
||||||
signal: effectiveSignal,
|
|
||||||
};
|
};
|
||||||
const items: PlatformContentMetadata[] =
|
const items: PlatformContentMetadata[] =
|
||||||
await source.fetchRecentContent(channel, fetchOptions);
|
await source.fetchRecentContent(channel, fetchOptions);
|
||||||
|
|
@ -236,28 +206,9 @@ export class SchedulerService {
|
||||||
(item) => !existingIds.has(item.platformContentId)
|
(item) => !existingIds.has(item.platformContentId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. Insert new items (check abort between each)
|
// 7. Insert new items
|
||||||
let insertedCount = 0;
|
let insertedCount = 0;
|
||||||
for (const item of newItems) {
|
for (const item of newItems) {
|
||||||
// Check if scan was cancelled
|
|
||||||
if (effectiveSignal.aborted) {
|
|
||||||
console.log(
|
|
||||||
`[scheduler] Scan cancelled for channel ${channel.id} ("${channel.name}") after ${insertedCount} items`
|
|
||||||
);
|
|
||||||
this.eventBus?.emitScan('scan:complete', {
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
newItems: insertedCount,
|
|
||||||
totalFetched: items.length,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
newItems: insertedCount,
|
|
||||||
totalFetched: items.length,
|
|
||||||
status: 'success',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Scheduler discovers *new* content (future), so 'all' and 'future' → monitored
|
// Scheduler discovers *new* content (future), so 'all' and 'future' → monitored
|
||||||
const monitored = channel.monitoringMode === 'all' || channel.monitoringMode === 'future';
|
const monitored = channel.monitoringMode === 'all' || channel.monitoringMode === 'future';
|
||||||
const created = await createContentItem(this.db, {
|
const created = await createContentItem(this.db, {
|
||||||
|
|
@ -274,12 +225,6 @@ export class SchedulerService {
|
||||||
});
|
});
|
||||||
if (created) {
|
if (created) {
|
||||||
insertedCount++;
|
insertedCount++;
|
||||||
// Broadcast the new item to WebSocket clients
|
|
||||||
this.eventBus?.emitScan('scan:item-discovered', {
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
item: created,
|
|
||||||
});
|
|
||||||
// Only auto-enqueue monitored items
|
// Only auto-enqueue monitored items
|
||||||
if (this.onNewContent && created.monitored) {
|
if (this.onNewContent && created.monitored) {
|
||||||
this.onNewContent(created.id);
|
this.onNewContent(created.id);
|
||||||
|
|
@ -299,26 +244,6 @@ export class SchedulerService {
|
||||||
`[scheduler] Check complete for channel ${channel.id}: ${insertedCount} new items (${items.length} fetched, ${existingIds.size} existing)`
|
`[scheduler] Check complete for channel ${channel.id}: ${insertedCount} new items (${items.length} fetched, ${existingIds.size} existing)`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.eventBus?.emitScan('scan:complete', {
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
newItems: insertedCount,
|
|
||||||
totalFetched: items.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 9. Background Phase 2: enrich newly inserted items with full metadata
|
|
||||||
// This runs after the scan result is returned — enrichment updates DB records
|
|
||||||
// and triggers a final cache invalidation when done.
|
|
||||||
if (insertedCount > 0 && !effectiveSignal.aborted) {
|
|
||||||
this.enrichNewItems(channel, newItems, existingIds, rateLimitDelay, source, effectiveSignal)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(
|
|
||||||
`[scheduler] Background enrichment failed for channel ${channel.id}:`,
|
|
||||||
err instanceof Error ? err.message : err
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
channelName: channel.name,
|
channelName: channel.name,
|
||||||
|
|
@ -347,12 +272,6 @@ export class SchedulerService {
|
||||||
|
|
||||||
this.rateLimiter.reportError(channel.platform);
|
this.rateLimiter.reportError(channel.platform);
|
||||||
|
|
||||||
this.eventBus?.emitScan('scan:error', {
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
`[scheduler] Check failed for channel ${channel.id} ("${channel.name}"): ${status}`,
|
`[scheduler] Check failed for channel ${channel.id} ("${channel.name}"): ${status}`,
|
||||||
err instanceof Error ? err.message : err
|
err instanceof Error ? err.message : err
|
||||||
|
|
@ -367,7 +286,6 @@ export class SchedulerService {
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
this.activeChecks.delete(channel.id);
|
this.activeChecks.delete(channel.id);
|
||||||
this.activeAbortControllers.delete(channel.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,18 +296,6 @@ export class SchedulerService {
|
||||||
return this.activeChecks.has(channelId);
|
return this.activeChecks.has(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel an in-progress scan for a channel.
|
|
||||||
* Returns true if a scan was running and was cancelled.
|
|
||||||
*/
|
|
||||||
cancelScan(channelId: number): boolean {
|
|
||||||
const controller = this.activeAbortControllers.get(channelId);
|
|
||||||
if (!controller) return false;
|
|
||||||
controller.abort('User cancelled');
|
|
||||||
console.log(`[scheduler] Scan cancel requested for channel ${channelId}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current state of the scheduler for diagnostic inspection.
|
* Get the current state of the scheduler for diagnostic inspection.
|
||||||
*/
|
*/
|
||||||
|
|
@ -416,76 +322,6 @@ export class SchedulerService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Background Phase 2: re-fetch full metadata for newly discovered items
|
|
||||||
* and update their DB records with enriched data (publishedAt, duration, etc).
|
|
||||||
* Runs after the main scan completes — items are already visible to the user.
|
|
||||||
*/
|
|
||||||
private async enrichNewItems(
|
|
||||||
channel: Channel,
|
|
||||||
discoveredItems: PlatformContentMetadata[],
|
|
||||||
existingIds: Set<string>,
|
|
||||||
rateLimitDelay: number,
|
|
||||||
source: PlatformSource,
|
|
||||||
signal: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
const newPlatformIds = discoveredItems
|
|
||||||
.filter((item) => !existingIds.has(item.platformContentId))
|
|
||||||
.map((item) => item.platformContentId);
|
|
||||||
|
|
||||||
if (newPlatformIds.length === 0) return;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[scheduler] Phase 2: enriching ${newPlatformIds.length} items for channel ${channel.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-fetch with enrichment (discoveryOnly: false)
|
|
||||||
const enrichedItems = await source.fetchRecentContent(channel, {
|
|
||||||
limit: newPlatformIds.length + existingIds.size,
|
|
||||||
existingIds,
|
|
||||||
rateLimitDelay,
|
|
||||||
discoveryOnly: false,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build lookup by platformContentId
|
|
||||||
const enrichedMap = new Map<string, PlatformContentMetadata>();
|
|
||||||
for (const item of enrichedItems) {
|
|
||||||
enrichedMap.set(item.platformContentId, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update DB records with enriched data
|
|
||||||
let enrichedCount = 0;
|
|
||||||
for (const platformContentId of newPlatformIds) {
|
|
||||||
if (signal.aborted) break;
|
|
||||||
|
|
||||||
const enriched = enrichedMap.get(platformContentId);
|
|
||||||
if (!enriched) continue;
|
|
||||||
|
|
||||||
// Look up the DB record by platformContentId
|
|
||||||
const dbItem = await getContentByPlatformContentId(this.db, channel.id, platformContentId);
|
|
||||||
if (!dbItem) continue;
|
|
||||||
|
|
||||||
// Only update if enrichment provides new data
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
if (enriched.publishedAt && !dbItem.publishedAt) {
|
|
||||||
updates.publishedAt = enriched.publishedAt;
|
|
||||||
}
|
|
||||||
if (enriched.duration != null && dbItem.duration == null) {
|
|
||||||
updates.duration = enriched.duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
await updateContentItem(this.db, dbItem.id, updates);
|
|
||||||
enrichedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[scheduler] Phase 2 complete for channel ${channel.id}: ${enrichedCount} items enriched`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal ──
|
// ── Internal ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
import type { Channel, PlatformSourceMetadata, PlatformContentMetadata, ContentType } from '../types/index';
|
|
||||||
import type { PlatformSource, FetchRecentContentOptions } from './platform-source';
|
|
||||||
import { execYtDlp, parseJsonLines, parseSingleJson } from './yt-dlp';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic platform source — catch-all for any URL yt-dlp supports.
|
|
||||||
*
|
|
||||||
* Works with Vimeo, Twitch VODs, Bandcamp, Dailymotion, Twitter/X,
|
|
||||||
* Instagram, TikTok, Reddit, news sites with embedded video, blogs,
|
|
||||||
* and hundreds of other sites yt-dlp can extract from.
|
|
||||||
*
|
|
||||||
* Unlike YouTube/SoundCloud sources which use channel-level enumeration,
|
|
||||||
* the Generic source treats the channel URL as a playlist/page to scrape.
|
|
||||||
* Content discovery uses yt-dlp's built-in extractors with no platform-specific logic.
|
|
||||||
*/
|
|
||||||
export class GenericSource implements PlatformSource {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a URL to channel-like metadata.
|
|
||||||
*
|
|
||||||
* For generic URLs, the "channel" is whatever yt-dlp identifies as the
|
|
||||||
* playlist/page/uploader. Falls back to the URL domain as the name
|
|
||||||
* if yt-dlp can't extract structured metadata.
|
|
||||||
*/
|
|
||||||
async resolveChannel(url: string): Promise<PlatformSourceMetadata> {
|
|
||||||
try {
|
|
||||||
const result = await execYtDlp(
|
|
||||||
[
|
|
||||||
'--dump-single-json',
|
|
||||||
'--playlist-items', '0',
|
|
||||||
'--flat-playlist',
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = parseSingleJson(result.stdout) as Record<string, unknown>;
|
|
||||||
|
|
||||||
// yt-dlp returns various shapes depending on the site
|
|
||||||
const name = data.channel
|
|
||||||
?? data.uploader
|
|
||||||
?? data.playlist_title
|
|
||||||
?? data.title
|
|
||||||
?? new URL(url).hostname;
|
|
||||||
|
|
||||||
const platformId = data.channel_id
|
|
||||||
?? data.uploader_id
|
|
||||||
?? data.playlist_id
|
|
||||||
?? data.id
|
|
||||||
?? url;
|
|
||||||
|
|
||||||
const channelUrl = data.channel_url
|
|
||||||
?? data.uploader_url
|
|
||||||
?? data.webpage_url
|
|
||||||
?? url;
|
|
||||||
|
|
||||||
// Best thumbnail
|
|
||||||
const thumbnails = data.thumbnails as Array<{ url: string; width?: number }> | undefined;
|
|
||||||
const imageUrl = thumbnails?.length
|
|
||||||
? thumbnails[thumbnails.length - 1].url
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: String(name),
|
|
||||||
platformId: String(platformId),
|
|
||||||
imageUrl,
|
|
||||||
url: String(channelUrl),
|
|
||||||
platform: 'generic' as const,
|
|
||||||
description: data.description ? String(data.description) : null,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// Fallback: use URL domain as name, URL as identifier
|
|
||||||
const hostname = (() => {
|
|
||||||
try { return new URL(url).hostname; } catch { return 'Unknown'; }
|
|
||||||
})();
|
|
||||||
return {
|
|
||||||
name: hostname,
|
|
||||||
platformId: url,
|
|
||||||
imageUrl: null,
|
|
||||||
url,
|
|
||||||
platform: 'generic' as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch content from a generic URL.
|
|
||||||
*
|
|
||||||
* Treats the channel URL as a page/playlist and enumerates items via
|
|
||||||
* --flat-playlist. Each item is a potential downloadable media file.
|
|
||||||
*/
|
|
||||||
async fetchRecentContent(
|
|
||||||
channel: Channel,
|
|
||||||
options?: FetchRecentContentOptions
|
|
||||||
): Promise<PlatformContentMetadata[]> {
|
|
||||||
const limit = options?.limit ?? 50;
|
|
||||||
const discoveryOnly = options?.discoveryOnly ?? false;
|
|
||||||
const existingIds = options?.existingIds ?? new Set<string>();
|
|
||||||
const rateLimitDelay = options?.rateLimitDelay ?? 2000;
|
|
||||||
const signal = options?.signal;
|
|
||||||
|
|
||||||
// Discovery: enumerate items from the URL
|
|
||||||
const discoveryTimeout = 60_000 + Math.ceil(limit / 500) * 30_000;
|
|
||||||
const flatResult = await execYtDlp(
|
|
||||||
[
|
|
||||||
'--flat-playlist',
|
|
||||||
'--dump-json',
|
|
||||||
'--playlist-items', `1:${limit}`,
|
|
||||||
channel.url,
|
|
||||||
],
|
|
||||||
{ timeout: discoveryTimeout }
|
|
||||||
);
|
|
||||||
|
|
||||||
const flatEntries = parseJsonLines(flatResult.stdout) as Record<string, unknown>[];
|
|
||||||
const discoveredItems = flatEntries.map((entry) => mapEntry(entry));
|
|
||||||
|
|
||||||
if (discoveryOnly) {
|
|
||||||
return discoveredItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrichment: fetch full metadata for new items only
|
|
||||||
const newItems = discoveredItems.filter(
|
|
||||||
(item) => !existingIds.has(item.platformContentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newItems.length === 0) return discoveredItems;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[generic] Enriching ${newItems.length} new items (${discoveredItems.length - newItems.length} already known)`
|
|
||||||
);
|
|
||||||
|
|
||||||
const enrichedMap = new Map<string, PlatformContentMetadata>();
|
|
||||||
|
|
||||||
for (let i = 0; i < newItems.length; i++) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
console.log(`[generic] Enrichment aborted after ${i} items`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = newItems[i];
|
|
||||||
if (i > 0 && rateLimitDelay > 0) {
|
|
||||||
await sleep(rateLimitDelay);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const enrichResult = await execYtDlp(
|
|
||||||
['--dump-json', '--no-playlist', item.url],
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
const enrichedEntry = parseSingleJson(enrichResult.stdout) as Record<string, unknown>;
|
|
||||||
enrichedMap.set(item.platformContentId, mapEntry(enrichedEntry));
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
`[generic] Enrichment failed for ${item.platformContentId}: ${err instanceof Error ? err.message : err}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return discoveredItems.map((item) => {
|
|
||||||
const enriched = enrichedMap.get(item.platformContentId);
|
|
||||||
return enriched ?? item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
function mapEntry(entry: Record<string, unknown>): PlatformContentMetadata {
|
|
||||||
const id = String(entry.id ?? entry.url ?? '');
|
|
||||||
const title = String(entry.title ?? entry.fulltitle ?? 'Untitled');
|
|
||||||
const url = String(entry.webpage_url ?? entry.url ?? entry.original_url ?? '');
|
|
||||||
|
|
||||||
// Content type detection
|
|
||||||
const liveStatus = entry.live_status as string | undefined;
|
|
||||||
const isLive = liveStatus === 'is_live' || liveStatus === 'is_upcoming';
|
|
||||||
const isAudio = entry._type === 'audio'
|
|
||||||
|| (entry.vcodec === 'none' && entry.acodec !== 'none')
|
|
||||||
|| /\.(mp3|flac|wav|ogg|opus|m4a|aac)$/i.test(url);
|
|
||||||
|
|
||||||
let contentType: ContentType = 'video';
|
|
||||||
if (isLive) contentType = 'livestream';
|
|
||||||
else if (isAudio) contentType = 'audio';
|
|
||||||
|
|
||||||
// Duration
|
|
||||||
const duration = typeof entry.duration === 'number' ? Math.round(entry.duration) : null;
|
|
||||||
|
|
||||||
// Thumbnail — best quality
|
|
||||||
const thumbnails = entry.thumbnails as Array<{ url: string }> | undefined;
|
|
||||||
const thumbnailUrl = thumbnails?.length
|
|
||||||
? thumbnails[thumbnails.length - 1].url
|
|
||||||
: (entry.thumbnail as string | undefined) ?? null;
|
|
||||||
|
|
||||||
// Published date
|
|
||||||
let publishedAt: string | null = null;
|
|
||||||
const uploadDate = entry.upload_date as string | undefined;
|
|
||||||
if (uploadDate && /^\d{8}$/.test(uploadDate)) {
|
|
||||||
publishedAt = `${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}T00:00:00Z`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { platformContentId: id, title, url, contentType, duration, thumbnailUrl, publishedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
@ -16,10 +16,6 @@ export interface FetchRecentContentOptions {
|
||||||
existingIds?: Set<string>;
|
existingIds?: Set<string>;
|
||||||
/** Milliseconds to wait between per-item enrichment calls. Default: 1000 */
|
/** Milliseconds to wait between per-item enrichment calls. Default: 1000 */
|
||||||
rateLimitDelay?: number;
|
rateLimitDelay?: number;
|
||||||
/** If true, skip Phase 2 enrichment and return discovery-phase results only. */
|
|
||||||
discoveryOnly?: boolean;
|
|
||||||
/** AbortSignal for cancellation support. */
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Interface ──
|
// ── Interface ──
|
||||||
|
|
@ -115,10 +111,5 @@ function detectPlatformFromUrl(url: string): Platform | null {
|
||||||
return 'soundcloud' as Platform;
|
return 'soundcloud' as Platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any URL with a valid scheme → Generic (yt-dlp supports 1000+ sites)
|
|
||||||
if (/^https?:\/\/.+/.test(url)) {
|
|
||||||
return 'generic' as Platform;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,23 +47,12 @@ export class SoundCloudSource implements PlatformSource {
|
||||||
? (thumbnails[thumbnails.length - 1]?.url ?? null)
|
? (thumbnails[thumbnails.length - 1]?.url ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Extract enrichment metadata (limited availability on SoundCloud)
|
|
||||||
const description = typeof data.description === 'string' ? data.description : null;
|
|
||||||
const subscriberCount = typeof data.channel_follower_count === 'number'
|
|
||||||
? data.channel_follower_count
|
|
||||||
: typeof data.uploader_follower_count === 'number'
|
|
||||||
? data.uploader_follower_count
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: channelName,
|
name: channelName,
|
||||||
platformId: uploaderId,
|
platformId: uploaderId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
url: uploaderUrl,
|
url: uploaderUrl,
|
||||||
platform: Platform.SoundCloud,
|
platform: Platform.SoundCloud,
|
||||||
bannerUrl: null, // SoundCloud doesn't provide banner URLs via yt-dlp
|
|
||||||
description,
|
|
||||||
subscriberCount,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,35 +44,17 @@ export class YouTubeSource implements PlatformSource {
|
||||||
url;
|
url;
|
||||||
|
|
||||||
// Pick the best thumbnail — yt-dlp returns an array sorted by quality
|
// Pick the best thumbnail — yt-dlp returns an array sorted by quality
|
||||||
const thumbnails = data.thumbnails as Array<{ url?: string; width?: number }> | undefined;
|
const thumbnails = data.thumbnails as Array<{ url?: string }> | undefined;
|
||||||
const imageUrl = thumbnails?.length
|
const imageUrl = thumbnails?.length
|
||||||
? (thumbnails[thumbnails.length - 1]?.url ?? null)
|
? (thumbnails[thumbnails.length - 1]?.url ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Extract enrichment metadata
|
|
||||||
const description = typeof data.description === 'string' ? data.description : null;
|
|
||||||
const subscriberCount = typeof data.channel_follower_count === 'number'
|
|
||||||
? data.channel_follower_count
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Banner: try channel_banner_url first, then look for wide thumbnails (>=1024px)
|
|
||||||
let bannerUrl: string | null = null;
|
|
||||||
if (typeof data.channel_banner_url === 'string') {
|
|
||||||
bannerUrl = data.channel_banner_url;
|
|
||||||
} else if (thumbnails?.length) {
|
|
||||||
const wideThumbnail = thumbnails.find((t) => (t.width ?? 0) >= 1024);
|
|
||||||
if (wideThumbnail?.url) bannerUrl = wideThumbnail.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: channelName,
|
name: channelName,
|
||||||
platformId: channelId,
|
platformId: channelId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
url: channelUrl,
|
url: channelUrl,
|
||||||
platform: Platform.YouTube,
|
platform: Platform.YouTube,
|
||||||
bannerUrl,
|
|
||||||
description,
|
|
||||||
subscriberCount,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,12 +77,8 @@ export class YouTubeSource implements PlatformSource {
|
||||||
const limit = options?.limit ?? 50;
|
const limit = options?.limit ?? 50;
|
||||||
const existingIds = options?.existingIds ?? new Set<string>();
|
const existingIds = options?.existingIds ?? new Set<string>();
|
||||||
const rateLimitDelay = options?.rateLimitDelay ?? 1000;
|
const rateLimitDelay = options?.rateLimitDelay ?? 1000;
|
||||||
const discoveryOnly = options?.discoveryOnly ?? false;
|
|
||||||
const signal = options?.signal;
|
|
||||||
|
|
||||||
// ── Phase 1: Fast discovery via --flat-playlist ──
|
// ── Phase 1: Fast discovery via --flat-playlist ──
|
||||||
// Timeout scales with limit: 60s base + 30s per 500 items
|
|
||||||
const discoveryTimeout = 60_000 + Math.ceil(limit / 500) * 30_000;
|
|
||||||
const flatResult = await execYtDlp(
|
const flatResult = await execYtDlp(
|
||||||
[
|
[
|
||||||
'--flat-playlist',
|
'--flat-playlist',
|
||||||
|
|
@ -109,17 +87,12 @@ export class YouTubeSource implements PlatformSource {
|
||||||
`1:${limit}`,
|
`1:${limit}`,
|
||||||
channel.url,
|
channel.url,
|
||||||
],
|
],
|
||||||
{ timeout: discoveryTimeout }
|
{ timeout: 60_000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
const flatEntries = parseJsonLines(flatResult.stdout);
|
const flatEntries = parseJsonLines(flatResult.stdout);
|
||||||
const discoveredItems = flatEntries.map((entry) => mapEntry(entry));
|
const discoveredItems = flatEntries.map((entry) => mapEntry(entry));
|
||||||
|
|
||||||
// If discovery-only, skip Phase 2 entirely — caller gets fast results
|
|
||||||
if (discoveryOnly) {
|
|
||||||
return discoveredItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 2: Enrich new items with upload_date ──
|
// ── Phase 2: Enrich new items with upload_date ──
|
||||||
const newItems = discoveredItems.filter(
|
const newItems = discoveredItems.filter(
|
||||||
(item) => !existingIds.has(item.platformContentId)
|
(item) => !existingIds.has(item.platformContentId)
|
||||||
|
|
@ -138,12 +111,6 @@ export class YouTubeSource implements PlatformSource {
|
||||||
const enrichedMap = new Map<string, PlatformContentMetadata>();
|
const enrichedMap = new Map<string, PlatformContentMetadata>();
|
||||||
|
|
||||||
for (let i = 0; i < newItems.length; i++) {
|
for (let i = 0; i < newItems.length; i++) {
|
||||||
// Check cancellation between enrichment calls
|
|
||||||
if (signal?.aborted) {
|
|
||||||
console.log(`[youtube] Phase 2 aborted after ${i} enrichments`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = newItems[i];
|
const item = newItems[i];
|
||||||
|
|
||||||
// Rate limit delay between enrichment calls (skip before first)
|
// Rate limit delay between enrichment calls (skip before first)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ export class YtDlpError extends Error {
|
||||||
readonly stderr: string;
|
readonly stderr: string;
|
||||||
readonly exitCode: number;
|
readonly exitCode: number;
|
||||||
readonly isRateLimit: boolean;
|
readonly isRateLimit: boolean;
|
||||||
readonly category: YtDlpErrorCategory;
|
|
||||||
|
|
||||||
constructor(message: string, stderr: string, exitCode: number) {
|
constructor(message: string, stderr: string, exitCode: number) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
@ -34,7 +33,6 @@ export class YtDlpError extends Error {
|
||||||
this.stderr = stderr;
|
this.stderr = stderr;
|
||||||
this.exitCode = exitCode;
|
this.exitCode = exitCode;
|
||||||
this.isRateLimit = detectRateLimit(stderr);
|
this.isRateLimit = detectRateLimit(stderr);
|
||||||
this.category = classifyYtDlpError(stderr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,92 +212,3 @@ export async function getYtDlpVersion(): Promise<string | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auto-Update ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update yt-dlp to the latest version.
|
|
||||||
* Uses `yt-dlp -U` which handles self-update on most installations.
|
|
||||||
* For pip-based installs (Alpine/Docker), falls back to `pip install -U yt-dlp`.
|
|
||||||
*
|
|
||||||
* Returns { updated, version, previousVersion } on success.
|
|
||||||
*/
|
|
||||||
export async function updateYtDlp(): Promise<{
|
|
||||||
updated: boolean;
|
|
||||||
version: string | null;
|
|
||||||
previousVersion: string | null;
|
|
||||||
}> {
|
|
||||||
const previousVersion = await getYtDlpVersion();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try native self-update first
|
|
||||||
const { stderr } = await execFileAsync('yt-dlp', ['-U'], {
|
|
||||||
timeout: 120_000,
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if it actually updated
|
|
||||||
const newVersion = await getYtDlpVersion();
|
|
||||||
const didUpdate = newVersion !== previousVersion;
|
|
||||||
|
|
||||||
if (didUpdate) {
|
|
||||||
console.log(`[yt-dlp] Updated from ${previousVersion} to ${newVersion}`);
|
|
||||||
} else if (stderr.toLowerCase().includes('up to date')) {
|
|
||||||
console.log(`[yt-dlp] Already up to date (${newVersion})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { updated: didUpdate, version: newVersion, previousVersion };
|
|
||||||
} catch (err) {
|
|
||||||
// Self-update may not work in pip-based installs — try pip
|
|
||||||
try {
|
|
||||||
await execFileAsync('pip', ['install', '-U', 'yt-dlp'], {
|
|
||||||
timeout: 120_000,
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newVersion = await getYtDlpVersion();
|
|
||||||
const didUpdate = newVersion !== previousVersion;
|
|
||||||
console.log(
|
|
||||||
`[yt-dlp] pip update: ${previousVersion} → ${newVersion}${didUpdate ? '' : ' (no change)'}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return { updated: didUpdate, version: newVersion, previousVersion };
|
|
||||||
} catch (pipErr) {
|
|
||||||
console.warn(
|
|
||||||
`[yt-dlp] Auto-update failed: ${err instanceof Error ? err.message : String(err)}`
|
|
||||||
);
|
|
||||||
return { updated: false, version: previousVersion, previousVersion };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Error Classification ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify a yt-dlp error into a category for better retry/fallback decisions.
|
|
||||||
*/
|
|
||||||
export type YtDlpErrorCategory =
|
|
||||||
| 'rate_limit' // 429, too many requests
|
|
||||||
| 'format_unavailable' // requested format not available
|
|
||||||
| 'geo_blocked' // geo-restriction
|
|
||||||
| 'age_restricted' // age-gated content
|
|
||||||
| 'private' // private or removed video
|
|
||||||
| 'network' // DNS, connection, timeout
|
|
||||||
| 'sign_in_required' // sign-in or login required
|
|
||||||
| 'copyright' // copyright claim or block
|
|
||||||
| 'unknown';
|
|
||||||
|
|
||||||
export function classifyYtDlpError(stderr: string): YtDlpErrorCategory {
|
|
||||||
const lower = stderr.toLowerCase();
|
|
||||||
|
|
||||||
if (lower.includes('429') || lower.includes('too many requests')) return 'rate_limit';
|
|
||||||
if (lower.includes('requested format') || lower.includes('format is not available')) return 'format_unavailable';
|
|
||||||
if (lower.includes('not available in your country') || lower.includes('geo')) return 'geo_blocked';
|
|
||||||
if (lower.includes('age') && (lower.includes('restricted') || lower.includes('verify'))) return 'age_restricted';
|
|
||||||
if (lower.includes('private video') || lower.includes('video unavailable') || lower.includes('been removed')) return 'private';
|
|
||||||
if (lower.includes('sign in') || lower.includes('login required')) return 'sign_in_required';
|
|
||||||
if (lower.includes('copyright') || /blocked.*claim/.test(lower)) return 'copyright';
|
|
||||||
if (lower.includes('unable to download') || lower.includes('connection') || lower.includes('timed out') || lower.includes('urlopen error')) return 'network';
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -80,19 +80,3 @@ export interface AppSettingsResponse {
|
||||||
export type ChannelWithCounts = import('./index').Channel & {
|
export type ChannelWithCounts = import('./index').Channel & {
|
||||||
contentCounts: ContentCounts;
|
contentCounts: ContentCounts;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── yt-dlp Status ──
|
|
||||||
|
|
||||||
/** Response shape for GET /api/v1/system/ytdlp/status. */
|
|
||||||
export interface YtDlpStatusResponse {
|
|
||||||
version: string | null;
|
|
||||||
lastUpdated: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response shape for POST /api/v1/system/ytdlp/update. */
|
|
||||||
export interface YtDlpUpdateResponse {
|
|
||||||
updated: boolean;
|
|
||||||
version: string | null;
|
|
||||||
previousVersion: string | null;
|
|
||||||
lastUpdated: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
export const Platform = {
|
export const Platform = {
|
||||||
YouTube: 'youtube',
|
YouTube: 'youtube',
|
||||||
SoundCloud: 'soundcloud',
|
SoundCloud: 'soundcloud',
|
||||||
Generic: 'generic',
|
|
||||||
} as const;
|
} as const;
|
||||||
export type Platform = (typeof Platform)[keyof typeof Platform];
|
export type Platform = (typeof Platform)[keyof typeof Platform];
|
||||||
|
|
||||||
|
|
@ -44,9 +43,6 @@ export interface PlatformSourceMetadata {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
platform: Platform;
|
platform: Platform;
|
||||||
bannerUrl?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
subscriberCount?: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Metadata for a single piece of content from a platform. */
|
/** Metadata for a single piece of content from a platform. */
|
||||||
|
|
@ -74,9 +70,6 @@ export interface Channel {
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
formatProfileId: number | null;
|
formatProfileId: number | null;
|
||||||
monitoringMode: MonitoringMode;
|
monitoringMode: MonitoringMode;
|
||||||
bannerUrl: string | null;
|
|
||||||
description: string | null;
|
|
||||||
subscriberCount: number | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
|
|
@ -120,7 +113,6 @@ export interface QueueItem {
|
||||||
attempts: number;
|
attempts: number;
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
errorCategory: string | null;
|
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -141,9 +133,6 @@ export interface FormatProfile {
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
subtitleLanguages: string | null; // comma-separated lang codes e.g. "en,es,fr"
|
subtitleLanguages: string | null; // comma-separated lang codes e.g. "en,es,fr"
|
||||||
embedSubtitles: boolean;
|
embedSubtitles: boolean;
|
||||||
embedChapters: boolean;
|
|
||||||
embedThumbnail: boolean;
|
|
||||||
sponsorBlockRemove: string | null; // comma-separated: 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue