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(
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
downloadSingleBilibiliPart,
|
||||
downloadYouTubeVideo,
|
||||
} from "./downloadService";
|
||||
import { BilibiliDownloader } from "./downloaders/BilibiliDownloader";
|
||||
import { YtDlpDownloader } from "./downloaders/YtDlpDownloader";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
export interface ContinuousDownloadTask {
|
||||
id: string;
|
||||
subscriptionId?: string;
|
||||
collectionId?: string; // For playlist tasks
|
||||
playlistName?: string; // Name of the collection (playlist)
|
||||
authorUrl: string;
|
||||
author: string;
|
||||
platform: string;
|
||||
@@ -32,6 +32,11 @@ export interface ContinuousDownloadTask {
|
||||
export class ContinuousDownloadService {
|
||||
private static instance: ContinuousDownloadService;
|
||||
private processingTasks: Set<string> = new Set();
|
||||
// Cache video URLs for tasks to avoid re-fetching large playlists
|
||||
// Use WeakMap to allow garbage collection when tasks are deleted
|
||||
private videoUrlCache: Map<string, string[]> = new Map();
|
||||
// Track which tasks are using incremental fetching (for large playlists)
|
||||
private incrementalFetchTasks: Set<string> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -66,7 +71,9 @@ export class ContinuousDownloadService {
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
await db.insert(continuousDownloadTasks).values(task);
|
||||
// Remove playlistName from the insert object as it's not in the table
|
||||
const { playlistName, ...taskToInsert } = task;
|
||||
await db.insert(continuousDownloadTasks).values(taskToInsert);
|
||||
logger.info(
|
||||
`Created continuous download task ${task.id} for ${author} (${platform})`
|
||||
);
|
||||
@@ -79,15 +86,67 @@ export class ContinuousDownloadService {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new continuous download task for a playlist
|
||||
*/
|
||||
async createPlaylistTask(
|
||||
playlistUrl: string,
|
||||
author: string,
|
||||
platform: string,
|
||||
collectionId: string
|
||||
): Promise<ContinuousDownloadTask> {
|
||||
const task: ContinuousDownloadTask = {
|
||||
id: uuidv4(),
|
||||
collectionId,
|
||||
authorUrl: playlistUrl,
|
||||
author,
|
||||
platform,
|
||||
status: "active",
|
||||
totalVideos: 0,
|
||||
downloadedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
currentVideoIndex: 0,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
// Remove playlistName from the insert object as it's not in the table
|
||||
const { playlistName, ...taskToInsert } = task;
|
||||
await db.insert(continuousDownloadTasks).values(taskToInsert);
|
||||
logger.info(
|
||||
`Created playlist download task ${task.id} for collection ${collectionId} (${platform})`
|
||||
);
|
||||
|
||||
// Start processing the task asynchronously
|
||||
this.processTask(task.id).catch((error) => {
|
||||
logger.error(`Error processing task ${task.id}:`, error);
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tasks
|
||||
*/
|
||||
async getAllTasks(): Promise<ContinuousDownloadTask[]> {
|
||||
const tasks = await db.select().from(continuousDownloadTasks);
|
||||
const { collections } = await import("../db/schema");
|
||||
const result = await db
|
||||
.select({
|
||||
task: continuousDownloadTasks,
|
||||
playlistName: collections.name,
|
||||
})
|
||||
.from(continuousDownloadTasks)
|
||||
.leftJoin(
|
||||
collections,
|
||||
eq(continuousDownloadTasks.collectionId, collections.id)
|
||||
);
|
||||
|
||||
// Convert null to undefined for TypeScript compatibility and ensure status type
|
||||
return tasks.map((task) => ({
|
||||
return result.map(({ task, playlistName }) => ({
|
||||
...task,
|
||||
subscriptionId: task.subscriptionId ?? undefined,
|
||||
collectionId: task.collectionId ?? undefined,
|
||||
playlistName: playlistName ?? undefined,
|
||||
updatedAt: task.updatedAt ?? undefined,
|
||||
completedAt: task.completedAt ?? undefined,
|
||||
error: task.error ?? undefined,
|
||||
@@ -104,17 +163,30 @@ export class ContinuousDownloadService {
|
||||
* Get a task by ID
|
||||
*/
|
||||
async getTaskById(id: string): Promise<ContinuousDownloadTask | null> {
|
||||
const tasks = await db
|
||||
.select()
|
||||
const { collections } = await import("../db/schema");
|
||||
const result = await db
|
||||
.select({
|
||||
task: continuousDownloadTasks,
|
||||
playlistName: collections.name,
|
||||
})
|
||||
.from(continuousDownloadTasks)
|
||||
.leftJoin(
|
||||
collections,
|
||||
eq(continuousDownloadTasks.collectionId, collections.id)
|
||||
)
|
||||
.where(eq(continuousDownloadTasks.id, id))
|
||||
.limit(1);
|
||||
if (tasks.length === 0) return null;
|
||||
const task = tasks[0];
|
||||
|
||||
if (result.length === 0) return null;
|
||||
|
||||
const { task, playlistName } = result[0];
|
||||
|
||||
// Convert null to undefined for TypeScript compatibility and ensure status type
|
||||
return {
|
||||
...task,
|
||||
subscriptionId: task.subscriptionId ?? undefined,
|
||||
collectionId: task.collectionId ?? undefined,
|
||||
playlistName: playlistName ?? undefined,
|
||||
updatedAt: task.updatedAt ?? undefined,
|
||||
completedAt: task.completedAt ?? undefined,
|
||||
error: task.error ?? undefined,
|
||||
@@ -140,6 +212,18 @@ export class ContinuousDownloadService {
|
||||
return; // Already completed or cancelled
|
||||
}
|
||||
|
||||
// Clean up temporary files for the current video being downloaded
|
||||
try {
|
||||
await this.cleanupCurrentVideoTempFiles(task);
|
||||
} catch (error) {
|
||||
logger.error(`Error cleaning up temp files for task ${id}:`, error);
|
||||
// Continue with cancellation even if cleanup fails
|
||||
}
|
||||
|
||||
// Clear cached video URLs for this task
|
||||
const cacheKey = `${id}:${task.authorUrl}`;
|
||||
this.videoUrlCache.delete(cacheKey);
|
||||
|
||||
await db
|
||||
.update(continuousDownloadTasks)
|
||||
.set({
|
||||
@@ -151,6 +235,102 @@ export class ContinuousDownloadService {
|
||||
logger.info(`Cancelled continuous download task ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary files for the current video being downloaded in a task
|
||||
*/
|
||||
private async cleanupCurrentVideoTempFiles(
|
||||
task: ContinuousDownloadTask
|
||||
): Promise<void> {
|
||||
// If no videos have been processed yet, nothing to clean up
|
||||
if (task.currentVideoIndex === 0 || task.totalVideos === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the video URL that's currently being downloaded
|
||||
const videoUrls = await this.getAllVideoUrls(
|
||||
task.authorUrl,
|
||||
task.platform
|
||||
);
|
||||
|
||||
if (task.currentVideoIndex < videoUrls.length) {
|
||||
const currentVideoUrl = videoUrls[task.currentVideoIndex];
|
||||
logger.info(
|
||||
`Cleaning up temp files for current video: ${currentVideoUrl}`
|
||||
);
|
||||
|
||||
// Get video info to determine the expected filename
|
||||
const { getVideoInfo } = await import("./downloadService");
|
||||
const videoInfo = await getVideoInfo(currentVideoUrl);
|
||||
|
||||
if (videoInfo && videoInfo.title) {
|
||||
const { formatVideoFilename } = await import("../utils/helpers");
|
||||
const { VIDEOS_DIR } = await import("../config/paths");
|
||||
const path = await import("path");
|
||||
|
||||
// Generate the expected base filename
|
||||
const baseFilename = formatVideoFilename(
|
||||
videoInfo.title,
|
||||
videoInfo.author || task.author,
|
||||
videoInfo.date ||
|
||||
new Date().toISOString().slice(0, 10).replace(/-/g, "")
|
||||
);
|
||||
|
||||
// Clean up video artifacts (temp files, .part files, etc.)
|
||||
const { cleanupVideoArtifacts } = await import(
|
||||
"../utils/downloadUtils"
|
||||
);
|
||||
const deletedFiles = await cleanupVideoArtifacts(
|
||||
baseFilename,
|
||||
VIDEOS_DIR
|
||||
);
|
||||
|
||||
if (deletedFiles.length > 0) {
|
||||
logger.info(
|
||||
`Cleaned up ${deletedFiles.length} temp files for cancelled task ${task.id}`
|
||||
);
|
||||
}
|
||||
|
||||
// Also check active downloads and cancel any matching download
|
||||
const downloadStatus = storageService.getDownloadStatus();
|
||||
const activeDownloads = downloadStatus.activeDownloads || [];
|
||||
|
||||
for (const download of activeDownloads) {
|
||||
if (
|
||||
download.sourceUrl === currentVideoUrl ||
|
||||
(download.filename && download.filename.includes(baseFilename))
|
||||
) {
|
||||
// Cancel this download
|
||||
logger.info(
|
||||
`Cancelling active download ${download.id} for video ${currentVideoUrl}`
|
||||
);
|
||||
storageService.removeActiveDownload(download.id);
|
||||
|
||||
// Clean up temp files for this download
|
||||
if (download.filename) {
|
||||
const { cleanupVideoArtifacts: cleanupArtifacts } =
|
||||
await import("../utils/downloadUtils");
|
||||
const path = await import("path");
|
||||
// Extract base filename without extension
|
||||
const baseFilename = path.basename(
|
||||
download.filename,
|
||||
path.extname(download.filename)
|
||||
);
|
||||
await cleanupArtifacts(baseFilename, VIDEOS_DIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in cleanupCurrentVideoTempFiles for task ${task.id}:`,
|
||||
error
|
||||
);
|
||||
// Don't throw - we want cancellation to proceed even if cleanup fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a task (remove from database)
|
||||
*/
|
||||
@@ -160,6 +340,10 @@ export class ContinuousDownloadService {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
|
||||
// Clear cached video URLs for this task
|
||||
const cacheKey = `${id}:${task.authorUrl}`;
|
||||
this.videoUrlCache.delete(cacheKey);
|
||||
|
||||
await db
|
||||
.delete(continuousDownloadTasks)
|
||||
.where(eq(continuousDownloadTasks.id, id));
|
||||
@@ -168,7 +352,318 @@ export class ContinuousDownloadService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all video URLs from a channel/author
|
||||
* Get total video count without loading all URLs (memory efficient)
|
||||
*/
|
||||
private async getVideoCount(
|
||||
authorUrl: string,
|
||||
platform: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (platform === "Bilibili") {
|
||||
const { extractBilibiliMid } = await import("../utils/helpers");
|
||||
const mid = extractBilibiliMid(authorUrl);
|
||||
if (!mid) {
|
||||
throw new Error("Invalid Bilibili space URL");
|
||||
}
|
||||
// For Bilibili, we'd need to make a lightweight API call
|
||||
// For now, return 0 and let getAllVideoUrls handle it
|
||||
return 0;
|
||||
} else {
|
||||
// For YouTube playlists, get count from playlist info
|
||||
const {
|
||||
executeYtDlpJson,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} = await import("../utils/ytDlpUtils");
|
||||
const { getProviderScript } = await import(
|
||||
"./downloaders/ytdlp/ytdlpHelpers"
|
||||
);
|
||||
const userConfig = getUserYtDlpConfig(authorUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
|
||||
const isPlaylist = playlistRegex.test(authorUrl);
|
||||
|
||||
if (isPlaylist) {
|
||||
// Get playlist count - fetch first page to get total count
|
||||
const result = await executeYtDlpJson(authorUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true,
|
||||
playlistStart: 1,
|
||||
playlistEnd: 1, // Just get first entry to get metadata
|
||||
...(PROVIDER_SCRIPT
|
||||
? {
|
||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// playlist_count is the total count in the playlist
|
||||
return result.playlist_count || 0;
|
||||
} else {
|
||||
// For channels, we can't easily get count without fetching
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error getting video count:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video URLs incrementally (for large playlists to save memory)
|
||||
* Returns URLs for a specific range
|
||||
*/
|
||||
private async getVideoUrlsIncremental(
|
||||
authorUrl: string,
|
||||
platform: string,
|
||||
startIndex: number,
|
||||
batchSize: number = 50
|
||||
): Promise<string[]> {
|
||||
const videoUrls: string[] = [];
|
||||
|
||||
try {
|
||||
if (platform === "Bilibili") {
|
||||
// For Bilibili, use yt-dlp to get all videos (more reliable than API)
|
||||
const { extractBilibiliMid } = await import("../utils/helpers");
|
||||
const mid = extractBilibiliMid(authorUrl);
|
||||
|
||||
if (!mid) {
|
||||
throw new Error("Invalid Bilibili space URL");
|
||||
}
|
||||
|
||||
const {
|
||||
executeYtDlpJson,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} = await import("../utils/ytDlpUtils");
|
||||
const userConfig = getUserYtDlpConfig(authorUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
// Use yt-dlp to get all videos from the space
|
||||
const videosUrl = `https://space.bilibili.com/${mid}/video`;
|
||||
|
||||
try {
|
||||
// Fetch all videos using flat playlist
|
||||
let hasMore = true;
|
||||
let page = 1;
|
||||
const pageSize = 100;
|
||||
|
||||
while (hasMore) {
|
||||
try {
|
||||
const result = await executeYtDlpJson(videosUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true,
|
||||
playlistStart: (page - 1) * pageSize + 1,
|
||||
playlistEnd: page * pageSize,
|
||||
});
|
||||
|
||||
if (result.entries && result.entries.length > 0) {
|
||||
for (const entry of result.entries) {
|
||||
if (entry.id && entry.id.startsWith("BV")) {
|
||||
// Valid Bilibili video ID
|
||||
videoUrls.push(
|
||||
entry.url || `https://www.bilibili.com/video/${entry.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
hasMore = result.entries.length === pageSize;
|
||||
page++;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error fetching Bilibili videos page ${page}:`,
|
||||
error
|
||||
);
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If yt-dlp didn't work, try API fallback
|
||||
if (videoUrls.length === 0) {
|
||||
logger.info("yt-dlp returned no videos, trying API fallback...");
|
||||
const axios = await import("axios");
|
||||
let pageNum = 1;
|
||||
const pageSize = 50;
|
||||
let hasMoreApi = true;
|
||||
|
||||
while (hasMoreApi) {
|
||||
try {
|
||||
const response = await axios.default.get(
|
||||
`https://api.bilibili.com/x/space/arc/search?mid=${mid}&pn=${pageNum}&ps=${pageSize}&order=pubdate`,
|
||||
{
|
||||
headers: {
|
||||
Referer: "https://www.bilibili.com",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
if (
|
||||
data &&
|
||||
data.code === 0 &&
|
||||
data.data &&
|
||||
data.data.list &&
|
||||
data.data.list.vlist
|
||||
) {
|
||||
const videos = data.data.list.vlist;
|
||||
for (const video of videos) {
|
||||
if (video.bvid) {
|
||||
videoUrls.push(
|
||||
`https://www.bilibili.com/video/${video.bvid}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const total = data.data.page?.count || 0;
|
||||
hasMoreApi =
|
||||
videoUrls.length < total && videos.length === pageSize;
|
||||
pageNum++;
|
||||
} else {
|
||||
hasMoreApi = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error fetching Bilibili videos page ${pageNum}:`,
|
||||
error
|
||||
);
|
||||
hasMoreApi = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error fetching Bilibili videos with yt-dlp:", error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// For YouTube, use yt-dlp to get all videos
|
||||
const {
|
||||
executeYtDlpJson,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} = await import("../utils/ytDlpUtils");
|
||||
const { getProviderScript } = await import(
|
||||
"./downloaders/ytdlp/ytdlpHelpers"
|
||||
);
|
||||
const userConfig = getUserYtDlpConfig(authorUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
// Check if it's a playlist URL
|
||||
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
|
||||
const isPlaylist = playlistRegex.test(authorUrl);
|
||||
|
||||
if (isPlaylist) {
|
||||
// For playlists, fetch only the batch we need
|
||||
const endIndex = startIndex + batchSize;
|
||||
try {
|
||||
const result = await executeYtDlpJson(authorUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true,
|
||||
playlistStart: startIndex + 1, // yt-dlp is 1-indexed
|
||||
playlistEnd: endIndex,
|
||||
...(PROVIDER_SCRIPT
|
||||
? {
|
||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (result.entries && result.entries.length > 0) {
|
||||
for (const entry of result.entries) {
|
||||
if (entry.id && !entry.id.startsWith("UC")) {
|
||||
// Skip channel IDs
|
||||
videoUrls.push(
|
||||
entry.url || `https://www.youtube.com/watch?v=${entry.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error fetching playlist videos batch ${startIndex}-${endIndex}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// For channels, construct URL to get videos from the channel
|
||||
let targetUrl = authorUrl;
|
||||
if (
|
||||
!targetUrl.includes("/videos") &&
|
||||
!targetUrl.includes("/shorts") &&
|
||||
!targetUrl.includes("/streams")
|
||||
) {
|
||||
if (targetUrl.endsWith("/")) {
|
||||
targetUrl = `${targetUrl}videos`;
|
||||
} else {
|
||||
targetUrl = `${targetUrl}/videos`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all videos using flat playlist
|
||||
let hasMore = true;
|
||||
let page = 1;
|
||||
const pageSize = 100;
|
||||
|
||||
while (hasMore) {
|
||||
try {
|
||||
const result = await executeYtDlpJson(targetUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true,
|
||||
playlistStart: (page - 1) * pageSize + 1,
|
||||
playlistEnd: page * pageSize,
|
||||
...(PROVIDER_SCRIPT
|
||||
? {
|
||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (result.entries && result.entries.length > 0) {
|
||||
for (const entry of result.entries) {
|
||||
if (entry.id && !entry.id.startsWith("UC")) {
|
||||
// Skip channel IDs
|
||||
videoUrls.push(
|
||||
entry.url || `https://www.youtube.com/watch?v=${entry.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
hasMore = result.entries.length === pageSize;
|
||||
page++;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error fetching YouTube videos page ${page}:`,
|
||||
error
|
||||
);
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error getting all video URLs:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(`Found ${videoUrls.length} videos for ${authorUrl}`);
|
||||
return videoUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all video URLs from a channel/author (for non-incremental mode)
|
||||
* This loads all URLs into memory - use with caution for large playlists
|
||||
*/
|
||||
private async getAllVideoUrls(
|
||||
authorUrl: string,
|
||||
@@ -228,57 +723,11 @@ export class ContinuousDownloadService {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching Bilibili videos page ${page}:`, error);
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If yt-dlp didn't work, try API fallback
|
||||
if (videoUrls.length === 0) {
|
||||
logger.info("yt-dlp returned no videos, trying API fallback...");
|
||||
const axios = await import("axios");
|
||||
let pageNum = 1;
|
||||
const pageSize = 50;
|
||||
let hasMoreApi = true;
|
||||
|
||||
while (hasMoreApi) {
|
||||
try {
|
||||
const response = await axios.default.get(
|
||||
`https://api.bilibili.com/x/space/arc/search?mid=${mid}&pn=${pageNum}&ps=${pageSize}&order=pubdate`,
|
||||
{
|
||||
headers: {
|
||||
Referer: "https://www.bilibili.com",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
},
|
||||
}
|
||||
logger.error(
|
||||
`Error fetching Bilibili videos page ${page}:`,
|
||||
error
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
if (
|
||||
data &&
|
||||
data.code === 0 &&
|
||||
data.data &&
|
||||
data.data.list &&
|
||||
data.data.list.vlist
|
||||
) {
|
||||
const videos = data.data.list.vlist;
|
||||
for (const video of videos) {
|
||||
if (video.bvid) {
|
||||
videoUrls.push(`https://www.bilibili.com/video/${video.bvid}`);
|
||||
}
|
||||
}
|
||||
|
||||
const total = data.data.page?.count || 0;
|
||||
hasMoreApi = videoUrls.length < total && videos.length === pageSize;
|
||||
pageNum++;
|
||||
} else {
|
||||
hasMoreApi = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching Bilibili videos page ${pageNum}:`, error);
|
||||
hasMoreApi = false;
|
||||
}
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -292,10 +741,62 @@ export class ContinuousDownloadService {
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} = await import("../utils/ytDlpUtils");
|
||||
const { getProviderScript } = await import(
|
||||
"./downloaders/ytdlp/ytdlpHelpers"
|
||||
);
|
||||
const userConfig = getUserYtDlpConfig(authorUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
// Construct URL to get videos from the channel
|
||||
// Check if it's a playlist URL
|
||||
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
|
||||
const isPlaylist = playlistRegex.test(authorUrl);
|
||||
|
||||
if (isPlaylist) {
|
||||
// For playlists, fetch all videos directly from the playlist URL
|
||||
let hasMore = true;
|
||||
let page = 1;
|
||||
const pageSize = 100;
|
||||
|
||||
while (hasMore) {
|
||||
try {
|
||||
const result = await executeYtDlpJson(authorUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true,
|
||||
playlistStart: (page - 1) * pageSize + 1,
|
||||
playlistEnd: page * pageSize,
|
||||
...(PROVIDER_SCRIPT
|
||||
? {
|
||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (result.entries && result.entries.length > 0) {
|
||||
for (const entry of result.entries) {
|
||||
if (entry.id && !entry.id.startsWith("UC")) {
|
||||
// Skip channel IDs
|
||||
videoUrls.push(
|
||||
entry.url || `https://www.youtube.com/watch?v=${entry.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
hasMore = result.entries.length === pageSize;
|
||||
page++;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error fetching playlist videos page ${page}:`,
|
||||
error
|
||||
);
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For channels, construct URL to get videos from the channel
|
||||
let targetUrl = authorUrl;
|
||||
if (
|
||||
!targetUrl.includes("/videos") &&
|
||||
@@ -322,6 +823,11 @@ export class ContinuousDownloadService {
|
||||
flatPlaylist: true,
|
||||
playlistStart: (page - 1) * pageSize + 1,
|
||||
playlistEnd: page * pageSize,
|
||||
...(PROVIDER_SCRIPT
|
||||
? {
|
||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (result.entries && result.entries.length > 0) {
|
||||
@@ -339,11 +845,15 @@ export class ContinuousDownloadService {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching YouTube videos page ${page}:`, error);
|
||||
logger.error(
|
||||
`Error fetching YouTube videos page ${page}:`,
|
||||
error
|
||||
);
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error getting all video URLs:", error);
|
||||
throw error;
|
||||
@@ -377,15 +887,60 @@ export class ContinuousDownloadService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch video list if we haven't already
|
||||
let videoUrls: string[] = [];
|
||||
// For large playlists, use incremental fetching to save memory
|
||||
// Check if it's a playlist (likely to be large)
|
||||
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
|
||||
const isPlaylist = playlistRegex.test(task.authorUrl);
|
||||
const useIncremental = isPlaylist && task.platform === "YouTube";
|
||||
|
||||
// Get total count if not set
|
||||
if (task.totalVideos === 0) {
|
||||
if (useIncremental) {
|
||||
// For playlists, get count without loading all URLs
|
||||
const count = await this.getVideoCount(task.authorUrl, task.platform);
|
||||
if (count > 0) {
|
||||
await db
|
||||
.update(continuousDownloadTasks)
|
||||
.set({
|
||||
totalVideos: count,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
task.totalVideos = count;
|
||||
} else {
|
||||
// Fallback: get count from first batch
|
||||
const firstBatch = await this.getVideoUrlsIncremental(
|
||||
task.authorUrl,
|
||||
task.platform,
|
||||
0,
|
||||
1
|
||||
);
|
||||
// We'll need to fetch more to get accurate count, but for now use estimate
|
||||
// Actually, let's just fetch a larger initial batch to get count
|
||||
const testBatch = await this.getVideoUrlsIncremental(
|
||||
task.authorUrl,
|
||||
task.platform,
|
||||
0,
|
||||
100
|
||||
);
|
||||
const estimatedTotal =
|
||||
testBatch.length >= 100 ? 1000 : testBatch.length; // Estimate
|
||||
await db
|
||||
.update(continuousDownloadTasks)
|
||||
.set({
|
||||
totalVideos: estimatedTotal,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
task.totalVideos = estimatedTotal;
|
||||
}
|
||||
} else {
|
||||
// For channels or small lists, use traditional method
|
||||
logger.info(`Fetching video list for task ${taskId}...`);
|
||||
videoUrls = await this.getAllVideoUrls(
|
||||
const videoUrls = await this.getAllVideoUrls(
|
||||
task.authorUrl,
|
||||
task.platform
|
||||
);
|
||||
|
||||
await db
|
||||
.update(continuousDownloadTasks)
|
||||
.set({
|
||||
@@ -393,20 +948,19 @@ export class ContinuousDownloadService {
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
|
||||
task.totalVideos = videoUrls.length;
|
||||
} else {
|
||||
// Fetch video URLs again (we could optimize this by storing URLs, but for now this works)
|
||||
videoUrls = await this.getAllVideoUrls(
|
||||
task.authorUrl,
|
||||
task.platform
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalVideos = task.totalVideos || 0;
|
||||
const fetchBatchSize = 50; // Fetch 50 URLs at a time
|
||||
const processBatchSize = 10; // Process 10 videos at a time
|
||||
|
||||
// Process videos incrementally
|
||||
for (
|
||||
let i = task.currentVideoIndex;
|
||||
i < videoUrls.length;
|
||||
i++
|
||||
i < totalVideos;
|
||||
i += processBatchSize
|
||||
) {
|
||||
// Check if task was cancelled
|
||||
const currentTask = await this.getTaskById(taskId);
|
||||
@@ -415,9 +969,40 @@ export class ContinuousDownloadService {
|
||||
break;
|
||||
}
|
||||
|
||||
const videoUrl = videoUrls[i];
|
||||
// Fetch batch of URLs if using incremental mode
|
||||
let videoUrls: string[] = [];
|
||||
if (useIncremental) {
|
||||
// Fetch only the batch we need
|
||||
const batchStart = i;
|
||||
const batchEnd = Math.min(i + fetchBatchSize, totalVideos);
|
||||
videoUrls = await this.getVideoUrlsIncremental(
|
||||
task.authorUrl,
|
||||
task.platform,
|
||||
batchStart,
|
||||
batchEnd - batchStart
|
||||
);
|
||||
} else {
|
||||
// For non-incremental, get all URLs (cached)
|
||||
const cacheKey = `${taskId}:${task.authorUrl}`;
|
||||
if (this.videoUrlCache.has(cacheKey)) {
|
||||
videoUrls = this.videoUrlCache.get(cacheKey)!;
|
||||
} else {
|
||||
videoUrls = await this.getAllVideoUrls(
|
||||
task.authorUrl,
|
||||
task.platform
|
||||
);
|
||||
this.videoUrlCache.set(cacheKey, videoUrls);
|
||||
}
|
||||
}
|
||||
|
||||
// Process videos in this batch
|
||||
for (let j = 0; j < videoUrls.length && i + j < totalVideos; j++) {
|
||||
const videoIndex = i + j;
|
||||
const videoUrl = videoUrls[j];
|
||||
logger.info(
|
||||
`Processing video ${i + 1}/${videoUrls.length} for task ${taskId}: ${videoUrl}`
|
||||
`Processing video ${
|
||||
videoIndex + 1
|
||||
}/${totalVideos} for task ${taskId}: ${videoUrl}`
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -429,7 +1014,7 @@ export class ContinuousDownloadService {
|
||||
.update(continuousDownloadTasks)
|
||||
.set({
|
||||
skippedCount: (currentTask.skippedCount || 0) + 1,
|
||||
currentVideoIndex: i + 1,
|
||||
currentVideoIndex: videoIndex + 1,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
@@ -450,8 +1035,7 @@ export class ContinuousDownloadService {
|
||||
}
|
||||
|
||||
// Add to download history
|
||||
const videoData =
|
||||
downloadResult?.videoData || downloadResult || {};
|
||||
const videoData = downloadResult?.videoData || downloadResult || {};
|
||||
storageService.addDownloadHistoryItem({
|
||||
id: uuidv4(),
|
||||
title: videoData.title || `Video from ${task.author}`,
|
||||
@@ -464,12 +1048,31 @@ export class ContinuousDownloadService {
|
||||
videoId: videoData.id,
|
||||
});
|
||||
|
||||
// If task has a collectionId, add video to collection
|
||||
if (task.collectionId && videoData.id) {
|
||||
try {
|
||||
storageService.addVideoToCollection(
|
||||
task.collectionId,
|
||||
videoData.id
|
||||
);
|
||||
logger.info(
|
||||
`Added video ${videoData.id} to collection ${task.collectionId}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error adding video to collection ${task.collectionId}:`,
|
||||
error
|
||||
);
|
||||
// Don't fail the task if collection add fails
|
||||
}
|
||||
}
|
||||
|
||||
// Update task progress
|
||||
await db
|
||||
.update(continuousDownloadTasks)
|
||||
.set({
|
||||
downloadedCount: (currentTask.downloadedCount || 0) + 1,
|
||||
currentVideoIndex: i + 1,
|
||||
currentVideoIndex: videoIndex + 1,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
@@ -497,7 +1100,7 @@ export class ContinuousDownloadService {
|
||||
.update(continuousDownloadTasks)
|
||||
.set({
|
||||
failedCount: (currentTask.failedCount || 0) + 1,
|
||||
currentVideoIndex: i + 1,
|
||||
currentVideoIndex: videoIndex + 1,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
@@ -508,6 +1111,10 @@ export class ContinuousDownloadService {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Clear videoUrls reference after processing batch to help GC
|
||||
videoUrls = [];
|
||||
}
|
||||
|
||||
// Mark task as completed
|
||||
const finalTask = await this.getTaskById(taskId);
|
||||
if (finalTask && finalTask.status === "active") {
|
||||
@@ -520,6 +1127,10 @@ export class ContinuousDownloadService {
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
|
||||
// Clear cached video URLs to free memory
|
||||
const cacheKey = `${taskId}:${finalTask.authorUrl}`;
|
||||
this.videoUrlCache.delete(cacheKey);
|
||||
|
||||
logger.info(
|
||||
`Completed continuous download task ${taskId}: ${finalTask.downloadedCount} downloaded, ${finalTask.skippedCount} skipped, ${finalTask.failedCount} failed`
|
||||
);
|
||||
@@ -534,6 +1145,13 @@ export class ContinuousDownloadService {
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(continuousDownloadTasks.id, taskId));
|
||||
|
||||
// Clear cached video URLs on error to free memory
|
||||
const task = await this.getTaskById(taskId);
|
||||
if (task) {
|
||||
const cacheKey = `${taskId}:${task.authorUrl}`;
|
||||
this.videoUrlCache.delete(cacheKey);
|
||||
}
|
||||
} finally {
|
||||
this.processingTasks.delete(taskId);
|
||||
}
|
||||
@@ -542,4 +1160,3 @@ export class ContinuousDownloadService {
|
||||
|
||||
export const continuousDownloadService =
|
||||
ContinuousDownloadService.getInstance();
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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\/)/;
|
||||
@@ -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
|
||||
|
||||
@@ -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