feat: add youtube playlist download feature
This commit is contained in:
1
backend/drizzle/0008_useful_sharon_carter.sql
Normal file
1
backend/drizzle/0008_useful_sharon_carter.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `continuous_download_tasks` ADD `collection_id` text;
|
||||
833
backend/drizzle/meta/0008_snapshot.json
Normal file
833
backend/drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,833 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "e727cb82-6923-4f2f-a2dd-459a8a052879",
|
||||
"prevId": "107caef6-bda3-4836-b79d-ba3e0107a989",
|
||||
"tables": {
|
||||
"collection_videos": {
|
||||
"name": "collection_videos",
|
||||
"columns": {
|
||||
"collection_id": {
|
||||
"name": "collection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_id": {
|
||||
"name": "video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"collection_videos_collection_id_collections_id_fk": {
|
||||
"name": "collection_videos_collection_id_collections_id_fk",
|
||||
"tableFrom": "collection_videos",
|
||||
"tableTo": "collections",
|
||||
"columnsFrom": [
|
||||
"collection_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"collection_videos_video_id_videos_id_fk": {
|
||||
"name": "collection_videos_video_id_videos_id_fk",
|
||||
"tableFrom": "collection_videos",
|
||||
"tableTo": "videos",
|
||||
"columnsFrom": [
|
||||
"video_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"collection_videos_collection_id_video_id_pk": {
|
||||
"columns": [
|
||||
"collection_id",
|
||||
"video_id"
|
||||
],
|
||||
"name": "collection_videos_collection_id_video_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"collections": {
|
||||
"name": "collections",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"continuous_download_tasks": {
|
||||
"name": "continuous_download_tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subscription_id": {
|
||||
"name": "subscription_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"collection_id": {
|
||||
"name": "collection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author_url": {
|
||||
"name": "author_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform": {
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"total_videos": {
|
||||
"name": "total_videos",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"downloaded_count": {
|
||||
"name": "downloaded_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"skipped_count": {
|
||||
"name": "skipped_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"failed_count": {
|
||||
"name": "failed_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"current_video_index": {
|
||||
"name": "current_video_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"download_history": {
|
||||
"name": "download_history",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"finished_at": {
|
||||
"name": "finished_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_path": {
|
||||
"name": "video_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_size": {
|
||||
"name": "total_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_id": {
|
||||
"name": "video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"downloaded_at": {
|
||||
"name": "downloaded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "downloads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_size": {
|
||||
"name": "total_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"downloaded_size": {
|
||||
"name": "downloaded_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"speed": {
|
||||
"name": "speed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"subscriptions": {
|
||||
"name": "subscriptions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author_url": {
|
||||
"name": "author_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"interval": {
|
||||
"name": "interval",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_video_link": {
|
||||
"name": "last_video_link",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_check": {
|
||||
"name": "last_check",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"download_count": {
|
||||
"name": "download_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform": {
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'YouTube'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"video_downloads": {
|
||||
"name": "video_downloads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_video_id": {
|
||||
"name": "source_video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform": {
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_id": {
|
||||
"name": "video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'exists'"
|
||||
},
|
||||
"downloaded_at": {
|
||||
"name": "downloaded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"videos": {
|
||||
"name": "videos",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_filename": {
|
||||
"name": "video_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_filename": {
|
||||
"name": "thumbnail_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_path": {
|
||||
"name": "video_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"added_at": {
|
||||
"name": "added_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"part_number": {
|
||||
"name": "part_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_parts": {
|
||||
"name": "total_parts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"series_title": {
|
||||
"name": "series_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"view_count": {
|
||||
"name": "view_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_played_at": {
|
||||
"name": "last_played_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subtitles": {
|
||||
"name": "subtitles",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"channel_url": {
|
||||
"name": "channel_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1766548244908,
|
||||
"tag": "0007_broad_jasper_sitwell",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1766776202201,
|
||||
"tag": "0008_useful_sharon_carter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -120,3 +120,111 @@ export const deleteContinuousDownloadTask = async (
|
||||
await continuousDownloadService.deleteTask(id);
|
||||
res.status(200).json(successMessage("Task deleted"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a continuous download task for a playlist
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const createPlaylistTask = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { playlistUrl, collectionName } = req.body;
|
||||
logger.info("Creating playlist task:", {
|
||||
playlistUrl,
|
||||
collectionName,
|
||||
});
|
||||
|
||||
if (!playlistUrl || !collectionName) {
|
||||
throw new ValidationError("Playlist URL and collection name are required", "body");
|
||||
}
|
||||
|
||||
// Check if it's a valid playlist URL
|
||||
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
|
||||
if (!playlistRegex.test(playlistUrl)) {
|
||||
throw new ValidationError("URL does not contain a playlist parameter", "playlistUrl");
|
||||
}
|
||||
|
||||
// Get playlist info to determine author and platform
|
||||
const { checkPlaylist } = await import("../services/downloadService");
|
||||
const playlistInfo = await checkPlaylist(playlistUrl);
|
||||
|
||||
if (!playlistInfo.success) {
|
||||
throw new ValidationError(
|
||||
playlistInfo.error || "Failed to get playlist information",
|
||||
"playlistUrl"
|
||||
);
|
||||
}
|
||||
|
||||
// Create collection first - ensure unique name
|
||||
const storageService = await import("../services/storageService");
|
||||
const uniqueCollectionName = storageService.generateUniqueCollectionName(collectionName);
|
||||
const newCollection = {
|
||||
id: Date.now().toString(),
|
||||
name: uniqueCollectionName,
|
||||
videos: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
title: uniqueCollectionName,
|
||||
};
|
||||
storageService.saveCollection(newCollection);
|
||||
logger.info(`Created collection "${uniqueCollectionName}" with ID ${newCollection.id}`);
|
||||
|
||||
// Extract author from playlist (try to get from first video or use default)
|
||||
let author = "Playlist Author";
|
||||
let platform = "YouTube";
|
||||
|
||||
try {
|
||||
const {
|
||||
executeYtDlpJson,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} = await import("../utils/ytDlpUtils");
|
||||
const { getProviderScript } = await import("../services/downloaders/ytdlp/ytdlpHelpers");
|
||||
|
||||
const userConfig = getUserYtDlpConfig(playlistUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
// Get first video info to extract author
|
||||
const info = await executeYtDlpJson(playlistUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true,
|
||||
playlistEnd: 1,
|
||||
...(PROVIDER_SCRIPT
|
||||
? {
|
||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (info.entries && info.entries.length > 0) {
|
||||
const firstEntry = info.entries[0];
|
||||
if (firstEntry.uploader) {
|
||||
author = firstEntry.uploader;
|
||||
}
|
||||
} else if (info.uploader) {
|
||||
author = info.uploader;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Could not extract author from playlist, using default:", error);
|
||||
}
|
||||
|
||||
// Create continuous download task with collection ID
|
||||
const task = await continuousDownloadService.createPlaylistTask(
|
||||
playlistUrl,
|
||||
author,
|
||||
platform,
|
||||
newCollection.id
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Created playlist download task ${task.id} for collection ${newCollection.id}`
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
taskId: task.id,
|
||||
collectionId: newCollection.id,
|
||||
task,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -621,3 +621,41 @@ export const checkBilibiliCollection = async (
|
||||
// Return result object directly for backward compatibility (frontend expects response.data.success, response.data.type)
|
||||
res.status(200).json(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if URL is a YouTube playlist
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const checkPlaylist = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { url } = req.query;
|
||||
|
||||
if (!url) {
|
||||
throw new ValidationError("URL is required", "url");
|
||||
}
|
||||
|
||||
const playlistUrl = url as string;
|
||||
|
||||
// Check if it's a YouTube URL with playlist parameter
|
||||
if (!playlistUrl.includes("youtube.com") && !playlistUrl.includes("youtu.be")) {
|
||||
throw new ValidationError("Not a valid YouTube URL", "url");
|
||||
}
|
||||
|
||||
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
|
||||
if (!playlistRegex.test(playlistUrl)) {
|
||||
throw new ValidationError("URL does not contain a playlist parameter", "url");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await downloadService.checkPlaylist(playlistUrl);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error("Error checking playlist:", error);
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to check playlist"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,6 +142,7 @@ export const videoDownloads = sqliteTable('video_downloads', {
|
||||
export const continuousDownloadTasks = sqliteTable('continuous_download_tasks', {
|
||||
id: text('id').primaryKey(),
|
||||
subscriptionId: text('subscription_id'), // Reference to subscription (nullable if subscription deleted)
|
||||
collectionId: text('collection_id'), // Reference to collection (nullable, for playlist tasks)
|
||||
authorUrl: text('author_url').notNull(),
|
||||
author: text('author').notNull(),
|
||||
platform: text('platform').notNull(), // YouTube, Bilibili, etc.
|
||||
|
||||
@@ -71,6 +71,10 @@ router.get(
|
||||
"/check-bilibili-collection",
|
||||
asyncHandler(videoDownloadController.checkBilibiliCollection)
|
||||
);
|
||||
router.get(
|
||||
"/check-playlist",
|
||||
asyncHandler(videoDownloadController.checkPlaylist)
|
||||
);
|
||||
|
||||
// Download management
|
||||
router.post(
|
||||
@@ -137,6 +141,10 @@ router.delete(
|
||||
"/subscriptions/tasks/:id/delete",
|
||||
asyncHandler(subscriptionController.deleteContinuousDownloadTask)
|
||||
);
|
||||
router.post(
|
||||
"/subscriptions/tasks/playlist",
|
||||
asyncHandler(subscriptionController.createPlaylistTask)
|
||||
);
|
||||
|
||||
// Cloud storage routes
|
||||
router.get(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,60 @@ export async function checkBilibiliVideoParts(
|
||||
return BilibiliDownloader.checkVideoParts(videoId);
|
||||
}
|
||||
|
||||
// Helper function to check if a YouTube URL is a playlist
|
||||
export async function checkPlaylist(
|
||||
playlistUrl: string
|
||||
): Promise<{ success: boolean; title?: string; videoCount?: number; error?: string }> {
|
||||
try {
|
||||
const {
|
||||
executeYtDlpJson,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} = await import("../utils/ytDlpUtils");
|
||||
const { getProviderScript } = await import("./downloaders/ytdlp/ytdlpHelpers");
|
||||
|
||||
const userConfig = getUserYtDlpConfig(playlistUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
// Get playlist info using flat playlist (faster, doesn't download)
|
||||
const info = await executeYtDlpJson(playlistUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true,
|
||||
...(PROVIDER_SCRIPT
|
||||
? {
|
||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Check if it's a playlist
|
||||
if (info._type === "playlist" || (info.entries && info.entries.length > 0)) {
|
||||
const videoCount = info.playlist_count || info.entries?.length || 0;
|
||||
const title = info.title || info.playlist || "Playlist";
|
||||
|
||||
return {
|
||||
success: true,
|
||||
title,
|
||||
videoCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Not a valid playlist",
|
||||
};
|
||||
} catch (error) {
|
||||
const { logger } = await import("../utils/logger");
|
||||
logger.error("Error checking playlist:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to check playlist",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a Bilibili video belongs to a collection or series
|
||||
export async function checkBilibiliCollectionOrSeries(
|
||||
videoId: string
|
||||
|
||||
@@ -149,6 +149,32 @@ export function getCollectionByName(name: string): Collection | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique collection name by appending a number if the name already exists
|
||||
* @param baseName - The desired collection name
|
||||
* @returns A unique collection name
|
||||
*/
|
||||
export function generateUniqueCollectionName(baseName: string): string {
|
||||
const existingCollection = getCollectionByName(baseName);
|
||||
if (!existingCollection) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// Try appending numbers: "Name (2)", "Name (3)", etc.
|
||||
let counter = 2;
|
||||
let uniqueName = `${baseName} (${counter})`;
|
||||
|
||||
while (getCollectionByName(uniqueName)) {
|
||||
counter++;
|
||||
uniqueName = `${baseName} (${counter})`;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Collection name "${baseName}" already exists, using "${uniqueName}" instead`
|
||||
);
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
export function saveCollection(collection: Collection): Collection {
|
||||
try {
|
||||
db.transaction(() => {
|
||||
|
||||
@@ -53,6 +53,7 @@ export {
|
||||
getCollectionById,
|
||||
getCollectionByVideoId,
|
||||
getCollectionByName,
|
||||
generateUniqueCollectionName,
|
||||
saveCollection,
|
||||
atomicUpdateCollection,
|
||||
deleteCollection,
|
||||
|
||||
@@ -22,7 +22,7 @@ interface BilibiliPartsModalProps {
|
||||
onDownloadAll: (collectionName: string) => void;
|
||||
onDownloadCurrent: () => void;
|
||||
isLoading: boolean;
|
||||
type?: 'parts' | 'collection' | 'series';
|
||||
type?: 'parts' | 'collection' | 'series' | 'playlist';
|
||||
}
|
||||
|
||||
const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
@@ -49,6 +49,8 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
return t('bilibiliCollectionDetected');
|
||||
case 'series':
|
||||
return t('bilibiliSeriesDetected');
|
||||
case 'playlist':
|
||||
return t('playlistDetected');
|
||||
default:
|
||||
return t('multiPartVideoDetected');
|
||||
}
|
||||
@@ -60,6 +62,8 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
return t('collectionHasVideos', { count: videosNumber });
|
||||
case 'series':
|
||||
return t('seriesHasVideos', { count: videosNumber });
|
||||
case 'playlist':
|
||||
return t('playlistHasVideos', { count: videosNumber });
|
||||
default:
|
||||
return t('videoHasParts', { count: videosNumber });
|
||||
}
|
||||
@@ -73,6 +77,8 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
return t('downloadAllVideos', { count: videosNumber });
|
||||
case 'series':
|
||||
return t('downloadAllVideos', { count: videosNumber });
|
||||
case 'playlist':
|
||||
return t('downloadAllVideos', { count: videosNumber });
|
||||
default:
|
||||
return t('downloadAllParts', { count: videosNumber });
|
||||
}
|
||||
@@ -86,6 +92,8 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
return t('downloadThisVideoOnly');
|
||||
case 'series':
|
||||
return t('downloadThisVideoOnly');
|
||||
case 'playlist':
|
||||
return t('downloadThisVideoOnly');
|
||||
default:
|
||||
return t('downloadCurrentPartOnly');
|
||||
}
|
||||
@@ -125,7 +133,7 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
<strong>{t('title')}:</strong> {videoTitle}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, mb: 1 }}>
|
||||
{type === 'parts' ? t('wouldYouLikeToDownloadAllParts') : t('wouldYouLikeToDownloadAllVideos')}
|
||||
{type === 'parts' ? t('wouldYouLikeToDownloadAllParts') : type === 'playlist' ? t('downloadPlaylistAndCreateCollection') : t('wouldYouLikeToDownloadAllVideos')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
@@ -137,7 +145,7 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
onChange={(e) => setCollectionName(e.target.value)}
|
||||
placeholder={videoTitle}
|
||||
disabled={isLoading}
|
||||
helperText={type === 'parts' ? t('allPartsAddedToCollection') : t('allVideosAddedToCollection')}
|
||||
helperText={type === 'parts' ? t('allPartsAddedToCollection') : type === 'playlist' ? t('allVideosAddedToCollection') : t('allVideosAddedToCollection')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ActionButtonsProps {
|
||||
onDownloadsClose: () => void;
|
||||
onManageClick: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onManageClose: () => void;
|
||||
hasActiveSubscriptions?: boolean;
|
||||
}
|
||||
|
||||
const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
@@ -26,7 +27,8 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
onDownloadsClick,
|
||||
onDownloadsClose,
|
||||
onManageClick,
|
||||
onManageClose
|
||||
onManageClose,
|
||||
hasActiveSubscriptions = false
|
||||
}) => {
|
||||
const { mode: currentThemeMode, toggleTheme } = useThemeContext();
|
||||
const { t } = useLanguage();
|
||||
@@ -49,6 +51,7 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
onClose={onDownloadsClose}
|
||||
activeDownloads={activeDownloads}
|
||||
queuedDownloads={queuedDownloads}
|
||||
hasActiveSubscriptions={hasActiveSubscriptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -70,6 +73,7 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
<ManageMenu
|
||||
anchorEl={manageAnchorEl}
|
||||
onClose={onManageClose}
|
||||
hasActiveSubscriptions={hasActiveSubscriptions}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Download } from '@mui/icons-material';
|
||||
import { Download, Subscriptions } from '@mui/icons-material';
|
||||
import {
|
||||
alpha,
|
||||
Badge,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Fade,
|
||||
Menu,
|
||||
MenuItem,
|
||||
@@ -19,13 +21,15 @@ interface DownloadsMenuProps {
|
||||
onClose: () => void;
|
||||
activeDownloads: DownloadInfo[];
|
||||
queuedDownloads: DownloadInfo[];
|
||||
hasActiveSubscriptions?: boolean;
|
||||
}
|
||||
|
||||
const DownloadsMenu: React.FC<DownloadsMenuProps> = ({
|
||||
anchorEl,
|
||||
onClose,
|
||||
activeDownloads,
|
||||
queuedDownloads
|
||||
queuedDownloads,
|
||||
hasActiveSubscriptions = false
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
@@ -78,6 +82,18 @@ const DownloadsMenu: React.FC<DownloadsMenuProps> = ({
|
||||
<MenuItem onClick={() => { onClose(); navigate('/downloads'); }}>
|
||||
<Download sx={{ mr: 2 }} /> {t('manageDownloads') || 'Manage Downloads'}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { onClose(); navigate('/subscriptions'); }}>
|
||||
<Badge
|
||||
variant="dot"
|
||||
color="primary"
|
||||
invisible={!hasActiveSubscriptions}
|
||||
sx={{ mr: 2, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Subscriptions />
|
||||
</Badge>
|
||||
<Box component="span">{t('subscriptions')}</Box>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
|
||||
{activeDownloads.map((download) => (
|
||||
<MenuItem key={download.id} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1, py: 1.5 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Help, Settings, Subscriptions, VideoLibrary } from '@mui/icons-material';
|
||||
import { Help, Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import {
|
||||
alpha,
|
||||
Fade,
|
||||
@@ -62,9 +62,6 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
|
||||
<MenuItem onClick={() => { onClose(); navigate('/manage'); }}>
|
||||
<VideoLibrary sx={{ mr: 2 }} /> {t('manageContent')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { onClose(); navigate('/subscriptions'); }}>
|
||||
<Subscriptions sx={{ mr: 2 }} /> {t('subscriptions')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { onClose(); navigate('/settings'); }}>
|
||||
<Settings sx={{ mr: 2 }} /> {t('settings')}
|
||||
</MenuItem>
|
||||
|
||||
@@ -42,6 +42,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const [websiteName, setWebsiteName] = useState('MyTube');
|
||||
const [isScrolled, setIsScrolled] = useState<boolean>(false);
|
||||
const [infiniteScroll, setInfiniteScroll] = useState<boolean>(false);
|
||||
const [hasActiveSubscriptions, setHasActiveSubscriptions] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
@@ -58,6 +59,45 @@ const Header: React.FC<HeaderProps> = ({
|
||||
console.log('Header props:', { activeDownloads, queuedDownloads });
|
||||
}, [activeDownloads, queuedDownloads]);
|
||||
|
||||
// Check for active subscriptions and tasks
|
||||
useEffect(() => {
|
||||
if (visitorMode) {
|
||||
setHasActiveSubscriptions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkActiveSubscriptions = async () => {
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const axios = await import('axios');
|
||||
|
||||
// Fetch subscriptions and tasks
|
||||
const [subscriptionsRes, tasksRes] = await Promise.all([
|
||||
axios.default.get(`${API_URL}/subscriptions`).catch(() => ({ data: [] })),
|
||||
axios.default.get(`${API_URL}/subscriptions/tasks`).catch(() => ({ data: [] }))
|
||||
]);
|
||||
|
||||
const subscriptions = subscriptionsRes.data || [];
|
||||
const tasks = tasksRes.data || [];
|
||||
|
||||
// Check if there are active subscriptions or active tasks
|
||||
const hasActiveTasks = tasks.some((task: any) =>
|
||||
task.status === 'active' || task.status === 'paused'
|
||||
);
|
||||
|
||||
setHasActiveSubscriptions(subscriptions.length > 0 || hasActiveTasks);
|
||||
} catch (error) {
|
||||
console.error('Error checking subscriptions:', error);
|
||||
setHasActiveSubscriptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkActiveSubscriptions();
|
||||
// Poll every 10 seconds to update indicator
|
||||
const interval = setInterval(checkActiveSubscriptions, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [visitorMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch settings to get website name and infinite scroll setting
|
||||
const fetchSettings = async () => {
|
||||
@@ -82,7 +122,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
// Scroll detection - for mobile always, for desktop when infinite scroll is enabled on home page
|
||||
useEffect(() => {
|
||||
const shouldDetectScroll = isMobile || (infiniteScroll && isHomePage);
|
||||
|
||||
|
||||
if (!shouldDetectScroll) {
|
||||
setIsScrolled(false);
|
||||
return;
|
||||
@@ -262,6 +302,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
onDownloadsClose={handleDownloadsClose}
|
||||
onManageClick={handleManageClick}
|
||||
onManageClose={handleManageClose}
|
||||
hasActiveSubscriptions={hasActiveSubscriptions}
|
||||
/>
|
||||
<IconButton onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||
<MenuIcon />
|
||||
@@ -297,6 +338,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
onDownloadsClose={handleDownloadsClose}
|
||||
onManageClick={handleManageClick}
|
||||
onManageClose={handleManageClose}
|
||||
hasActiveSubscriptions={hasActiveSubscriptions}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
@@ -345,14 +387,14 @@ const Header: React.FC<HeaderProps> = ({
|
||||
/>
|
||||
|
||||
{/* Scroll to top button - mobile always, desktop when infinite scroll is enabled on home page */}
|
||||
<Slide
|
||||
direction="up"
|
||||
<Slide
|
||||
direction="up"
|
||||
in={
|
||||
isScrolled &&
|
||||
!isSettingsPage &&
|
||||
isScrolled &&
|
||||
!isSettingsPage &&
|
||||
(isMobile || (infiniteScroll && isHomePage))
|
||||
}
|
||||
mountOnEnter
|
||||
}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Fab
|
||||
@@ -367,9 +409,9 @@ const Header: React.FC<HeaderProps> = ({
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
zIndex: (theme) => theme.zIndex.speedDial,
|
||||
display: {
|
||||
xs: 'flex',
|
||||
md: (infiniteScroll && isHomePage) ? 'flex' : 'none'
|
||||
display: {
|
||||
xs: 'flex',
|
||||
md: (infiniteScroll && isHomePage) ? 'flex' : 'none'
|
||||
},
|
||||
opacity: 0.8,
|
||||
'&:hover': {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import BilibiliPartsModal from '../BilibiliPartsModal';
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({
|
||||
t: (key: string, options?: any) => {
|
||||
if (options && options.count) {
|
||||
return `${key}_${options.count}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BilibiliPartsModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
videosNumber: 10,
|
||||
videoTitle: 'Test Video',
|
||||
onDownloadAll: vi.fn(),
|
||||
onDownloadCurrent: vi.fn(),
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderModal = (props = {}) => {
|
||||
const theme = createTheme();
|
||||
render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<BilibiliPartsModal {...defaultProps} {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders playlist specific text when type is playlist', () => {
|
||||
renderModal({ type: 'playlist' });
|
||||
|
||||
// Header text: should use 'playlistDetected' key
|
||||
expect(screen.getByText('playlistDetected')).toBeInTheDocument();
|
||||
|
||||
// Description text: should use 'playlistHasVideos' key with count
|
||||
expect(screen.getByText('playlistHasVideos_10')).toBeInTheDocument();
|
||||
|
||||
// Download all text description: should use 'downloadPlaylistAndCreateCollection' key
|
||||
expect(screen.getByText('downloadPlaylistAndCreateCollection')).toBeInTheDocument();
|
||||
|
||||
// Helper text for collection name input: should use 'allVideosAddedToCollection' key
|
||||
// TextField helper text sometimes can be tricky to find by exact text if it's broken up, but getByText usually works.
|
||||
// There might be multiple instances if I'm not careful, but here it should be unique or just exist.
|
||||
expect(screen.getByText('allVideosAddedToCollection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default text when type is parts', () => {
|
||||
renderModal({ type: 'parts' });
|
||||
|
||||
expect(screen.getByText('multiPartVideoDetected')).toBeInTheDocument();
|
||||
expect(screen.getByText('videoHasParts_10')).toBeInTheDocument();
|
||||
expect(screen.getByText('wouldYouLikeToDownloadAllParts')).toBeInTheDocument();
|
||||
expect(screen.getByText('allPartsAddedToCollection')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Header from '../Header';
|
||||
@@ -35,15 +35,23 @@ vi.mock('../../contexts/CollectionContext', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({
|
||||
visitorMode: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock child components to avoid context dependency issues
|
||||
vi.mock('../AuthorsList', () => ({ default: () => <div data-testid="authors-list" /> }));
|
||||
vi.mock('../Collections', () => ({ default: () => <div data-testid="collections-list" /> }));
|
||||
vi.mock('../TagsList', () => ({ default: () => <div data-testid="tags-list" /> }));
|
||||
|
||||
// Mock axios for settings fetch in useEffect
|
||||
// Mock axios for settings fetch
|
||||
const mockAxiosGet = vi.fn();
|
||||
vi.mock('axios', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: { websiteName: 'TestTube' } }),
|
||||
get: (...args: any[]) => mockAxiosGet(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -87,11 +95,24 @@ describe('Header', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock implementation
|
||||
mockAxiosGet.mockImplementation((url) => {
|
||||
if (url && url.includes('/settings')) {
|
||||
return Promise.resolve({ data: { websiteName: 'TestTube', infiniteScroll: false } });
|
||||
}
|
||||
return Promise.resolve({ data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with logo and title', async () => {
|
||||
renderHeader();
|
||||
// The title is fetched async, might need wait
|
||||
|
||||
// Wait for the settings fetch to complete and update the title
|
||||
await waitFor(() => {
|
||||
expect(mockAxiosGet).toHaveBeenCalledWith(expect.stringContaining('/settings'));
|
||||
});
|
||||
|
||||
// Use findByText to allow for async updates if any
|
||||
expect(await screen.findByText('TestTube')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('MyTube Logo')).toBeInTheDocument();
|
||||
});
|
||||
@@ -103,15 +124,9 @@ describe('Header', () => {
|
||||
const input = screen.getByPlaceholderText('enterUrlOrSearchTerm');
|
||||
fireEvent.change(input, { target: { value: 'https://youtube.com/watch?v=123' } });
|
||||
|
||||
const submitButton = screen.getAllByRole('button', { name: '' }).find(btn => btn.querySelector('svg[data-testid="SearchIcon"]'));
|
||||
// Or find by type="submit"
|
||||
// MUI TextField slotProps endAdornment button type="submit"
|
||||
|
||||
// Let's use fireEvent.submit on the form
|
||||
// The form is a Box component="form"
|
||||
// We can find the input and submit passing key enter or form submit
|
||||
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
const form = input.closest('form');
|
||||
expect(form).toBeInTheDocument();
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('https://youtube.com/watch?v=123');
|
||||
});
|
||||
@@ -120,6 +135,7 @@ describe('Header', () => {
|
||||
renderHeader();
|
||||
|
||||
const themeButton = screen.getAllByRole('button').find(btn => btn.querySelector('svg[data-testid="Brightness4Icon"]'));
|
||||
expect(themeButton).toBeDefined();
|
||||
fireEvent.click(themeButton!);
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalled();
|
||||
@@ -129,7 +145,8 @@ describe('Header', () => {
|
||||
renderHeader();
|
||||
|
||||
const input = screen.getByPlaceholderText('enterUrlOrSearchTerm');
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
const form = input.closest('form');
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(screen.getByText('pleaseEnterUrlOrSearchTerm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ interface BilibiliPartsInfo {
|
||||
videosNumber: number;
|
||||
title: string;
|
||||
url: string;
|
||||
type: 'parts' | 'collection' | 'series';
|
||||
type: 'parts' | 'collection' | 'series' | 'playlist';
|
||||
collectionInfo: any;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,36 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
|
||||
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false, skipPartsCheck = false, forceDownload = false): Promise<any> => {
|
||||
try {
|
||||
// Check for YouTube playlist URL (must check before channel check)
|
||||
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
|
||||
if (playlistRegex.test(videoUrl) && !skipCollectionCheck) {
|
||||
setIsCheckingParts(true);
|
||||
try {
|
||||
const playlistResponse = await axios.get(`${API_URL}/check-playlist`, {
|
||||
params: { url: videoUrl }
|
||||
});
|
||||
|
||||
if (playlistResponse.data.success) {
|
||||
const { title, videoCount } = playlistResponse.data;
|
||||
setBilibiliPartsInfo({
|
||||
videosNumber: videoCount,
|
||||
title: title,
|
||||
url: videoUrl,
|
||||
type: 'playlist',
|
||||
collectionInfo: null
|
||||
});
|
||||
setShowBilibiliPartsModal(true);
|
||||
setIsCheckingParts(false);
|
||||
return { success: true };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking playlist:', err);
|
||||
// Continue with normal download if check fails
|
||||
} finally {
|
||||
setIsCheckingParts(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for YouTube channel URL
|
||||
// Regex for: @username, channel/ID, user/username, c/customURL
|
||||
const channelRegex = /youtube\.com\/(?:@|channel\/|user\/|c\/)/;
|
||||
@@ -233,9 +263,9 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
}
|
||||
|
||||
// Normal download flow
|
||||
const response = await axios.post(`${API_URL}/download`, {
|
||||
const response = await axios.post(`${API_URL}/download`, {
|
||||
youtubeUrl: videoUrl,
|
||||
forceDownload: forceDownload
|
||||
forceDownload: forceDownload
|
||||
});
|
||||
|
||||
// Check if video was skipped (already exists or previously deleted)
|
||||
@@ -272,7 +302,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download video. Please try again.'
|
||||
error: err.response?.data?.error || t('failedToDownloadVideo')
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -283,6 +313,26 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
setShowBilibiliPartsModal(false);
|
||||
|
||||
const isCollection = bilibiliPartsInfo.type === 'collection' || bilibiliPartsInfo.type === 'series';
|
||||
const isPlaylist = bilibiliPartsInfo.type === 'playlist';
|
||||
|
||||
// Handle playlist differently - create continuous download task
|
||||
if (isPlaylist) {
|
||||
const response = await axios.post(`${API_URL}/subscriptions/tasks/playlist`, {
|
||||
playlistUrl: bilibiliPartsInfo.url,
|
||||
collectionName: collectionName || bilibiliPartsInfo.title
|
||||
});
|
||||
|
||||
// Trigger immediate status check
|
||||
checkBackendDownloadStatus();
|
||||
|
||||
// If a collection was created, refresh collections
|
||||
if (response.data.collectionId) {
|
||||
await fetchCollections();
|
||||
}
|
||||
|
||||
showSnackbar(t('playlistDownloadStarted'));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const response = await axios.post(`${API_URL}/download`, {
|
||||
youtubeUrl: bilibiliPartsInfo.url,
|
||||
@@ -307,7 +357,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || 'Failed to download. Please try again.'
|
||||
error: err.response?.data?.error || t('failedToDownload')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
TableRow,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
@@ -52,48 +53,39 @@ interface ContinuousDownloadTask {
|
||||
updatedAt?: number;
|
||||
completedAt?: number;
|
||||
error?: string;
|
||||
playlistName?: string;
|
||||
}
|
||||
|
||||
const SubscriptionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||
const [tasks, setTasks] = useState<ContinuousDownloadTask[]>([]);
|
||||
const [isUnsubscribeModalOpen, setIsUnsubscribeModalOpen] = useState(false);
|
||||
const [selectedSubscription, setSelectedSubscription] = useState<{ id: string; author: string } | null>(null);
|
||||
const [isCancelTaskModalOpen, setIsCancelTaskModalOpen] = useState(false);
|
||||
const [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<ContinuousDownloadTask | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscriptions();
|
||||
fetchTasks();
|
||||
// Poll for task updates every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
fetchTasks();
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchSubscriptions = async () => {
|
||||
try {
|
||||
// Use React Query for better caching and memory management
|
||||
const { data: subscriptions = [], refetch: refetchSubscriptions } = useQuery({
|
||||
queryKey: ['subscriptions'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/subscriptions`);
|
||||
setSubscriptions(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriptions:', error);
|
||||
showSnackbar(t('error'));
|
||||
}
|
||||
};
|
||||
return response.data as Subscription[];
|
||||
},
|
||||
refetchInterval: 30000, // Refetch every 30 seconds (less frequent)
|
||||
staleTime: 10000, // Consider data fresh for 10 seconds
|
||||
});
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const { data: tasks = [], refetch: refetchTasks } = useQuery({
|
||||
queryKey: ['subscriptionTasks'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/subscriptions/tasks`);
|
||||
setTasks(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
}
|
||||
};
|
||||
return response.data as ContinuousDownloadTask[];
|
||||
},
|
||||
refetchInterval: 10000, // Poll every 10 seconds
|
||||
staleTime: 5000, // Consider data fresh for 5 seconds
|
||||
});
|
||||
|
||||
const handleUnsubscribeClick = (id: string, author: string) => {
|
||||
setSelectedSubscription({ id, author });
|
||||
@@ -106,7 +98,7 @@ const SubscriptionsPage: React.FC = () => {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/subscriptions/${selectedSubscription.id}`);
|
||||
showSnackbar(t('unsubscribedSuccessfully'));
|
||||
fetchSubscriptions();
|
||||
refetchSubscriptions();
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing:', error);
|
||||
showSnackbar(t('error'));
|
||||
@@ -132,7 +124,7 @@ const SubscriptionsPage: React.FC = () => {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/subscriptions/tasks/${selectedTask.id}`);
|
||||
showSnackbar(t('taskCancelled'));
|
||||
fetchTasks();
|
||||
refetchTasks();
|
||||
} catch (error) {
|
||||
console.error('Error cancelling task:', error);
|
||||
showSnackbar(t('error'));
|
||||
@@ -153,7 +145,7 @@ const SubscriptionsPage: React.FC = () => {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/subscriptions/tasks/${selectedTask.id}/delete`);
|
||||
showSnackbar(t('taskDeleted'));
|
||||
fetchTasks();
|
||||
refetchTasks();
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
showSnackbar(t('error'));
|
||||
@@ -239,7 +231,7 @@ const SubscriptionsPage: React.FC = () => {
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('author')}</TableCell>
|
||||
<TableCell>{t('authorOrPlaylist')}</TableCell>
|
||||
<TableCell>{t('platform')}</TableCell>
|
||||
<TableCell>{t('status')}</TableCell>
|
||||
<TableCell>{t('progress')}</TableCell>
|
||||
@@ -250,9 +242,9 @@ const SubscriptionsPage: React.FC = () => {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
{tasks.slice().reverse().map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>{task.author}</TableCell>
|
||||
<TableCell>{task.playlistName || task.author}</TableCell>
|
||||
<TableCell>{task.platform}</TableCell>
|
||||
<TableCell>
|
||||
<Typography
|
||||
@@ -261,8 +253,8 @@ const SubscriptionsPage: React.FC = () => {
|
||||
task.status === 'completed'
|
||||
? 'success.main'
|
||||
: task.status === 'cancelled'
|
||||
? 'error.main'
|
||||
: 'info.main'
|
||||
? 'error.main'
|
||||
: 'info.main'
|
||||
}
|
||||
>
|
||||
{t(`taskStatus${task.status.charAt(0).toUpperCase() + task.status.slice(1)}` as TranslationKey)}
|
||||
|
||||
@@ -369,6 +369,10 @@ export const ar = {
|
||||
bilibiliCollectionDetected: "تم اكتشاف مجموعة Bilibili",
|
||||
bilibiliSeriesDetected: "تم اكتشاف سلسلة Bilibili",
|
||||
multiPartVideoDetected: "تم اكتشاف فيديو متعدد الأجزاء",
|
||||
authorOrPlaylist: "المؤلف / قائمة التشغيل",
|
||||
playlistDetected: "تم اكتشاف قائمة تشغيل",
|
||||
playlistHasVideos: "تحتوي قائمة التشغيل هذه على {count} فيديوهات.",
|
||||
downloadPlaylistAndCreateCollection: "هل تريد تحميل فيديوهات قائمة التشغيل وإنشاء مجموعة لها؟",
|
||||
collectionHasVideos: "تحتوي هذه المجموعة من Bilibili على {count} فيديوهات.",
|
||||
seriesHasVideos: "تحتوي هذه السلسلة من Bilibili على {count} فيديوهات.",
|
||||
videoHasParts: "يحتوي هذا الفيديو من Bilibili على {count} أجزاء.",
|
||||
@@ -586,4 +590,7 @@ export const ar = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "فشل تنزيل الفيديو. يرجى المحاولة مرة أخرى.",
|
||||
failedToDownload: "فشل التنزيل. يرجى المحاولة مرة أخرى.",
|
||||
playlistDownloadStarted: "بدأ تنزيل قائمة التشغيل",
|
||||
};
|
||||
|
||||
@@ -333,6 +333,10 @@ export const de = {
|
||||
bilibiliCollectionDetected: "Bilibili-Sammlung Erkannt",
|
||||
bilibiliSeriesDetected: "Bilibili-Serie Erkannt",
|
||||
multiPartVideoDetected: "Mehrteiliges Video Erkannt",
|
||||
authorOrPlaylist: "Autor / Wiedergabeliste",
|
||||
playlistDetected: "Wiedergabeliste erkannt",
|
||||
playlistHasVideos: "Diese Wiedergabeliste hat {count} Videos.",
|
||||
downloadPlaylistAndCreateCollection: "Videos der Wiedergabeliste herunterladen und eine Sammlung dafür erstellen?",
|
||||
collectionHasVideos: "Diese Bilibili-Sammlung hat {count} Videos.",
|
||||
seriesHasVideos: "Diese Bilibili-Serie hat {count} Videos.",
|
||||
videoHasParts: "Dieses Bilibili-Video hat {count} Teile.",
|
||||
@@ -567,4 +571,7 @@ export const de = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
|
||||
failedToDownload: "Fehler beim Herunterladen. Bitte versuchen Sie es erneut.",
|
||||
playlistDownloadStarted: "Playlist-Download gestartet",
|
||||
};
|
||||
|
||||
@@ -358,6 +358,11 @@ export const en = {
|
||||
bilibiliCollectionDetected: "Bilibili Collection Detected",
|
||||
bilibiliSeriesDetected: "Bilibili Series Detected",
|
||||
multiPartVideoDetected: "Multi-part Video Detected",
|
||||
authorOrPlaylist: "Author / Playlist",
|
||||
playlistDetected: "Playlist Detected",
|
||||
playlistHasVideos: "This playlist has {count} videos.",
|
||||
downloadPlaylistAndCreateCollection: "Download playlist videos and create a Collection for it?",
|
||||
playlistDownloadStarted: "Playlist download started",
|
||||
collectionHasVideos: "This Bilibili collection has {count} videos.",
|
||||
seriesHasVideos: "This Bilibili series has {count} videos.",
|
||||
videoHasParts: "This Bilibili video has {count} parts.",
|
||||
@@ -603,4 +608,6 @@ export const en = {
|
||||
restoreFromLastBackupFailed: "Failed to restore from backup",
|
||||
lastBackupDate: "Last backup date",
|
||||
noBackupAvailable: "No backup available",
|
||||
failedToDownloadVideo: "Failed to download video. Please try again.",
|
||||
failedToDownload: "Failed to download. Please try again.",
|
||||
};
|
||||
|
||||
@@ -357,6 +357,10 @@ export const es = {
|
||||
bilibiliCollectionDetected: "Colección de Bilibili Detectada",
|
||||
bilibiliSeriesDetected: "Serie de Bilibili Detectada",
|
||||
multiPartVideoDetected: "Video Multiparte Detectado",
|
||||
authorOrPlaylist: "Autor / Lista de reproducción",
|
||||
playlistDetected: "Lista de reproducción detectada",
|
||||
playlistHasVideos: "Esta lista de reproducción tiene {count} videos.",
|
||||
downloadPlaylistAndCreateCollection: "¿Descargar videos de la lista de reproducción y crear una colección para ella?",
|
||||
collectionHasVideos: "Esta colección de Bilibili tiene {count} videos.",
|
||||
seriesHasVideos: "Esta serie de Bilibili tiene {count} videos.",
|
||||
videoHasParts: "Este video de Bilibili tiene {count} partes.",
|
||||
@@ -573,4 +577,7 @@ export const es = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "Error al descargar el video. Inténtalo de nuevo.",
|
||||
failedToDownload: "Error al descargar. Inténtalo de nuevo.",
|
||||
playlistDownloadStarted: "Descarga de lista de reproducción iniciada",
|
||||
};
|
||||
|
||||
@@ -384,6 +384,10 @@ export const fr = {
|
||||
bilibiliCollectionDetected: "Collection Bilibili détectée",
|
||||
bilibiliSeriesDetected: "Série Bilibili détectée",
|
||||
multiPartVideoDetected: "Vidéo en plusieurs parties détectée",
|
||||
authorOrPlaylist: "Auteur / Playlist",
|
||||
playlistDetected: "Playlist détectée",
|
||||
playlistHasVideos: "Cette playlist contient {count} vidéos.",
|
||||
downloadPlaylistAndCreateCollection: "Télécharger les vidéos de la playlist et créer une collection pour celle-ci ?",
|
||||
collectionHasVideos: "Cette collection Bilibili contient {count} vidéos.",
|
||||
seriesHasVideos: "Cette série Bilibili contient {count} vidéos.",
|
||||
videoHasParts: "Cette vidéo Bilibili contient {count} parties.",
|
||||
@@ -620,4 +624,7 @@ export const fr = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "Échec du téléchargement de la vidéo. Veuillez réessayer.",
|
||||
failedToDownload: "Échec du téléchargement. Veuillez réessayer.",
|
||||
playlistDownloadStarted: "Téléchargement de la playlist commencé",
|
||||
};
|
||||
|
||||
@@ -362,6 +362,10 @@ export const ja = {
|
||||
bilibiliCollectionDetected: "Bilibiliコレクションを検出しました",
|
||||
bilibiliSeriesDetected: "Bilibiliシリーズを検出しました",
|
||||
multiPartVideoDetected: "マルチパート動画を検出しました",
|
||||
authorOrPlaylist: "作者 / 再生リスト",
|
||||
playlistDetected: "プレイリストが検出されました",
|
||||
playlistHasVideos: "このプレイリストには{count}本の動画があります。",
|
||||
downloadPlaylistAndCreateCollection: "プレイリストの動画をダウンロードして、コレクションを作成しますか?",
|
||||
collectionHasVideos:
|
||||
"このBilibiliコレクションには{count}個の動画があります。",
|
||||
seriesHasVideos: "このBilibiliシリーズには{count}個の動画があります。",
|
||||
@@ -595,4 +599,7 @@ export const ja = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "動画のダウンロードに失敗しました。もう一度お試しください。",
|
||||
failedToDownload: "ダウンロードに失敗しました。もう一度お試しください。",
|
||||
playlistDownloadStarted: "プレイリストのダウンロードが開始されました",
|
||||
};
|
||||
|
||||
@@ -359,6 +359,10 @@ export const ko = {
|
||||
bilibiliCollectionDetected: "Bilibili 컬렉션 감지됨",
|
||||
bilibiliSeriesDetected: "Bilibili 시리즈 감지됨",
|
||||
multiPartVideoDetected: "멀티 파트 동영상 감지됨",
|
||||
authorOrPlaylist: "작성자 / 재생 목록",
|
||||
playlistDetected: "재생 목록 감지됨",
|
||||
playlistHasVideos: "이 재생 목록에는 {count}개의 동영상이 있습니다.",
|
||||
downloadPlaylistAndCreateCollection: "재생 목록 동영상을 다운로드하고 컬렉션을 만드시겠습니까?",
|
||||
collectionHasVideos: "이 Bilibili 컬렉션에는 {count}개의 동영상이 있습니다.",
|
||||
seriesHasVideos: "이 Bilibili 시리즈에는 {count}개의 동영상이 있습니다.",
|
||||
videoHasParts: "이 Bilibili 동영상에는 {count}개의 파트가 있습니다.",
|
||||
@@ -586,4 +590,7 @@ export const ko = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "동영상 다운로드에 실패했습니다. 다시 시도해 주세요.",
|
||||
failedToDownload: "다운로드에 실패했습니다. 다시 시도해 주세요.",
|
||||
playlistDownloadStarted: "재생 목록 다운로드가 시작되었습니다",
|
||||
};
|
||||
|
||||
@@ -367,6 +367,10 @@ export const pt = {
|
||||
bilibiliCollectionDetected: "Coleção Bilibili Detectada",
|
||||
bilibiliSeriesDetected: "Série Bilibili Detectada",
|
||||
multiPartVideoDetected: "Vídeo em Múltiplas Partes Detectado",
|
||||
authorOrPlaylist: "Autor / Lista de reprodução",
|
||||
playlistDetected: "Lista de reprodução detectada",
|
||||
playlistHasVideos: "Esta lista de reprodução tem {count} vídeos.",
|
||||
downloadPlaylistAndCreateCollection: "Baixar vídeos da lista de reprodução e criar uma coleção para ela?",
|
||||
collectionHasVideos: "Esta coleção Bilibili tem {count} vídeos.",
|
||||
previouslyDeletedVideo: "Vídeo Anteriormente Excluído",
|
||||
previouslyDeleted: "Anteriormente excluído",
|
||||
@@ -598,4 +602,7 @@ export const pt = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "Falha ao baixar o vídeo. Por favor, tente novamente.",
|
||||
failedToDownload: "Falha ao baixar. Por favor, tente novamente.",
|
||||
playlistDownloadStarted: "Download da playlist iniciado",
|
||||
};
|
||||
|
||||
@@ -377,6 +377,10 @@ export const ru = {
|
||||
bilibiliCollectionDetected: "Обнаружена коллекция Bilibili",
|
||||
bilibiliSeriesDetected: "Обнаружена серия Bilibili",
|
||||
multiPartVideoDetected: "Обнаружено многочастное видео",
|
||||
authorOrPlaylist: "Автор / Плейлист",
|
||||
playlistDetected: "Обнаружен плейлист",
|
||||
playlistHasVideos: "В этом плейлисте {count} видео.",
|
||||
downloadPlaylistAndCreateCollection: "Скачать видео из плейлиста и создать для него коллекцию?",
|
||||
collectionHasVideos: "В этой коллекции Bilibili {count} видео.",
|
||||
seriesHasVideos: "В этой серии Bilibili {count} видео.",
|
||||
videoHasParts: "В этом видео Bilibili {count} частей.",
|
||||
@@ -592,4 +596,7 @@ export const ru = {
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
failedToDownloadVideo: "Не удалось скачать видео. Пожалуйста, попробуйте снова.",
|
||||
failedToDownload: "Не удалось скачать. Пожалуйста, попробуйте снова.",
|
||||
playlistDownloadStarted: "Скачивание плейлиста началось",
|
||||
};
|
||||
|
||||
@@ -359,6 +359,10 @@ export const zh = {
|
||||
bilibiliCollectionDetected: "检测到 Bilibili 合集",
|
||||
bilibiliSeriesDetected: "检测到 Bilibili 系列",
|
||||
multiPartVideoDetected: "检测到多P视频",
|
||||
authorOrPlaylist: "作者 / 播放列表",
|
||||
playlistDetected: "检测到播放列表",
|
||||
playlistHasVideos: "此播放列表包含 {count} 个视频。",
|
||||
downloadPlaylistAndCreateCollection: "下载播放列表视频并为其创建合集?",
|
||||
collectionHasVideos: "此合集包含 {count} 个视频。",
|
||||
seriesHasVideos: "此系列包含 {count} 个视频。",
|
||||
videoHasParts: "此视频包含 {count} 个分P。",
|
||||
@@ -584,4 +588,7 @@ export const zh = {
|
||||
restoreFromLastBackupFailed: "从备份恢复失败",
|
||||
lastBackupDate: "最后备份日期",
|
||||
noBackupAvailable: "没有可用的备份",
|
||||
failedToDownloadVideo: "下载视频失败。请重试。",
|
||||
failedToDownload: "下载失败。请重试。",
|
||||
playlistDownloadStarted: "播放列表下载已开始",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user