Merge milestone/M008: Frontend polish & real-time features (M002)

This commit is contained in:
jlightner 2026-04-03 07:23:47 +00:00
commit d4c595958f
51 changed files with 5414 additions and 1260 deletions

1
.gitignore vendored
View file

@ -52,3 +52,4 @@ venv/
target/
vendor/
config/
media/

View file

@ -6,7 +6,7 @@ services:
ports:
- "8989:8989"
volumes:
- ./config:/config
- tubearr-config:/config
- ./media:/media
environment:
- NODE_ENV=production
@ -18,3 +18,6 @@ services:
retries: 3
start_period: 15s
restart: unless-stopped
volumes:
tubearr-config:

View file

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

View file

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

View file

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

View file

@ -0,0 +1,997 @@
{
"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": {}
}
}

View file

@ -64,6 +64,20 @@
"when": 1774839000000,
"tag": "0008_add_default_monitoring_mode",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1775192114394,
"tag": "0009_many_carlie_cooper",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1775196046744,
"tag": "0010_special_ghost_rider",
"breakpoints": true
}
]
}

View file

@ -344,7 +344,7 @@ describe('DownloadService', () => {
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).toContain('-f');
const fIdx = args.indexOf('-f');
expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/best[height<=1080]');
expect(args[fIdx + 1]).toBe('bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]*+bestaudio/best[height<=1080]/bestvideo+bestaudio/best');
expect(args).toContain('--merge-output-format');
const moIdx = args.indexOf('--merge-output-format');
expect(args[moIdx + 1]).toBe('mkv');
@ -431,7 +431,7 @@ describe('DownloadService', () => {
const args = execYtDlpMock.mock.calls[0][0] as string[];
expect(args).toContain('-f');
const fIdx = args.indexOf('-f');
expect(args[fIdx + 1]).toBe('best');
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
});
it('falls back to -f "bestaudio" for audio when no format profile', async () => {
@ -652,7 +652,7 @@ describe('DownloadService', () => {
const args = execYtDlpMock.mock.calls[0][0] as string[];
const fIdx = args.indexOf('-f');
expect(fIdx).toBeGreaterThanOrEqual(0);
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best');
// Should default to mp4 merge format when containerFormat is null
expect(args).toContain('--merge-output-format');
const moIdx = args.indexOf('--merge-output-format');
@ -695,7 +695,7 @@ describe('DownloadService', () => {
const args = execYtDlpMock.mock.calls[0][0] as string[];
const fIdx = args.indexOf('-f');
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/best');
expect(args[fIdx + 1]).toBe('bestvideo+bestaudio/bestvideo*+bestaudio/best');
const moIdx = args.indexOf('--merge-output-format');
expect(args[moIdx + 1]).toBe('mkv');
});

View file

@ -135,6 +135,9 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
metadata: null,
formatProfileId: null,
monitoringMode: 'all',
bannerUrl: null,
description: null,
subscriberCount: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
lastCheckedAt: null,
@ -237,6 +240,9 @@ describe('YouTubeSource', () => {
imageUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
url: 'https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw',
platform: 'youtube',
bannerUrl: 'https://i.ytimg.com/vi/thumb_large.jpg',
description: null,
subscriberCount: null,
});
// Verify yt-dlp was called with correct args
@ -669,6 +675,9 @@ describe('SoundCloudSource', () => {
imageUrl: 'https://i1.sndcdn.com/avatars-large.jpg',
url: 'https://soundcloud.com/deadmau5',
platform: 'soundcloud',
bannerUrl: null,
description: null,
subscriberCount: null,
});
});
});

View file

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

View file

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

View file

@ -92,6 +92,81 @@ export async function getContentByChannelId(
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. */
export async function getContentByPlatformContentId(
db: Db,

View file

@ -16,6 +16,7 @@ export interface CreateQueueItemData {
/** Optional fields when updating queue item status. */
export interface UpdateQueueItemFields {
error?: string | null;
errorCategory?: string | null;
startedAt?: string | null;
completedAt?: string | null;
attempts?: number;
@ -72,6 +73,7 @@ export async function getQueueItemsByStatus(
attempts: queueItems.attempts,
maxAttempts: queueItems.maxAttempts,
error: queueItems.error,
errorCategory: queueItems.errorCategory,
startedAt: queueItems.startedAt,
completedAt: queueItems.completedAt,
createdAt: queueItems.createdAt,
@ -101,6 +103,7 @@ export async function getAllQueueItems(
attempts: queueItems.attempts,
maxAttempts: queueItems.maxAttempts,
error: queueItems.error,
errorCategory: queueItems.errorCategory,
startedAt: queueItems.startedAt,
completedAt: queueItems.completedAt,
createdAt: queueItems.createdAt,
@ -154,6 +157,7 @@ export async function updateQueueItemStatus(
};
if (updates?.error !== undefined) setData.error = updates.error;
if (updates?.errorCategory !== undefined) setData.errorCategory = updates.errorCategory;
if (updates?.startedAt !== undefined) setData.startedAt = updates.startedAt;
if (updates?.completedAt !== undefined) setData.completedAt = updates.completedAt;
if (updates?.attempts !== undefined) setData.attempts = updates.attempts;
@ -239,6 +243,7 @@ function mapRow(row: typeof queueItems.$inferSelect): QueueItem {
attempts: row.attempts,
maxAttempts: row.maxAttempts,
error: row.error,
errorCategory: row.errorCategory,
startedAt: row.startedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,
@ -255,6 +260,7 @@ interface JoinedQueueRow {
attempts: number;
maxAttempts: number;
error: string | null;
errorCategory: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
@ -273,6 +279,7 @@ function mapJoinedRow(row: JoinedQueueRow): QueueItem {
attempts: row.attempts,
maxAttempts: row.maxAttempts,
error: row.error,
errorCategory: row.errorCategory,
startedAt: row.startedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,

View file

@ -10,6 +10,7 @@ type Db = LibSQLDatabase<typeof schema>;
export const APP_CHECK_INTERVAL = 'app.check_interval';
export const APP_CONCURRENT_DOWNLOADS = 'app.concurrent_downloads';
export const YTDLP_LAST_UPDATED = 'ytdlp.last_updated';
// ── Read / Write ──

View file

@ -28,4 +28,7 @@ export const channels = sqliteTable('channels', {
lastCheckedAt: text('last_checked_at'), // null until first monitoring check
lastCheckStatus: text('last_check_status'), // 'success' | 'error' | 'rate_limited'
monitoringMode: text('monitoring_mode').notNull().default('all'), // 'all' | 'future' | 'existing' | 'none'
bannerUrl: text('banner_url'),
description: text('description'),
subscriberCount: integer('subscriber_count'),
});

View file

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

View file

@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { Sidebar } from './components/Sidebar';
import { ToastProvider } from './components/Toast';
import { Channels } from './pages/Channels';
import { ChannelDetail } from './pages/ChannelDetail';
import { Library } from './pages/Library';
@ -37,8 +38,10 @@ function AuthenticatedLayout() {
export function App() {
return (
<Routes>
<Route path="/*" element={<AuthenticatedLayout />} />
</Routes>
<ToastProvider>
<Routes>
<Route path="/*" element={<AuthenticatedLayout />} />
</Routes>
</ToastProvider>
);
}

View file

@ -39,8 +39,6 @@ interface CreateChannelInput {
monitoringEnabled?: boolean;
monitoringMode?: string;
formatProfileId?: number;
grabAll?: boolean;
grabAllOrder?: 'newest' | 'oldest';
}
/** Create a new channel by URL (resolves metadata via backend). */

View file

@ -1,8 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { apiClient } from '../client';
import { queueKeys } from './useQueue';
import type { ContentItem } from '@shared/types/index';
import type { ApiResponse } from '@shared/types/api';
import type { ApiResponse, PaginatedResponse } from '@shared/types/api';
// ── Collect Types ──
@ -13,15 +13,29 @@ export interface CollectResult {
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 ──
export const contentKeys = {
byChannel: (channelId: number) => ['content', 'channel', channelId] as const,
byChannelPaginated: (channelId: number, filters: ChannelContentFilters) =>
['content', 'channel', channelId, 'paginated', filters] as const,
};
// ── Queries ──
/** Fetch content items for a specific channel. */
/** Fetch content items for a specific channel (legacy — all items). */
export function useChannelContent(channelId: number) {
return useQuery({
queryKey: contentKeys.byChannel(channelId),
@ -35,6 +49,30 @@ export function useChannelContent(channelId: number) {
});
}
/** Fetch paginated content items for a channel with search/filter/sort. */
export function useChannelContentPaginated(channelId: number, filters: ChannelContentFilters) {
return useQuery({
queryKey: contentKeys.byChannelPaginated(channelId, filters),
queryFn: async () => {
const params = new URLSearchParams();
if (filters.page) params.set('page', String(filters.page));
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;
},
enabled: channelId > 0,
placeholderData: keepPreviousData,
});
}
// ── Mutations ──
/** Enqueue a content item for download. Returns 202 with queue item. */

View file

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse } from '@shared/types/api';
import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
// ── Query Keys ──
@ -9,6 +9,7 @@ export const systemKeys = {
health: ['system', 'health'] as const,
apiKey: ['system', 'apikey'] as const,
appSettings: ['system', 'appSettings'] as const,
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
};
// ── Queries ──
@ -70,3 +71,23 @@ 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 });
},
});
}

View file

@ -51,8 +51,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
const [checkInterval, setCheckInterval] = useState('');
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
const [monitoringMode, setMonitoringMode] = useState<string>('all');
const [grabAll, setGrabAll] = useState(false);
const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest');
const createChannel = useCreateChannel();
const { data: platformSettingsList } = usePlatformSettings();
@ -82,16 +80,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
if (settings.defaultMonitoringMode) {
setMonitoringMode(settings.defaultMonitoringMode);
}
// Pre-fill grab-all defaults for YouTube
if (detectedPlatform === 'youtube') {
if (settings.grabAllEnabled) {
setGrabAll(true);
}
if (settings.grabAllOrder) {
setGrabAllOrder(settings.grabAllOrder);
}
}
}, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = (e: React.FormEvent) => {
@ -104,8 +92,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
monitoringMode,
formatProfileId: formatProfileId ?? undefined,
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
},
{
onSuccess: (newChannel) => {
@ -128,8 +114,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
setCheckInterval('');
setFormatProfileId(undefined);
setMonitoringMode('all');
setGrabAll(false);
setGrabAllOrder('newest');
createChannel.reset();
};
@ -142,7 +126,29 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
return (
<Modal title="Add Channel" open={open} onClose={handleClose}>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} style={{ position: 'relative' }}>
{/* Loading overlay */}
{createChannel.isPending && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-3)',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 'var(--radius-md)',
}}
>
<Loader size={28} style={{ animation: 'spin 1s linear infinite', color: 'var(--accent)' }} />
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 500, color: 'var(--text-primary)' }}>
Resolving channel
</span>
</div>
)}
{/* URL input */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
@ -188,31 +194,34 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
)}
</div>
{/* Check interval (optional) */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="check-interval"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Check Interval (minutes)
</label>
<input
id="check-interval"
type="number"
min={1}
value={checkInterval}
onChange={(e) => setCheckInterval(e.target.value)}
placeholder="360 (default: 6 hours)"
disabled={createChannel.isPending}
style={{ width: '100%' }}
/>
</div>
{/* Monitoring Mode — shown when platform detected */}
{detectedPlatform && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="monitoring-mode"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Monitoring Mode
</label>
<select
id="monitoring-mode"
value={monitoringMode}
onChange={(e) => setMonitoringMode(e.target.value)}
disabled={createChannel.isPending}
style={{ width: '100%' }}
>
<option value="all">Monitor All</option>
<option value="future">Future Only</option>
<option value="none">None</option>
</select>
</div>
)}
{/* Format Profile (optional, shown when platform detected) */}
{detectedPlatform && formatProfiles && formatProfiles.length > 0 && (
@ -248,105 +257,31 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
</div>
)}
{/* Monitoring Mode — shown when platform detected */}
{detectedPlatform && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="monitoring-mode"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Monitoring Mode
</label>
<select
id="monitoring-mode"
value={monitoringMode}
onChange={(e) => setMonitoringMode(e.target.value)}
disabled={createChannel.isPending}
style={{ width: '100%' }}
>
<option value="all">Monitor All</option>
<option value="future">Future Only</option>
<option value="none">None</option>
</select>
</div>
)}
{/* 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>
)}
</>
)}
{/* Check interval (optional) */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="check-interval"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Check Interval (minutes)
</label>
<input
id="check-interval"
type="number"
min={1}
value={checkInterval}
onChange={(e) => setCheckInterval(e.target.value)}
placeholder="360 (default: 6 hours)"
disabled={createChannel.isPending}
style={{ width: '100%' }}
/>
</div>
{/* Error display */}
{createChannel.isError && (
@ -362,9 +297,12 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
color: 'var(--danger)',
}}
>
{createChannel.error instanceof Error
? createChannel.error.message
: 'Failed to add channel'}
{createChannel.error instanceof Error &&
createChannel.error.message.toLowerCase().includes('already exists')
? 'This channel has already been added.'
: createChannel.error instanceof Error
? createChannel.error.message
: 'Failed to add channel'}
</div>
)}

