From 373a2ee6499b41f248a35dfaccb93014d8f444e0 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 05:07:24 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Created=20POST=20/api/v1/download/url/p?= =?UTF-8?q?review=20endpoint=20that=20resolves=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/server/routes/adhoc-download.ts" - "src/__tests__/adhoc-download-api.test.ts" - "src/server/index.ts" - "drizzle/0013_flat_lady_deathstrike.sql" GSD-Task: S01/T02 --- drizzle/0013_flat_lady_deathstrike.sql | 29 + drizzle/meta/0013_snapshot.json | 1020 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/__tests__/adhoc-download-api.test.ts | 283 ++++++ src/server/index.ts | 2 + src/server/routes/adhoc-download.ts | 175 ++++ 6 files changed, 1516 insertions(+) create mode 100644 drizzle/0013_flat_lady_deathstrike.sql create mode 100644 drizzle/meta/0013_snapshot.json create mode 100644 src/__tests__/adhoc-download-api.test.ts create mode 100644 src/server/routes/adhoc-download.ts diff --git a/drizzle/0013_flat_lady_deathstrike.sql b/drizzle/0013_flat_lady_deathstrike.sql new file mode 100644 index 0000000..deaf6f2 --- /dev/null +++ b/drizzle/0013_flat_lady_deathstrike.sql @@ -0,0 +1,29 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_content_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `channel_id` integer, + `title` text NOT NULL, + `platform_content_id` text NOT NULL, + `url` text NOT NULL, + `content_type` text NOT NULL, + `duration` integer, + `file_path` text, + `file_size` integer, + `format` text, + `quality_metadata` text, + `status` text DEFAULT 'monitored' NOT NULL, + `thumbnail_url` text, + `published_at` text, + `downloaded_at` text, + `monitored` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + `updated_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_content_items`("id", "channel_id", "title", "platform_content_id", "url", "content_type", "duration", "file_path", "file_size", "format", "quality_metadata", "status", "thumbnail_url", "published_at", "downloaded_at", "monitored", "created_at", "updated_at") SELECT "id", "channel_id", "title", "platform_content_id", "url", "content_type", "duration", "file_path", "file_size", "format", "quality_metadata", "status", "thumbnail_url", "published_at", "downloaded_at", "monitored", "created_at", "updated_at" FROM `content_items`;--> statement-breakpoint +DROP TABLE `content_items`;--> statement-breakpoint +ALTER TABLE `__new_content_items` RENAME TO `content_items`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +ALTER TABLE `format_profiles` ADD `embed_thumbnail` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `format_profiles` ADD `sponsor_block_remove` text; \ No newline at end of file diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..30fb453 --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1020 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d24995c0-0acf-41f1-9fdb-ba8256dc7fad", + "prevId": "2032ed4f-0e7d-4a3c-9e00-96716084f3f6", + "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": false, + "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 + }, + "embed_chapters": { + "name": "embed_chapters", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "embed_thumbnail": { + "name": "embed_thumbnail", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sponsor_block_remove": { + "name": "sponsor_block_remove", + "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": {}, + "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 73e2150..a686b1a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1775520000000, "tag": "0012_adhoc_nullable_channel", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1775279021003, + "tag": "0013_flat_lady_deathstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/__tests__/adhoc-download-api.test.ts b/src/__tests__/adhoc-download-api.test.ts new file mode 100644 index 0000000..26e5c40 --- /dev/null +++ b/src/__tests__/adhoc-download-api.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { type FastifyInstance } from 'fastify'; +import { initDatabaseAsync, closeDatabase } from '../db/index'; +import { runMigrations } from '../db/migrate'; +import { buildServer } from '../server/index'; +import { systemConfig } from '../db/schema/index'; +import { eq } from 'drizzle-orm'; +import { type LibSQLDatabase } from 'drizzle-orm/libsql'; +import type * as schema from '../db/schema/index'; + +// Mock yt-dlp module before imports +vi.mock('../sources/yt-dlp', () => { + const YtDlpError = class YtDlpError extends Error { + stderr: string; + exitCode: number; + isRateLimit: boolean; + category: string; + constructor(message: string, stderr: string, exitCode: number) { + super(message); + this.name = 'YtDlpError'; + this.stderr = stderr; + this.exitCode = exitCode; + this.isRateLimit = stderr.toLowerCase().includes('429') || stderr.toLowerCase().includes('too many requests'); + // Minimal classify + const lower = stderr.toLowerCase(); + if (this.isRateLimit) this.category = 'rate_limit'; + else if (lower.includes('private video') || lower.includes('video unavailable')) this.category = 'private'; + else if (lower.includes('not available in your country')) this.category = 'geo_blocked'; + else if (lower.includes('connection') || lower.includes('timed out')) this.category = 'network'; + else this.category = 'unknown'; + } + }; + + return { + execYtDlp: vi.fn(), + parseSingleJson: vi.fn((stdout: string) => JSON.parse(stdout.trim())), + YtDlpError, + }; +}); + +import { execYtDlp, YtDlpError } from '../sources/yt-dlp'; + +const mockExecYtDlp = vi.mocked(execYtDlp); + +/** + * Integration tests for the ad-hoc URL preview endpoint. + */ +describe('Adhoc Download API - URL Preview', () => { + let server: FastifyInstance; + let db: LibSQLDatabase; + let apiKey: string; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-adhoc-')); + const dbPath = join(tmpDir, 'test.db'); + db = await initDatabaseAsync(dbPath); + await runMigrations(dbPath); + server = await buildServer({ db }); + + // Fetch API key + const rows = await db + .select() + .from(systemConfig) + .where(eq(systemConfig.key, 'api_key')); + apiKey = rows[0]?.value ?? 'test-key'; + + await server.ready(); + }); + + afterAll(async () => { + await server.close(); + closeDatabase(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ── Happy Path ── + + it('should return metadata for a valid YouTube URL', async () => { + const ytMetadata = { + id: 'dQw4w9WgXcQ', + title: 'Rick Astley - Never Gonna Give You Up', + thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg', + duration: 212, + extractor_key: 'Youtube', + webpage_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + channel: 'Rick Astley', + vcodec: 'avc1.640028', + is_live: false, + }; + + mockExecYtDlp.mockResolvedValueOnce({ + stdout: JSON.stringify(ytMetadata), + stderr: '', + exitCode: 0, + }); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.title).toBe('Rick Astley - Never Gonna Give You Up'); + expect(body.platform).toBe('youtube'); + expect(body.contentType).toBe('video'); + expect(body.platformContentId).toBe('dQw4w9WgXcQ'); + expect(body.duration).toBe(212); + expect(body.thumbnail).toBe('https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg'); + expect(body.channelName).toBe('Rick Astley'); + expect(body.url).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + }); + + it('should return metadata for a SoundCloud URL', async () => { + const scMetadata = { + id: '12345', + title: 'Test Track', + thumbnail: 'https://i1.sndcdn.com/artworks-test.jpg', + duration: 180, + extractor_key: 'Soundcloud', + webpage_url: 'https://soundcloud.com/artist/test-track', + uploader: 'Test Artist', + vcodec: 'none', + is_live: false, + }; + + mockExecYtDlp.mockResolvedValueOnce({ + stdout: JSON.stringify(scMetadata), + stderr: '', + exitCode: 0, + }); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'https://soundcloud.com/artist/test-track' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.platform).toBe('soundcloud'); + expect(body.contentType).toBe('audio'); + expect(body.channelName).toBe('Test Artist'); + }); + + it('should detect livestream content type', async () => { + const liveMetadata = { + id: 'abc123', + title: 'Live Stream', + thumbnail: null, + duration: null, + extractor_key: 'Youtube', + webpage_url: 'https://www.youtube.com/watch?v=abc123', + channel: 'Streamer', + is_live: true, + }; + + mockExecYtDlp.mockResolvedValueOnce({ + stdout: JSON.stringify(liveMetadata), + stderr: '', + exitCode: 0, + }); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'https://www.youtube.com/watch?v=abc123' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().contentType).toBe('livestream'); + }); + + // ── Validation Errors ── + + it('should reject missing URL', async () => { + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + + it('should reject non-HTTP URL', async () => { + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'ftp://example.com/file.mp4' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().message).toContain('valid HTTP or HTTPS URL'); + }); + + it('should reject empty string URL', async () => { + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: '' }, + }); + + expect(res.statusCode).toBe(400); + }); + + // ── Error Handling ── + + it('should return 429 on rate limit', async () => { + mockExecYtDlp.mockRejectedValueOnce( + new YtDlpError('yt-dlp error', 'HTTP Error 429: Too Many Requests', 1), + ); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'https://www.youtube.com/watch?v=test' }, + }); + + expect(res.statusCode).toBe(429); + expect(res.json().message).toContain('Rate limited'); + }); + + it('should return 422 for private/unavailable content', async () => { + mockExecYtDlp.mockRejectedValueOnce( + new YtDlpError('yt-dlp error', 'ERROR: Private video', 1), + ); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'https://www.youtube.com/watch?v=private123' }, + }); + + expect(res.statusCode).toBe(422); + expect(res.json().message).toContain('not accessible'); + }); + + it('should return 502 for network errors', async () => { + mockExecYtDlp.mockRejectedValueOnce( + new YtDlpError('yt-dlp error', 'ERROR: Unable to download - connection timed out', 1), + ); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'https://www.youtube.com/watch?v=test' }, + }); + + expect(res.statusCode).toBe(502); + expect(res.json().message).toContain('Failed to reach'); + }); + + it('should return 422 for unsupported URLs', async () => { + mockExecYtDlp.mockRejectedValueOnce( + new YtDlpError('yt-dlp error', 'ERROR: Unsupported URL', 1), + ); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/download/url/preview', + headers: { 'x-api-key': apiKey }, + payload: { url: 'https://example.com/not-a-video' }, + }); + + expect(res.statusCode).toBe(422); + expect(res.json().message).toContain('Could not resolve metadata'); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index 6ca1c68..4824bb7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,6 +22,7 @@ import { platformSettingsRoutes } from './routes/platform-settings'; import { scanRoutes } from './routes/scan'; import { collectRoutes } from './routes/collect'; import { playlistRoutes } from './routes/playlist'; +import { adhocDownloadRoutes } from './routes/adhoc-download'; import { websocketRoutes } from './routes/websocket'; import type { SchedulerService } from '../services/scheduler'; import type { DownloadService } from '../services/download'; @@ -109,6 +110,7 @@ export async function buildServer(opts: BuildServerOptions): Promise): ContentType { + const isLive = info.is_live === true || info.was_live === true; + if (isLive) return 'livestream'; + + // SoundCloud and audio-only extractors + const extractor = String(info.extractor_key || '').toLowerCase(); + if (extractor.includes('soundcloud')) return 'audio'; + + // Fallback: if there's no video codec, it's audio + const vcodec = String(info.vcodec || 'none'); + if (vcodec === 'none') return 'audio'; + + return 'video'; +} + +/** + * Map raw yt-dlp --dump-json output to a preview response. + */ +function mapToPreview(info: Record, originalUrl: string): UrlPreviewResponse { + const extractorKey = String(info.extractor_key || info.extractor || ''); + const webpageUrl = String(info.webpage_url || info.url || originalUrl); + + return { + title: String(info.title || info.fulltitle || 'Untitled'), + thumbnail: (info.thumbnail as string) || null, + duration: typeof info.duration === 'number' ? Math.round(info.duration) : null, + platform: inferPlatform(extractorKey, webpageUrl), + channelName: (info.channel as string) || (info.uploader as string) || null, + contentType: inferContentType(info), + platformContentId: String(info.id || ''), + url: webpageUrl, + }; +} + +// ── Route Plugin ── + +/** + * Ad-hoc download route plugin. + * + * Registers: + * POST /api/v1/download/url/preview — resolve metadata for a URL via yt-dlp + */ +export async function adhocDownloadRoutes(fastify: FastifyInstance): Promise { + // ── POST /api/v1/download/url/preview ── + + fastify.post<{ Body: PreviewRequestBody }>( + '/api/v1/download/url/preview', + { + schema: { + body: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string' }, + }, + }, + }, + }, + async (request, reply) => { + const { url } = request.body; + + // Validate URL format + if (!url || !URL_PATTERN.test(url)) { + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: 'A valid HTTP or HTTPS URL is required', + }); + } + + try { + // Use --dump-json to get metadata without downloading + const result = await execYtDlp( + ['--dump-json', '--no-download', '--no-playlist', url], + { timeout: 30_000 }, + ); + + const info = parseSingleJson(result.stdout) as Record; + const preview = mapToPreview(info, url); + + return reply.status(200).send(preview); + } catch (err) { + if (err instanceof YtDlpError) { + request.log.warn( + { url, category: err.category, exitCode: err.exitCode }, + 'URL preview failed: %s', + err.message, + ); + + // Map error categories to HTTP status codes + if (err.isRateLimit) { + return reply.status(429).send({ + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limited by platform. Try again later.', + }); + } + + if (err.category === 'private' || err.category === 'geo_blocked' || err.category === 'copyright') { + return reply.status(422).send({ + statusCode: 422, + error: 'Unprocessable Entity', + message: `Content is not accessible: ${err.category.replace('_', ' ')}`, + }); + } + + if (err.category === 'network') { + return reply.status(502).send({ + statusCode: 502, + error: 'Bad Gateway', + message: 'Failed to reach the content platform. Check the URL and try again.', + }); + } + + // Generic yt-dlp failure — likely an unsupported URL or invalid content + return reply.status(422).send({ + statusCode: 422, + error: 'Unprocessable Entity', + message: 'Could not resolve metadata for this URL. Verify it points to a supported video or audio page.', + }); + } + + // Unexpected errors + request.log.error({ err, url }, 'Unexpected error during URL preview'); + return reply.status(500).send({ + statusCode: 500, + error: 'Internal Server Error', + message: 'An unexpected error occurred while resolving URL metadata', + }); + } + }, + ); +}