diff --git a/.gitignore b/.gitignore index e834e5c..57c9dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ venv/ target/ vendor/ config/ +media/ diff --git a/docker-compose.yml b/docker-compose.yml index 4fd08fe..fa093b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/drizzle/0009_many_carlie_cooper.sql b/drizzle/0009_many_carlie_cooper.sql new file mode 100644 index 0000000..9ffe95f --- /dev/null +++ b/drizzle/0009_many_carlie_cooper.sql @@ -0,0 +1 @@ +ALTER TABLE `queue_items` ADD `error_category` text; \ No newline at end of file diff --git a/drizzle/0010_special_ghost_rider.sql b/drizzle/0010_special_ghost_rider.sql new file mode 100644 index 0000000..adcd59e --- /dev/null +++ b/drizzle/0010_special_ghost_rider.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..755adda --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..ffe76c8 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 063a6a8..445549e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/__tests__/download.test.ts b/src/__tests__/download.test.ts index a961bd9..3f83acb 100644 --- a/src/__tests__/download.test.ts +++ b/src/__tests__/download.test.ts @@ -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'); }); diff --git a/src/__tests__/sources.test.ts b/src/__tests__/sources.test.ts index 60dc603..4174ab5 100644 --- a/src/__tests__/sources.test.ts +++ b/src/__tests__/sources.test.ts @@ -135,6 +135,9 @@ function makeChannel(overrides: Partial = {}): 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, }); }); }); diff --git a/src/__tests__/yt-dlp-classification.test.ts b/src/__tests__/yt-dlp-classification.test.ts new file mode 100644 index 0000000..94713a1 --- /dev/null +++ b/src/__tests__/yt-dlp-classification.test.ts @@ -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: ')).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'); + }); +}); diff --git a/src/db/repositories/channel-repository.ts b/src/db/repositories/channel-repository.ts index ecc955b..0c9872f 100644 --- a/src/db/repositories/channel-repository.ts +++ b/src/db/repositories/channel-repository.ts @@ -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 + Pick >; type Db = LibSQLDatabase; @@ -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 | 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, diff --git a/src/db/repositories/content-repository.ts b/src/db/repositories/content-repository.ts index 260946e..346fa69 100644 --- a/src/db/repositories/content-repository.ts +++ b/src/db/repositories/content-repository.ts @@ -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 { + 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`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, diff --git a/src/db/repositories/queue-repository.ts b/src/db/repositories/queue-repository.ts index 68b28be..a589a25 100644 --- a/src/db/repositories/queue-repository.ts +++ b/src/db/repositories/queue-repository.ts @@ -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, diff --git a/src/db/repositories/system-config-repository.ts b/src/db/repositories/system-config-repository.ts index d9276cc..4be9d8f 100644 --- a/src/db/repositories/system-config-repository.ts +++ b/src/db/repositories/system-config-repository.ts @@ -10,6 +10,7 @@ type Db = LibSQLDatabase; 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 ── diff --git a/src/db/schema/channels.ts b/src/db/schema/channels.ts index dfc9620..58f187b 100644 --- a/src/db/schema/channels.ts +++ b/src/db/schema/channels.ts @@ -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'), }); diff --git a/src/db/schema/queue.ts b/src/db/schema/queue.ts index 7820384..5babed0 100644 --- a/src/db/schema/queue.ts +++ b/src/db/schema/queue.ts @@ -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') diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 9c29038..c2a64a7 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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 ( - - } /> - + + + } /> + + ); } diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index 51de309..4d8582c 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -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). */ diff --git a/src/frontend/src/api/hooks/useContent.ts b/src/frontend/src/api/hooks/useContent.ts index d9f0a4e..e11739b 100644 --- a/src/frontend/src/api/hooks/useContent.ts +++ b/src/frontend/src/api/hooks/useContent.ts @@ -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>( + `/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. */ diff --git a/src/frontend/src/api/hooks/useSystem.ts b/src/frontend/src/api/hooks/useSystem.ts index eb1b01b..eb0ede2 100644 --- a/src/frontend/src/api/hooks/useSystem.ts +++ b/src/frontend/src/api/hooks/useSystem.ts @@ -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('/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('/api/v1/system/ytdlp/update'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: systemKeys.ytdlpStatus }); + }, + }); +} diff --git a/src/frontend/src/components/AddChannelModal.tsx b/src/frontend/src/components/AddChannelModal.tsx index 7ffc163..61324be 100644 --- a/src/frontend/src/components/AddChannelModal.tsx +++ b/src/frontend/src/components/AddChannelModal.tsx @@ -51,8 +51,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { const [checkInterval, setCheckInterval] = useState(''); const [formatProfileId, setFormatProfileId] = useState(undefined); const [monitoringMode, setMonitoringMode] = useState('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 ( -
+ + {/* Loading overlay */} + {createChannel.isPending && ( +
+ + + Resolving channel… + +
+ )} {/* URL input */}
)} - {/* Monitoring Mode — shown when platform detected */} - {detectedPlatform && ( -
- - -
- )} - - {/* Grab All — YouTube only */} - {detectedPlatform === 'youtube' && ( - <> -
- setGrabAll(e.target.checked)} - disabled={createChannel.isPending} - style={{ width: 'auto' }} - /> - -
- - {/* Download order — shown when grab-all enabled */} - {grabAll && ( -
- - -

- Back-catalog items will be enqueued at low priority. -

-
- )} - - )} + {/* Check interval (optional) */} +
+ + setCheckInterval(e.target.value)} + placeholder="360 (default: 6 hours)" + disabled={createChannel.isPending} + style={{ width: '100%' }} + /> +
{/* 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'} )} diff --git a/src/frontend/src/components/ContentCard.tsx b/src/frontend/src/components/ContentCard.tsx new file mode 100644 index 0000000..82c9e10 --- /dev/null +++ b/src/frontend/src/components/ContentCard.tsx @@ -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 ( +
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 */} +
+ {item.thumbnailUrl ? ( + + ) : ( +
+ {item.contentType === 'audio' ? : } +
+ )} + + {/* Duration badge */} + {duration && ( + + {duration} + + )} + + {/* Selection checkbox */} +
+ { + e.stopPropagation(); + onSelect(item.id); + }} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${item.title}`} + style={{ + width: 18, + height: 18, + cursor: 'pointer', + accentColor: 'var(--accent)', + }} + /> +
+ + {/* Download progress overlay */} + {item.status === 'downloading' && progress && ( +
+ +
+ )} +
+ + {/* Card body */} +
+ {/* Title */} + 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} + + + {/* Meta row */} +
+ + + {published} + +
+ + {/* Action row */} +
+ + + {item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && ( + + )} + + 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)', + }} + > + + +
+
+
+ ); +} diff --git a/src/frontend/src/components/ContentListItem.tsx b/src/frontend/src/components/ContentListItem.tsx new file mode 100644 index 0000000..e99e64d --- /dev/null +++ b/src/frontend/src/components/ContentListItem.tsx @@ -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 ( +
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 */} +
+ { + e.stopPropagation(); + onSelect(item.id); + }} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${item.title}`} + style={{ + width: 16, + height: 16, + cursor: 'pointer', + accentColor: 'var(--accent)', + }} + /> +
+ + {/* Thumbnail */} +
+ {item.thumbnailUrl ? ( + + ) : ( +
+ {item.contentType === 'audio' ? : } +
+ )} + + {/* Duration badge on thumbnail */} + {duration && ( + + {duration} + + )} + + {/* Download progress overlay */} + {item.status === 'downloading' && progress && ( +
+ +
+ )} +
+ + {/* Info section */} +
+ {/* Title */} + 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} + + + {/* Meta row: published · duration · content type */} +
+ {published && {published}} + {published && duration && ·} + {duration && {duration}} + {(published || duration) && ·} + {item.contentType} +
+
+ + {/* Right section: status badge + action buttons */} +
+ + +
+ + + {item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && ( + + )} + + 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)', + }} + > + + +
+
+
+ ); +} diff --git a/src/frontend/src/components/Modal.tsx b/src/frontend/src/components/Modal.tsx index 2e2317b..b8782af 100644 --- a/src/frontend/src/components/Modal.tsx +++ b/src/frontend/src/components/Modal.tsx @@ -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 diff --git a/src/frontend/src/components/Sidebar.tsx b/src/frontend/src/components/Sidebar.tsx index 532dc0c..3c7b112 100644 --- a/src/frontend/src/components/Sidebar.tsx +++ b/src/frontend/src/components/Sidebar.tsx @@ -78,26 +78,11 @@ export function Sidebar() { + ); + })} + + {/* Spacer */} +
+ + {/* Group by */} + + Group + + +
+ ); +} diff --git a/src/frontend/src/components/Toast.tsx b/src/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..06665e5 --- /dev/null +++ b/src/frontend/src/components/Toast.tsx @@ -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(null); + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used inside '); + return ctx; +} + +// ── Provider ── + +const TOAST_DURATION = 5000; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + 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 ( + + {children} + {/* Toast container */} + {toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
dismiss(t.id)} + > + {t.message} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/frontend/src/contexts/DownloadProgressContext.tsx b/src/frontend/src/contexts/DownloadProgressContext.tsx index 5683b66..23aac8d 100644 --- a/src/frontend/src/contexts/DownloadProgressContext.tsx +++ b/src/frontend/src/contexts/DownloadProgressContext.tsx @@ -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(); @@ -63,6 +98,58 @@ class ProgressStore { } } +// ── Scan Progress Store ── + +export interface ScanProgress { + scanning: boolean; + newItemCount: number; +} + +class ScanStore { + private _map = new Map(); + 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; } const DownloadProgressContext = createContext(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 ( - + {children} ); @@ -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>( + { 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, + ), + }, + }; + }, + ); +} diff --git a/src/frontend/src/pages/Activity.tsx b/src/frontend/src/pages/Activity.tsx index f803b11..e90d7d3 100644 --- a/src/frontend/src/pages/Activity.tsx +++ b/src/frontend/src/pages/Activity.tsx @@ -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() { - {isExpanded ? renderTable(group.items) : null} + {isExpanded ? ( + viewMode === 'card' ? renderCardGrid(group.items) : + viewMode === 'list' ? renderListView(group.items) : + renderTable(group.items) + ) : null} ); })} ), - [expandedPlaylists, togglePlaylist, renderTable], + [expandedGroups, toggleGroup, renderTable, renderCardGrid, renderListView, viewMode], ); // ── Loading / Error states ── if (channelLoading) { return ( -
- - Loading channel... +
+ +
+ +
); } @@ -733,7 +892,6 @@ export function ChannelDetail() { } const isYouTube = channel.platform === 'youtube'; - const hasPlaylistGroups = isYouTube && playlistGroups !== null; return (
@@ -752,309 +910,445 @@ export function ChannelDetail() { Back to Channels - {/* Channel header */} + {/* Compact sticky bar — visible when full header scrolls out of view */}
- {/* Avatar */} + {/* Identity — compact */} {`${channel.name} + + {channel.name} + + + {channel.subscriberCount != null && ( + + {formatSubscriberCount(channel.subscriberCount)} subscribers + + )} - {/* Info */} -
-
-

- {channel.name} -

- -
+
- - - {channel.url} - - - + {/* Key actions — compact */} + - {/* Actions row */} + + + + +
+ + {/* Scroll-to-top to reveal full header */} + +
+ + {/* Full channel header — observed for collapse trigger */} +
+ {/* Banner area */} + {channel.bannerUrl ? (
- {/* Monitoring mode dropdown */} - + /> + ) : ( +
+ )} - {/* Format profile selector */} - - - {/* Per-channel check interval */} -
- setLocalCheckInterval(e.target.value === '' ? '' : Number(e.target.value))} - aria-label="Check interval in minutes" - title="Check interval (minutes)" - style={{ - width: 64, - padding: 'var(--space-2) var(--space-2)', - borderRadius: 'var(--radius-md)', - border: '1px solid var(--border)', - backgroundColor: 'var(--bg-main)', - color: 'var(--text-primary)', - fontSize: 'var(--font-size-sm)', - }} - /> - min - -
- - {/* Refresh & Scan button */} - - - {/* Collect Monitored button */} - - - {/* Refresh Playlists button (YouTube only) */} - {isYouTube ? ( -
+
+ + {/* Description — collapsible */} + {channel.description && ( +
+

150 + ? { overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } + : {} + ), }} > - {refreshPlaylists.isPending ? ( + {channel.description} +

+ {channel.description.length > 150 && ( + + )} +
+ )} +
+ {/* Monitoring group */} +
+ + Monitoring + +
+ +
+ setLocalCheckInterval(e.target.value === '' ? '' : Number(e.target.value))} + aria-label="Check interval in minutes" + title="Check interval (minutes)" + style={{ + width: 56, + padding: 'var(--space-2)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border)', + backgroundColor: 'var(--bg-main)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + }} + /> + min + +
+
+
+ + {/* Format group */} +
+ + Format + + +
+ + {/* Actions group */} +
+ + Actions + +
+ - ) : null} + + {isYouTube ? ( + + ) : null} +
+
- {/* Delete button */} + {/* Spacer + Delete */} +
+
{/* Content table / playlist groups */}

Content

+ {contentPagination ? ( + + {contentPagination.totalItems} items + + ) : null} +
+ {/* Search */} + { setContentSearch(e.target.value); setContentPage(1); }} + style={{ + padding: 'var(--space-2) var(--space-3)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border)', + backgroundColor: 'var(--bg-input)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-sm)', + width: 200, + }} + /> + {/* Status filter */} + + {/* Type filter */} + + {/* View mode segmented control */} +
+ + + +
+ {/* Sort & Group controls */} + {contentError ? (
refetchContent()} 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" + style={{ flexShrink: 0 }} > Retry @@ -1121,18 +1544,30 @@ export function ChannelDetail() {
) : null} {contentLoading ? ( -
- - Loading content... -
- ) : hasPlaylistGroups ? ( - renderPlaylistGroups(playlistGroups!) + + ) : groupedContent ? ( + renderGroupedContent(groupedContent) + ) : viewMode === 'card' ? ( + renderCardGrid(content) + ) : viewMode === 'list' ? ( + renderListView(content) ) : ( - renderTable(sortedContent) + renderTable(content) )} + {/* Pagination controls */} + {contentPagination && contentPagination.totalPages > 1 ? ( +
+ +
+ ) : null}
- {/* Floating bulk action bar */} + {/* Floating bulk action bar — glassmorphism */} {selectedIds.size > 0 ? (
) : null} - {/* Download error toast */} - {downloadContent.isError ? ( -
- {downloadContent.error instanceof Error - ? downloadContent.error.message - : 'Failed to enqueue download'} -
- ) : null} - - {/* Scan result toast */} - {scanResult ? ( -
- {scanResult.message} -
- ) : null} - {/* Delete confirmation modal */} setShowDeleteConfirm(false)} disabled={deleteChannel.isPending} - style={{ - padding: 'var(--space-2) var(--space-4)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - fontSize: 'var(--font-size-sm)', - fontWeight: 500, - }} + className="btn btn-ghost" > Cancel @@ -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)' }} > @@ -294,18 +271,7 @@ export function Queue() { @@ -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" > @@ -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" > @@ -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' ? @@ -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" > @@ -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" > @@ -652,12 +592,7 @@ export function SettingsPage() { // ── Loading state ── if (profilesLoading) { - return ( -
- - Loading settings... -
- ); + return ; } // ── Error state ── @@ -683,17 +618,7 @@ export function SettingsPage() { @@ -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 ? : } @@ -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" > @@ -911,17 +806,10 @@ export function SettingsPage() { + + {updateMessage && ( + + {updateMessage.type === 'success' ? : } + {updateMessage.text} + + )} +
+ + )} +
+ +

+ Auto-refreshes every 60 seconds. +

+ + {/* ── System Status section ── */}
@@ -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" > Refresh @@ -170,17 +224,7 @@ export function SystemPage() {