View file

@ -0,0 +1,273 @@
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { DownloadProgressBar } from './DownloadProgressBar';
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
import type { ContentItem } from '@shared/types/index';
// ── Helpers ──
function formatDuration(seconds: number | null): string {
if (seconds == null) return '';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatRelativeTime(isoString: string | null): string {
if (!isoString) return '';
const delta = Date.now() - Date.parse(isoString);
if (delta < 0) return 'just now';
const seconds = Math.floor(delta / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
// ── Component ──
interface ContentCardProps {
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>
);
}

View file

@ -0,0 +1,295 @@
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { DownloadProgressBar } from './DownloadProgressBar';
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
import type { ContentItem } from '@shared/types/index';
// ── Helpers (shared pattern with ContentCard) ──
function formatDuration(seconds: number | null): string {
if (seconds == null) return '';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatRelativeTime(isoString: string | null): string {
if (!isoString) return '';
const delta = Date.now() - Date.parse(isoString);
if (delta < 0) return 'just now';
const seconds = Math.floor(delta / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
// ── Component ──
interface 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>
);
}

View file

@ -112,6 +112,7 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-lg)',
animation: 'modal-enter 200ms ease-out',
}}
>
{/* Header */}
@ -137,24 +138,7 @@ export function Modal({ title, open, onClose, children, width = 480 }: ModalProp
<button
onClick={onClose}
aria-label="Close"
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';
}}
className="btn-icon"
>
<X size={16} />
</button>

View file

@ -78,26 +78,11 @@ export function Sidebar() {
</NavLink>
<button
onClick={() => setCollapsed(!collapsed)}
className="btn-icon"
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',
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'}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}

View file

@ -0,0 +1,266 @@
/**
* 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>
);
}

View file

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

View file

@ -0,0 +1,92 @@
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>
);
}

View file

@ -1,7 +1,10 @@
import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useQueryClient, type QueryClient } from '@tanstack/react-query';
import { useSyncExternalStore } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { contentKeys } from '../api/hooks/useContent';
import type { ContentItem } from '@shared/types/index';
import type { PaginatedResponse } from '@shared/types/api';
// ── Types ──
@ -32,7 +35,39 @@ interface DownloadFailedEvent {
type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent;
// ── Store (external to React for zero unnecessary re-renders) ──
// ── Scan Event Types ──
interface ScanStartedEvent {
type: 'scan:started';
channelId: number;
channelName: string;
}
interface ScanItemDiscoveredEvent {
type: 'scan:item-discovered';
channelId: number;
channelName: string;
item: ContentItem;
}
interface ScanCompleteEvent {
type: 'scan:complete';
channelId: number;
channelName: string;
newItems: number;
totalFetched: number;
}
interface ScanErrorEvent {
type: 'scan:error';
channelId: number;
channelName: string;
error: string;
}
type ScanEvent = ScanStartedEvent | ScanItemDiscoveredEvent | ScanCompleteEvent | ScanErrorEvent;
// ── Download Progress Store (external to React for zero unnecessary re-renders) ──
class ProgressStore {
private _map = new Map<number, ProgressInfo>();
@ -63,6 +98,58 @@ class ProgressStore {
}
}
// ── Scan Progress Store ──
export interface ScanProgress {
scanning: boolean;
newItemCount: number;
}
class ScanStore {
private _map = new Map<number, ScanProgress>();
private _listeners = new Set<() => void>();
subscribe = (listener: () => void) => {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
};
getSnapshot = () => this._map;
startScan(channelId: number) {
this._map = new Map(this._map);
this._map.set(channelId, { scanning: true, newItemCount: 0 });
this._notify();
}
incrementItems(channelId: number) {
this._map = new Map(this._map);
const current = this._map.get(channelId) ?? { scanning: true, newItemCount: 0 };
this._map.set(channelId, { ...current, newItemCount: current.newItemCount + 1 });
this._notify();
}
completeScan(channelId: number) {
this._map = new Map(this._map);
const current = this._map.get(channelId);
if (current) {
this._map.set(channelId, { scanning: false, newItemCount: current.newItemCount });
}
this._notify();
}
clearScan(channelId: number) {
if (!this._map.has(channelId)) return;
this._map = new Map(this._map);
this._map.delete(channelId);
this._notify();
}
private _notify() {
for (const listener of this._listeners) listener();
}
}
// ── Context ──
interface DownloadProgressContextValue {
@ -70,6 +157,10 @@ interface DownloadProgressContextValue {
getProgress: (contentItemId: number) => ProgressInfo | undefined;
/** Whether the WebSocket is connected */
isConnected: boolean;
/** Subscribe to scan store changes */
scanStoreSubscribe: (listener: () => void) => () => void;
/** Get scan store snapshot */
scanStoreGetSnapshot: () => Map<number, ScanProgress>;
}
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null);
@ -80,13 +171,15 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
const queryClient = useQueryClient();
const storeRef = useRef(new ProgressStore());
const store = storeRef.current;
const scanStoreRef = useRef(new ScanStore());
const scanStore = scanStoreRef.current;
// Subscribe to the store with useSyncExternalStore for optimal re-renders
const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot);
const handleMessage = useCallback(
(data: unknown) => {
const event = data as DownloadEvent;
const event = data as DownloadEvent | ScanEvent;
if (!event?.type) return;
switch (event.type) {
@ -111,9 +204,30 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
queryClient.invalidateQueries({ queryKey: ['content'] });
queryClient.invalidateQueries({ queryKey: ['queue'] });
break;
case 'scan:started':
scanStore.startScan(event.channelId);
break;
case 'scan:item-discovered':
scanStore.incrementItems(event.channelId);
injectContentItemIntoCache(queryClient, event.channelId, event.item);
break;
case 'scan:complete':
scanStore.completeScan(event.channelId);
// Safety net: reconcile any missed items
queryClient.invalidateQueries({
queryKey: contentKeys.byChannel(event.channelId),
});
break;
case 'scan:error':
scanStore.completeScan(event.channelId);
break;
}
},
[store, queryClient],
[store, scanStore, queryClient],
);
const { isConnected } = useWebSocket({ onMessage: handleMessage });
@ -126,7 +240,14 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
);
return (
<DownloadProgressContext.Provider value={{ getProgress, isConnected }}>
<DownloadProgressContext.Provider
value={{
getProgress,
isConnected,
scanStoreSubscribe: scanStore.subscribe,
scanStoreGetSnapshot: scanStore.getSnapshot,
}}
>
{children}
</DownloadProgressContext.Provider>
);
@ -153,3 +274,54 @@ export function useDownloadProgressConnection(): boolean {
const context = useContext(DownloadProgressContext);
return context?.isConnected ?? false;
}
// ── Scan Progress Hook ──
/**
* Get scan progress for a specific channel.
* Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore.
* Only re-renders components that use this hook when the scan store changes.
*/
export function useScanProgress(channelId: number): ScanProgress {
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,
),
},
};
},
);
}

View file

@ -1,9 +1,10 @@
import { useState, useMemo, useCallback } from 'react';
import { ActivityIcon, Clock, Loader, RefreshCw } from 'lucide-react';
import { ActivityIcon, Clock, RefreshCw } from 'lucide-react';
import { Table, type Column } from '../components/Table';
import { StatusBadge } from '../components/StatusBadge';
import { Pagination } from '../components/Pagination';
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
import { SkeletonActivityList } from '../components/Skeleton';
import { useHistory, useActivity, type HistoryFilters } from '../api/hooks/useActivity';
import type { DownloadHistoryRecord } from '@shared/types/index';
@ -278,18 +279,7 @@ export function ActivityPage() {
<button
onClick={() => refetchHistory()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry
@ -298,12 +288,7 @@ export function ActivityPage() {
)}
{/* Loading */}
{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>
)}
{historyLoading && <SkeletonActivityList />}
{/* Table */}
{!historyLoading && (
@ -351,18 +336,7 @@ export function ActivityPage() {
<button
onClick={() => refetchRecent()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry
@ -371,12 +345,7 @@ export function ActivityPage() {
)}
{/* Loading */}
{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>
)}
{recentLoading && <SkeletonActivityList />}
{/* Activity feed */}
{!recentLoading && (

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
@ -8,6 +8,8 @@ import { PlatformBadge } from '../components/PlatformBadge';
import { StatusBadge } from '../components/StatusBadge';
import { ProgressBar } from '../components/ProgressBar';
import { AddChannelModal } from '../components/AddChannelModal';
import { SkeletonChannelsList } from '../components/Skeleton';
import { useToast } from '../components/Toast';
import type { ChannelWithCounts } from '@shared/types/api';
// ── Helpers ──
@ -30,19 +32,12 @@ function formatRelativeTime(dateStr: string | null): string {
export function Channels() {
const navigate = useNavigate();
const [showAddModal, setShowAddModal] = useState(false);
const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null);
const { toast } = useToast();
const { data: channels, isLoading, error, refetch } = useChannels();
const scanAll = useScanAllChannels();
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(() => {
scanAll.mutate(undefined, {
onSuccess: (result) => {
@ -50,16 +45,13 @@ export function Channels() {
if (result.summary.errors > 0) {
msg += ` (${result.summary.errors} error${result.summary.errors === 1 ? '' : 's'})`;
}
setScanResult({ message: msg, isError: result.summary.errors > 0 });
toast(msg, result.summary.errors > 0 ? 'error' : 'success');
},
onError: (err) => {
setScanResult({
message: err instanceof Error ? err.message : 'Scan failed',
isError: true,
});
toast(err instanceof Error ? err.message : 'Scan failed', 'error');
},
});
}, [scanAll]);
}, [scanAll, toast]);
const handleCollectAll = useCallback(() => {
collectAll.mutate(undefined, {
@ -69,16 +61,13 @@ export function Channels() {
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect';
setScanResult({ message: msg, isError: result.errors > 0 });
toast(msg, result.errors > 0 ? 'error' : 'success');
},
onError: (err) => {
setScanResult({
message: err instanceof Error ? err.message : 'Collect failed',
isError: true,
});
toast(err instanceof Error ? err.message : 'Collect failed', 'error');
},
});
}, [collectAll]);
}, [collectAll, toast]);
const handleRowClick = useCallback(
(channel: ChannelWithCounts) => {
@ -184,12 +173,7 @@ export function Channels() {
);
if (isLoading) {
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>
);
return <SkeletonChannelsList />;
}
if (error) {
@ -213,18 +197,7 @@ export function Channels() {
<button
onClick={() => refetch()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry
@ -260,20 +233,8 @@ export function Channels() {
onClick={handleScanAll}
disabled={scanAll.isPending}
title="Refresh All"
style={{
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,
}}
className="btn btn-ghost"
style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
>
{scanAll.isPending ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
@ -287,20 +248,8 @@ export function Channels() {
onClick={handleCollectAll}
disabled={collectAll.isPending}
title="Collect All Monitored"
style={{
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,
}}
className="btn btn-ghost"
style={{ opacity: collectAll.isPending ? 0.6 : 1 }}
>
{collectAll.isPending ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
@ -313,21 +262,8 @@ export function Channels() {
<button
onClick={() => setShowAddModal(true)}
disabled={scanAll.isPending}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--accent)',
color: 'var(--text-inverse)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
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)'; }}
className="btn btn-primary"
style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
>
<Plus size={16} />
Add Channel
@ -339,7 +275,7 @@ export function Channels() {
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
@ -355,28 +291,6 @@ export function Channels() {
{/* Add Channel modal */}
<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>
);
}

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Library as LibraryIcon, Loader, RefreshCw, Film, Music } from 'lucide-react';
import { Library as LibraryIcon, RefreshCw, Film, Music } from 'lucide-react';
import { Table, type Column } from '../components/Table';
import { StatusBadge } from '../components/StatusBadge';
import { QualityLabel } from '../components/QualityLabel';
@ -8,6 +8,7 @@ import { PlatformBadge } from '../components/PlatformBadge';
import { Pagination } from '../components/Pagination';
import { SearchBar } from '../components/SearchBar';
import { FilterBar, type FilterDefinition } from '../components/FilterBar';
import { SkeletonLibrary } from '../components/Skeleton';
import { useLibraryContent, type LibraryFilters } from '../api/hooks/useLibrary';
import { useChannels } from '../api/hooks/useChannels';
import type { ContentItem, ContentStatus, ContentType } from '@shared/types/index';
@ -335,18 +336,7 @@ export function Library() {
<button
onClick={() => refetch()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry
@ -355,12 +345,7 @@ export function Library() {
)}
{/* Loading state */}
{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>
)}
{isLoading && <SkeletonLibrary />}
{/* Content table */}
{!isLoading && !error && (

View file

@ -1,7 +1,8 @@
import { useState, useMemo } from 'react';
import { ListOrdered, RotateCcw, X, Loader, RefreshCw } from 'lucide-react';
import { ListOrdered, RotateCcw, X, RefreshCw } from 'lucide-react';
import { Table, type Column } from '../components/Table';
import { StatusBadge } from '../components/StatusBadge';
import { SkeletonQueueList } from '../components/Skeleton';
import { useQueue, useRetryQueueItem, useCancelQueueItem } from '../api/hooks/useQueue';
import type { QueueItem, QueueStatus } from '@shared/types/index';
@ -163,20 +164,8 @@ export function Queue() {
disabled={retryMutation.isPending}
title="Retry"
aria-label="Retry failed item"
style={{
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)',
}}
className="btn-icon"
style={{ color: 'var(--warning)' }}
>
<RotateCcw size={14} />
</button>
@ -190,20 +179,8 @@ export function Queue() {
disabled={cancelMutation.isPending}
title="Cancel"
aria-label="Cancel pending item"
style={{
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)',
}}
className="btn-icon"
style={{ color: 'var(--danger)' }}
>
<X size={14} />
</button>
@ -294,18 +271,7 @@ export function Queue() {
<button
onClick={() => refetch()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry
@ -336,12 +302,7 @@ export function Queue() {
)}
{/* Loading state */}
{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>
)}
{isLoading && <SkeletonQueueList />}
{/* Queue table */}
{!isLoading && (

View file

@ -25,6 +25,7 @@ import { Modal } from '../components/Modal';
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
import { SkeletonSettings } from '../components/Skeleton';
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
// ── Badge styles ──
@ -40,17 +41,6 @@ const badgeBase: React.CSSProperties = {
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 ──
export function SettingsPage() {
@ -297,15 +287,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setEditingPlatform(row.platform); }}
title={`Edit ${row.label} settings`}
aria-label={`Edit ${row.label} settings`}
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';
}}
className="btn-icon btn-icon-edit"
>
<Pencil size={14} />
</button>
@ -479,15 +461,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setEditingProfile(p); }}
title="Edit profile"
aria-label={`Edit ${p.name}`}
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';
}}
className="btn-icon btn-icon-edit"
>
<Pencil size={14} />
</button>
@ -496,15 +470,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setDeletingProfile(p); }}
title="Delete profile"
aria-label={`Delete ${p.name}`}
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';
}}
className="btn-icon btn-icon-delete"
>
<Trash2 size={14} />
</button>
@ -587,18 +553,8 @@ export function SettingsPage() {
title="Send test notification"
aria-label={`Test ${n.name}`}
disabled={result === 'loading'}
style={{
...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';
}}
className="btn-icon btn-icon-test"
style={{ opacity: result === 'loading' ? 0.5 : 1 }}
>
{result === 'loading'
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -611,15 +567,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setEditingNotification(n); }}
title="Edit channel"
aria-label={`Edit ${n.name}`}
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';
}}
className="btn-icon btn-icon-edit"
>
<Pencil size={14} />
</button>
@ -629,15 +577,7 @@ export function SettingsPage() {
onClick={(e) => { e.stopPropagation(); setDeletingNotification(n); }}
title="Delete channel"
aria-label={`Delete ${n.name}`}
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';
}}
className="btn-icon btn-icon-delete"
>
<Trash2 size={14} />
</button>
@ -652,12 +592,7 @@ export function SettingsPage() {
// ── Loading state ──
if (profilesLoading) {
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>
);
return <SkeletonSettings />;
}
// ── Error state ──
@ -683,17 +618,7 @@ export function SettingsPage() {
<button
onClick={() => refetchProfiles()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry
@ -725,7 +650,7 @@ export function SettingsPage() {
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
@ -774,15 +699,7 @@ export function SettingsPage() {
onClick={() => setShowApiKey((v) => !v)}
title={showApiKey ? 'Hide API key' : 'Show API key'}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
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';
}}
className="btn-icon btn-icon-edit"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
@ -792,22 +709,8 @@ export function SettingsPage() {
onClick={handleCopyApiKey}
title={copySuccess ? 'Copied!' : 'Copy to clipboard'}
aria-label="Copy API key to clipboard"
style={{
...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';
}
}}
className="btn-icon btn-icon-edit"
style={copySuccess ? { color: 'var(--success)' } : undefined}
>
{copySuccess ? <CheckCircle size={14} /> : <Copy size={14} />}
</button>
@ -817,15 +720,7 @@ export function SettingsPage() {
onClick={() => setShowRegenerateConfirm(true)}
title="Regenerate API key"
aria-label="Regenerate API key"
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';
}}
className="btn-icon btn-icon-warning"
>
<RotateCw size={14} />
</button>
@ -911,17 +806,10 @@ export function SettingsPage() {
<button
onClick={handleSaveSettings}
disabled={!settingsDirty || !settingsValid || updateAppSettingsMutation.isPending}
className={`btn ${settingsSaveFlash ? 'btn-primary' : 'btn-primary'}`}
style={{
display: 'inline-flex',
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)',
backgroundColor: settingsSaveFlash ? 'var(--success)' : undefined,
borderColor: settingsSaveFlash ? 'var(--success)' : undefined,
opacity: !settingsDirty || !settingsValid ? 0.5 : 1,
cursor: !settingsDirty || !settingsValid ? 'not-allowed' : 'pointer',
}}
@ -963,7 +851,7 @@ export function SettingsPage() {
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
@ -992,20 +880,7 @@ export function SettingsPage() {
</h2>
<button
onClick={() => setShowCreateProfileModal(true)}
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)')}
className="btn btn-primary"
>
<Plus size={16} />
Add Profile
@ -1015,7 +890,7 @@ export function SettingsPage() {
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
@ -1045,20 +920,7 @@ export function SettingsPage() {
</h2>
<button
onClick={() => setShowCreateNotifModal(true)}
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)')}
className="btn btn-primary"
>
<Plus size={16} />
Add Channel
@ -1068,7 +930,7 @@ export function SettingsPage() {
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
@ -1148,36 +1010,15 @@ export function SettingsPage() {
<button
onClick={() => setDeletingProfile(null)}
disabled={deleteProfileMutation.isPending}
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)')}
className="btn btn-ghost"
>
Cancel
</button>
<button
onClick={handleDeleteProfile}
disabled={deleteProfileMutation.isPending}
style={{
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,
}}
className="btn btn-danger"
style={{ opacity: deleteProfileMutation.isPending ? 0.6 : 1 }}
>
{deleteProfileMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Delete
@ -1271,36 +1112,15 @@ export function SettingsPage() {
<button
onClick={() => setDeletingNotification(null)}
disabled={deleteNotifMutation.isPending}
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)')}
className="btn btn-ghost"
>
Cancel
</button>
<button
onClick={handleDeleteNotification}
disabled={deleteNotifMutation.isPending}
style={{
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,
}}
className="btn btn-danger"
style={{ opacity: deleteNotifMutation.isPending ? 0.6 : 1 }}
>
{deleteNotifMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Delete
@ -1338,36 +1158,15 @@ export function SettingsPage() {
<button
onClick={() => setShowRegenerateConfirm(false)}
disabled={regenerateApiKeyMutation.isPending}
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)')}
className="btn btn-ghost"
>
Cancel
</button>
<button
onClick={handleRegenerateApiKey}
disabled={regenerateApiKeyMutation.isPending}
style={{
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,
}}
className="btn btn-warning"
style={{ opacity: regenerateApiKeyMutation.isPending ? 0.6 : 1 }}
>
{regenerateApiKeyMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Regenerate

View file

@ -1,8 +1,11 @@
import { Loader, RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
import { useSystemStatus, useHealth } from '../api/hooks/useSystem';
import { RefreshCw, Server, Activity, Cpu, HardDrive, Download, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem';
import { HealthStatus } from '../components/HealthStatus';
import { SkeletonSystem } from '../components/Skeleton';
import { formatBytes } from '../utils/format';
import { useState } from 'react';
// ── Helpers ──
function formatUptime(seconds: number): string {
@ -22,16 +25,14 @@ function formatUptime(seconds: number): string {
export function SystemPage() {
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
const { data: ytdlpStatus, isLoading: ytdlpLoading, error: ytdlpError } = useYtDlpStatus();
const updateYtDlp = useUpdateYtDlp();
const [updateMessage, setUpdateMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isLoading = healthLoading || statusLoading;
if (isLoading) {
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 <SkeletonSystem />;
}
return (
@ -52,25 +53,7 @@ export function SystemPage() {
onClick={() => refetchHealth()}
title="Refresh health status"
aria-label="Refresh health status"
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)';
}}
className="btn btn-ghost"
>
<RefreshCw size={14} />
Refresh
@ -95,17 +78,7 @@ export function SystemPage() {
<button
onClick={() => refetchHealth()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry
@ -116,6 +89,105 @@ export function SystemPage() {
) : null}
</section>
{/* ── yt-dlp section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
<Download size={18} style={{ color: 'var(--accent)' }} />
yt-dlp
</h2>
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--border)',
padding: 'var(--space-4)',
}}
>
{ytdlpLoading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading yt-dlp status</p>
) : ytdlpError ? (
<p style={{ color: 'var(--danger)' }}>
Failed to load yt-dlp status: {ytdlpError instanceof Error ? ytdlpError.message : 'Unknown error'}
</p>
) : (
<>
<div style={{ display: 'flex', gap: 'var(--space-6)', marginBottom: 'var(--space-4)' }}>
<div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Version</span>
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--text-primary)', marginTop: 'var(--space-1)' }}>
{ytdlpStatus?.version ?? 'Unknown'}
</p>
</div>
<div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Last Updated</span>
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--text-primary)', marginTop: 'var(--space-1)' }}>
{ytdlpStatus?.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'}
</p>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<button
className="btn btn-secondary"
disabled={updateYtDlp.isPending}
onClick={() => {
setUpdateMessage(null);
updateYtDlp.mutate(undefined, {
onSuccess: (data) => {
setUpdateMessage({
type: 'success',
text: data.updated
? `Updated to ${data.version} (was ${data.previousVersion})`
: `Already up to date (${data.version})`,
});
},
onError: (err) => {
setUpdateMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Update failed',
});
},
});
}}
>
{updateYtDlp.isPending ? (
<>
<Loader2 size={14} className="animate-spin" />
Updating
</>
) : (
<>
<RefreshCw size={14} />
Check for Updates
</>
)}
</button>
{updateMessage && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
color: updateMessage.type === 'success' ? 'var(--success)' : 'var(--danger)',
}}>
{updateMessage.type === 'success' ? <CheckCircle size={14} /> : <AlertCircle size={14} />}
{updateMessage.text}
</span>
)}
</div>
</>
)}
</div>
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-xs)', marginTop: 'var(--space-2)' }}>
Auto-refreshes every 60 seconds.
</p>
</section>
{/* ── System Status section ── */}
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
@ -127,25 +199,7 @@ export function SystemPage() {
onClick={() => refetchStatus()}
title="Refresh system status"
aria-label="Refresh system status"
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)';
}}
className="btn btn-ghost"
>
<RefreshCw size={14} />
Refresh
@ -170,17 +224,7 @@ export function SystemPage() {
<button
onClick={() => refetchStatus()}
aria-label="Retry"
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,
}}
className="btn btn-danger"
>
<RefreshCw size={14} />
Retry

View file

@ -1,5 +1,6 @@
/* ── Global Reset & Base Styles ── */
@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,
@ -13,6 +14,7 @@ html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
@ -22,6 +24,7 @@ body {
background-color: var(--bg-main);
line-height: 1.5;
min-height: 100vh;
letter-spacing: -0.01em;
}
#root {
@ -32,6 +35,7 @@ body {
a {
color: var(--text-link);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
@ -42,25 +46,26 @@ a:hover {
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Scrollbar styling ── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-main);
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-light);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
background: rgba(255, 255, 255, 0.15);
}
/* ── Buttons base ── */
@ -71,6 +76,7 @@ button {
border: none;
background: none;
color: inherit;
transition: all var(--transition-fast);
}
/* ── Inputs base ── */
@ -81,15 +87,17 @@ select {
font-size: inherit;
color: var(--text-primary);
background-color: var(--bg-input);
border: 1px solid var(--border);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
outline: none;
}
@ -106,10 +114,10 @@ table {
th {
text-align: left;
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-size: var(--font-size-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.06em;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
@ -136,6 +144,161 @@ tr:hover {
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 ── */
@keyframes pulse {
0%, 100% { opacity: 1; }
@ -146,3 +309,51 @@ tr:hover {
from { transform: rotate(0deg); }
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;
}
}

View file

@ -1,56 +1,60 @@
/* *arr Dark Theme
* Color palette matching Sonarr/Radarr aesthetic.
* Color palette matching Sonarr/Radarr aesthetic with modern glassmorphism touches.
* All UI components reference these custom properties.
*/
:root {
/* ── Backgrounds ── */
--bg-main: #1a1d23;
--bg-sidebar: #14161a;
--bg-card: #242731;
--bg-input: #2a2e38;
--bg-hover: #2f3341;
--bg-selected: #35394a;
--bg-header: #1e2029;
--bg-toolbar: #1e2129;
--bg-modal-overlay: rgba(0, 0, 0, 0.6);
--bg-main: #0f1117;
--bg-sidebar: #0a0c10;
--bg-card: rgba(30, 33, 44, 0.8);
--bg-card-solid: #1e212c;
--bg-input: #1a1d26;
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-selected: rgba(255, 255, 255, 0.08);
--bg-header: #13151c;
--bg-toolbar: #13151c;
--bg-modal-overlay: rgba(0, 0, 0, 0.7);
--bg-glass: rgba(20, 22, 30, 0.6);
/* ── Accent ── */
--accent: #e05d44;
--accent-hover: #c94e38;
--accent-subtle: rgba(224, 93, 68, 0.12);
--accent-hover: #f06a51;
--accent-subtle: rgba(224, 93, 68, 0.1);
--accent-glow: rgba(224, 93, 68, 0.25);
/* ── Text ── */
--text-primary: #e1e2e6;
--text-primary: #e8e9ed;
--text-secondary: #8b8d97;
--text-muted: #5d5f69;
--text-inverse: #14161a;
--text-muted: #4d5060;
--text-inverse: #0f1117;
--text-link: #e05d44;
/* ── Status colors ── */
--success: #27c24c;
--success-bg: rgba(39, 194, 76, 0.12);
--warning: #ff902b;
--warning-bg: rgba(255, 144, 43, 0.12);
--success: #34d058;
--success-bg: rgba(52, 208, 88, 0.1);
--warning: #ff9f43;
--warning-bg: rgba(255, 159, 67, 0.1);
--danger: #f05050;
--danger-bg: rgba(240, 80, 80, 0.12);
--danger-bg: rgba(240, 80, 80, 0.1);
--info: #e05d44;
--info-bg: rgba(224, 93, 68, 0.12);
--info-bg: rgba(224, 93, 68, 0.1);
/* ── Borders ── */
--border: #2d3040;
--border-light: #373b4e;
--border: rgba(255, 255, 255, 0.06);
--border-light: rgba(255, 255, 255, 0.1);
--border-accent: rgba(224, 93, 68, 0.3);
/* ── Typography ── */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-size-xs: 0.75rem;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-size-xs: 0.6875rem;
--font-size-sm: 0.8125rem;
--font-size-base: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-xl: 1.375rem;
--font-size-2xl: 1.75rem;
/* ── Spacing ── */
--space-1: 0.25rem;
@ -64,22 +68,32 @@
--space-12: 3rem;
/* ── Border Radius ── */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-2xl: 20px;
--radius-full: 9999px;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(224, 93, 68, 0.15);
/* ── Layout ── */
--sidebar-width: 210px;
--sidebar-collapsed: 50px;
--header-height: 55px;
--sidebar-width: 220px;
--sidebar-collapsed: 56px;
--header-height: 56px;
/* ── Transitions ── */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--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);
}

View file

@ -23,6 +23,7 @@ import { PlatformRegistry } from './sources/platform-source';
import { YouTubeSource } from './sources/youtube';
import { SoundCloudSource } from './sources/soundcloud';
import { Platform } from './types/index';
import { getYtDlpVersion, updateYtDlp } from './sources/yt-dlp';
import type { ViteDevServer } from 'vite';
const APP_NAME = 'Tubearr';
@ -44,6 +45,25 @@ async function main(): Promise<void> {
await seedAppDefaults(db);
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
// In dev mode, embed Vite for HMR — single port, no separate frontend process
let vite: ViteDevServer | undefined;
@ -128,6 +148,7 @@ async function main(): Promise<void> {
);
});
},
eventBus,
});
// Attach scheduler to server so routes can notify it

View file

@ -176,6 +176,9 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
imageUrl: metadata.imageUrl,
metadata: null,
formatProfileId: formatProfileId ?? null,
bannerUrl: metadata.bannerUrl ?? null,
description: metadata.description ?? null,
subscriberCount: metadata.subscriberCount ?? null,
});
// Notify scheduler of new channel

View file

@ -2,6 +2,7 @@ import { type FastifyInstance } from 'fastify';
import {
getAllContentItems,
getContentByChannelId,
getChannelContentPaginated,
setMonitored,
bulkSetMonitored,
} from '../../db/repositories/content-repository';
@ -202,6 +203,15 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get<{
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) => {
const channelId = parseInt(request.params.id, 10);
@ -213,12 +223,50 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
});
}
try {
const items = await getContentByChannelId(fastify.db, channelId);
const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1);
const pageSize = Math.min(
200,
Math.max(1, parseInt(request.query.pageSize ?? '50', 10) || 50)
);
const response: ApiResponse<ContentItem[]> = {
// 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 {
if (!hasPaginationParams) {
// 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 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,
data: items,
data: result.items,
pagination: {
page,
pageSize,
totalItems: result.total,
totalPages: Math.ceil(result.total / pageSize),
},
};
return response;

View file

@ -5,15 +5,18 @@ import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { appConfig } from '../../config/index';
import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse } from '../../types/api';
import type { SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '../../types/api';
import { systemConfig } from '../../db/schema/index';
import { API_KEY_DB_KEY } from '../middleware/auth';
import {
getAppSettings,
getAppSetting,
setAppSetting,
APP_CHECK_INTERVAL,
APP_CONCURRENT_DOWNLOADS,
YTDLP_LAST_UPDATED,
} from '../../db/repositories/system-config-repository';
import { getYtDlpVersion, updateYtDlp } from '../../sources/yt-dlp';
import os from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -179,4 +182,44 @@ export async function systemRoutes(fastify: FastifyInstance): Promise<void> {
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;
});
}

View file

@ -1,7 +1,16 @@
import websocket from '@fastify/websocket';
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import type { DownloadEventBus, DownloadProgressPayload, DownloadCompletePayload, DownloadFailedPayload } from '../../services/event-bus';
import type {
DownloadEventBus,
DownloadProgressPayload,
DownloadCompletePayload,
DownloadFailedPayload,
ScanStartedPayload,
ScanItemDiscoveredPayload,
ScanCompletePayload,
ScanErrorPayload,
} from '../../services/event-bus';
/**
* WebSocket route plugin.
@ -39,16 +48,43 @@ export async function websocketRoutes(
sendJson(socket, { type: 'download:failed', ...data });
};
// Subscribe to event bus
// Subscribe to download events
eventBus.onDownload('download:progress', onProgress);
eventBus.onDownload('download:complete', onComplete);
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
const cleanup = () => {
eventBus.offDownload('download:progress', onProgress);
eventBus.offDownload('download:complete', onComplete);
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');
};

View file

@ -3,7 +3,7 @@ import { extname } from 'node:path';
import { createInterface } from 'node:readline';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '../db/schema/index';
import { execYtDlp, spawnYtDlp, YtDlpError } from '../sources/yt-dlp';
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
import { updateContentItem } from '../db/repositories/content-repository';
import { parseProgressLine } from './progress-parser';
import type { DownloadEventBus } from './event-bus';
@ -137,16 +137,22 @@ export class DownloadService {
// Report error to rate limiter
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
await updateContentItem(this.db, contentItem.id, { status: 'failed' });
const errorMsg = err instanceof Error ? err.message : String(err);
console.log(`${logPrefix} status=failed error="${errorMsg.slice(0, 200)}"`);
console.log(
`${logPrefix} status=failed category=${errorCategory} error="${errorMsg.slice(0, 200)}"`
);
// Emit download:failed event
// Emit download:failed event with error category
this.eventBus?.emitDownload('download:failed', {
contentItemId: contentItem.id,
error: errorMsg.slice(0, 200),
error: `[${errorCategory}] ${errorMsg.slice(0, 200)}`,
});
throw err;
@ -288,32 +294,32 @@ export class DownloadService {
/**
* 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[] {
const args: string[] = [];
const container = formatProfile?.containerFormat ?? 'mp4';
if (formatProfile?.videoResolution === 'Best') {
// "Best" selects separate best-quality video + audio streams, merged together.
// This is higher quality than `-f best` which picks a single combined format.
args.push('-f', 'bestvideo+bestaudio/best');
const container = formatProfile.containerFormat ?? 'mp4';
// Best quality: separate streams merged
args.push('-f', 'bestvideo+bestaudio/bestvideo*+bestaudio/best');
args.push('--merge-output-format', container);
} else if (formatProfile?.videoResolution) {
const height = parseResolutionHeight(formatProfile.videoResolution);
if (height) {
// Fallback chain: exact res → best under res → single best stream → any
args.push(
'-f',
`bestvideo[height<=${height}]+bestaudio/best[height<=${height}]`
`bestvideo[height<=${height}]+bestaudio/bestvideo[height<=${height}]*+bestaudio/best[height<=${height}]/bestvideo+bestaudio/best`
);
} else {
args.push('-f', 'best');
args.push('-f', 'bestvideo+bestaudio/best');
}
// Container format for merge
const container = formatProfile.containerFormat ?? 'mp4';
args.push('--merge-output-format', container);
} else {
args.push('-f', 'best');
args.push('-f', 'bestvideo+bestaudio/best');
args.push('--merge-output-format', container);
}
return args;
@ -367,18 +373,33 @@ export class DownloadService {
/**
* Parse the final file path from yt-dlp stdout.
* The `--print after_move:filepath` flag makes yt-dlp output the final path
* as the last line of stdout.
* The `--print after_move:filepath` flag makes yt-dlp output the final path.
*
* 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 {
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--) {
const line = lines[i].trim();
if (line && !line.startsWith('[') && !line.startsWith('Deleting')) {
if (!line) continue;
// 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;
}
}
console.warn('[download] Could not parse final path from yt-dlp output, using fallback');
return fallbackPath;
}

View file

@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events';
import type { ContentItem } from '../types/index';
// ── Event Payload Types ──
// ── Download Event Payload Types ──
export interface DownloadProgressPayload {
contentItemId: number;
@ -18,7 +19,33 @@ export interface DownloadFailedPayload {
error: string;
}
// ── Event Map ──
// ── Scan Event Payload Types ──
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 {
'download:progress': [DownloadProgressPayload];
@ -26,17 +53,23 @@ export interface DownloadEventMap {
'download:failed': [DownloadFailedPayload];
}
export interface ScanEventMap {
'scan:started': [ScanStartedPayload];
'scan:item-discovered': [ScanItemDiscoveredPayload];
'scan:complete': [ScanCompletePayload];
'scan:error': [ScanErrorPayload];
}
// ── Typed Event Bus ──
/**
* Typed EventEmitter for download events.
* Decouples download progress producers (DownloadService) from
* Typed EventEmitter for download and scan events.
* Decouples event producers (DownloadService, SchedulerService) from
* consumers (WebSocket route, logging, etc).
*/
export class DownloadEventBus extends EventEmitter {
/**
* Emit a typed download event.
*/
export class EventBus extends EventEmitter {
// ── Download events ──
emitDownload<K extends keyof DownloadEventMap>(
event: K,
...args: DownloadEventMap[K]
@ -44,9 +77,6 @@ export class DownloadEventBus extends EventEmitter {
return this.emit(event, ...args);
}
/**
* Subscribe to a typed download event.
*/
onDownload<K extends keyof DownloadEventMap>(
event: K,
listener: (...args: DownloadEventMap[K]) => void
@ -54,13 +84,38 @@ export class DownloadEventBus extends EventEmitter {
return this.on(event, listener as (...args: unknown[]) => void);
}
/**
* Unsubscribe from a typed download event.
*/
offDownload<K extends keyof DownloadEventMap>(
event: K,
listener: (...args: DownloadEventMap[K]) => void
): this {
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;

View file

@ -5,6 +5,7 @@ import type { Channel, Platform, PlatformContentMetadata } from '../types/index'
import type { PlatformRegistry, FetchRecentContentOptions } from '../sources/platform-source';
import type { RateLimiter } from './rate-limiter';
import { YtDlpError } from '../sources/yt-dlp';
import type { EventBus } from './event-bus';
import {
getEnabledChannels,
updateChannel,
@ -45,6 +46,8 @@ export interface CheckChannelResult {
export interface SchedulerOptions {
/** Called when a new content item is inserted — used to auto-enqueue for download. */
onNewContent?: (contentItemId: number) => void;
/** Event bus for broadcasting scan lifecycle events to WebSocket clients. */
eventBus?: EventBus;
}
// ── Scheduler Service ──
@ -61,6 +64,7 @@ export class SchedulerService {
private readonly platformRegistry: PlatformRegistry;
private readonly rateLimiter: RateLimiter;
private readonly onNewContent?: (contentItemId: number) => void;
private readonly eventBus?: EventBus;
private readonly jobs = new Map<number, Cron>();
private readonly channelCache = new Map<number, Channel>();
private readonly activeChecks = new Set<number>();
@ -76,6 +80,7 @@ export class SchedulerService {
this.platformRegistry = platformRegistry;
this.rateLimiter = rateLimiter;
this.onNewContent = options?.onNewContent;
this.eventBus = options?.eventBus;
}
/**
@ -166,6 +171,12 @@ export class SchedulerService {
this.activeChecks.add(channel.id);
// Emit scan:started before any async work
this.eventBus?.emitScan('scan:started', {
channelId: channel.id,
channelName: channel.name,
});
console.log(
`[scheduler] Checking channel ${channel.id} ("${channel.name}") on ${channel.platform}`
);
@ -225,6 +236,12 @@ export class SchedulerService {
});
if (created) {
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
if (this.onNewContent && created.monitored) {
this.onNewContent(created.id);
@ -244,6 +261,13 @@ export class SchedulerService {
`[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,
});
return {
channelId: channel.id,
channelName: channel.name,
@ -272,6 +296,12 @@ export class SchedulerService {
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(
`[scheduler] Check failed for channel ${channel.id} ("${channel.name}"): ${status}`,
err instanceof Error ? err.message : err

View file

@ -47,12 +47,23 @@ export class SoundCloudSource implements PlatformSource {
? (thumbnails[thumbnails.length - 1]?.url ?? 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 {
name: channelName,
platformId: uploaderId,
imageUrl,
url: uploaderUrl,
platform: Platform.SoundCloud,
bannerUrl: null, // SoundCloud doesn't provide banner URLs via yt-dlp
description,
subscriberCount,
};
}

View file

@ -44,17 +44,35 @@ export class YouTubeSource implements PlatformSource {
url;
// Pick the best thumbnail — yt-dlp returns an array sorted by quality
const thumbnails = data.thumbnails as Array<{ url?: string }> | undefined;
const thumbnails = data.thumbnails as Array<{ url?: string; width?: number }> | undefined;
const imageUrl = thumbnails?.length
? (thumbnails[thumbnails.length - 1]?.url ?? 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 {
name: channelName,
platformId: channelId,
imageUrl,
url: channelUrl,
platform: Platform.YouTube,
bannerUrl,
description,
subscriberCount,
};
}

View file

@ -26,6 +26,7 @@ export class YtDlpError extends Error {
readonly stderr: string;
readonly exitCode: number;
readonly isRateLimit: boolean;
readonly category: YtDlpErrorCategory;
constructor(message: string, stderr: string, exitCode: number) {
super(message);
@ -33,6 +34,7 @@ export class YtDlpError extends Error {
this.stderr = stderr;
this.exitCode = exitCode;
this.isRateLimit = detectRateLimit(stderr);
this.category = classifyYtDlpError(stderr);
}
}
@ -212,3 +214,92 @@ export async function getYtDlpVersion(): Promise<string | 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';
}

View file

@ -80,3 +80,19 @@ export interface AppSettingsResponse {
export type ChannelWithCounts = import('./index').Channel & {
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;
}

View file

@ -43,6 +43,9 @@ export interface PlatformSourceMetadata {
imageUrl: string | null;
url: string;
platform: Platform;
bannerUrl?: string | null;
description?: string | null;
subscriberCount?: number | null;
}
/** Metadata for a single piece of content from a platform. */
@ -70,6 +73,9 @@ export interface Channel {
metadata: Record<string, unknown> | null;
formatProfileId: number | null;
monitoringMode: MonitoringMode;
bannerUrl: string | null;
description: string | null;
subscriberCount: number | null;
createdAt: string;
updatedAt: string;
lastCheckedAt: string | null;
@ -113,6 +119,7 @@ export interface QueueItem {
attempts: number;
maxAttempts: number;
error: string | null;
errorCategory: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;