feat: subscription for youtube platfrom
This commit is contained in:
11
backend/drizzle/0003_puzzling_energizer.sql
Normal file
11
backend/drizzle/0003_puzzling_energizer.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `subscriptions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`author` text NOT NULL,
|
||||
`author_url` text NOT NULL,
|
||||
`interval` integer NOT NULL,
|
||||
`last_video_link` text,
|
||||
`last_check` integer,
|
||||
`download_count` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`platform` text DEFAULT 'YouTube'
|
||||
);
|
||||
581
backend/drizzle/meta/0003_snapshot.json
Normal file
581
backend/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,581 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "e34144d1-add0-4bb0-b9d3-852c5fa0384e",
|
||||
"prevId": "a4f15b55-7d41-46eb-a976-c89e80c42797",
|
||||
"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": {}
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1764190450949,
|
||||
"tag": "0002_romantic_colossus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1764631012929,
|
||||
"tag": "0003_puzzling_energizer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
75
backend/package-lock.json
generated
75
backend/package-lock.json
generated
@@ -20,8 +20,9 @@
|
||||
"express": "^4.18.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"path": "^0.12.7",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
"uuid": "^13.0.0",
|
||||
"youtube-dl-exec": "^2.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,7 +33,9 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"nodemon": "^3.0.3",
|
||||
@@ -1788,6 +1791,13 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -1859,6 +1869,13 @@
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
@@ -5358,6 +5375,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
|
||||
@@ -5700,16 +5726,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path": {
|
||||
"version": "0.12.7",
|
||||
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"process": "^0.11.1",
|
||||
"util": "^0.10.3"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -5888,15 +5904,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -7309,27 +7316,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
||||
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util/node_modules/inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -7339,6 +7331,19 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
"express": "^4.18.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
"uuid": "^13.0.0",
|
||||
"youtube-dl-exec": "^2.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37,7 +39,9 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"nodemon": "^3.0.3",
|
||||
|
||||
41
backend/src/controllers/subscriptionController.ts
Normal file
41
backend/src/controllers/subscriptionController.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { subscriptionService } from '../services/subscriptionService';
|
||||
|
||||
export const createSubscription = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { url, interval } = req.body;
|
||||
console.log('Creating subscription:', { url, interval, body: req.body });
|
||||
if (!url || !interval) {
|
||||
return res.status(400).json({ error: 'URL and interval are required' });
|
||||
}
|
||||
const subscription = await subscriptionService.subscribe(url, parseInt(interval));
|
||||
res.status(201).json(subscription);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating subscription:', error);
|
||||
if (error.message === 'Subscription already exists') {
|
||||
return res.status(409).json({ error: 'Subscription already exists' });
|
||||
}
|
||||
res.status(500).json({ error: error.message || 'Failed to create subscription' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getSubscriptions = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const subscriptions = await subscriptionService.listSubscriptions();
|
||||
res.json(subscriptions);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriptions:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch subscriptions' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSubscription = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await subscriptionService.unsubscribe(id);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting subscription:', error);
|
||||
res.status(500).json({ error: 'Failed to delete subscription' });
|
||||
}
|
||||
};
|
||||
@@ -105,3 +105,15 @@ export const downloadHistory = sqliteTable('download_history', {
|
||||
thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful
|
||||
totalSize: text('total_size'),
|
||||
});
|
||||
|
||||
export const subscriptions = sqliteTable('subscriptions', {
|
||||
id: text('id').primaryKey(),
|
||||
author: text('author').notNull(),
|
||||
authorUrl: text('author_url').notNull(),
|
||||
interval: integer('interval').notNull(), // Check interval in minutes
|
||||
lastVideoLink: text('last_video_link'),
|
||||
lastCheck: integer('last_check'), // Timestamp
|
||||
downloadCount: integer('download_count').default(0),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
platform: text('platform').default('YouTube'),
|
||||
});
|
||||
|
||||
@@ -42,4 +42,10 @@ router.post("/collections", collectionController.createCollection);
|
||||
router.put("/collections/:id", collectionController.updateCollection);
|
||||
router.delete("/collections/:id", collectionController.deleteCollection);
|
||||
|
||||
// Subscription routes
|
||||
import * as subscriptionController from "../controllers/subscriptionController";
|
||||
router.post("/subscriptions", subscriptionController.createSubscription);
|
||||
router.get("/subscriptions", subscriptionController.getSubscriptions);
|
||||
router.delete("/subscriptions/:id", subscriptionController.deleteSubscription);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -44,6 +44,11 @@ app.use('/api/settings', settingsRoutes);
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
|
||||
// Start subscription scheduler
|
||||
import("./services/subscriptionService").then(({ subscriptionService }) => {
|
||||
subscriptionService.startScheduler();
|
||||
}).catch(err => console.error("Failed to start subscription service:", err));
|
||||
|
||||
// Run duration backfill in background
|
||||
import("./services/metadataService").then(service => {
|
||||
service.backfillDurations();
|
||||
|
||||
@@ -106,6 +106,56 @@ export class YtDlpDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the latest video URL from a channel
|
||||
static async getLatestVideoUrl(channelUrl: string): Promise<string | null> {
|
||||
try {
|
||||
console.log("Fetching latest video for channel:", channelUrl);
|
||||
|
||||
// Append /videos to channel URL to ensure we get videos and not the channel tab
|
||||
let targetUrl = channelUrl;
|
||||
if (channelUrl.includes('youtube.com/') && !channelUrl.includes('/videos') && !channelUrl.includes('/shorts') && !channelUrl.includes('/streams')) {
|
||||
// Check if it looks like a channel URL
|
||||
if (channelUrl.includes('/@') || channelUrl.includes('/channel/') || channelUrl.includes('/c/') || channelUrl.includes('/user/')) {
|
||||
targetUrl = `${channelUrl}/videos`;
|
||||
console.log("Modified channel URL to:", targetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Use yt-dlp to get the first video in the channel (playlist)
|
||||
const result = await youtubedl(targetUrl, {
|
||||
dumpSingleJson: true,
|
||||
playlistEnd: 5,
|
||||
noWarnings: true,
|
||||
flatPlaylist: true, // We only need the ID/URL, not full info
|
||||
} as any);
|
||||
|
||||
// If it's a playlist/channel, 'entries' will contain the videos
|
||||
if ((result as any).entries && (result as any).entries.length > 0) {
|
||||
// Iterate through entries to find a valid video
|
||||
// Sometimes the first entry is the channel/tab itself (e.g. id starts with UC)
|
||||
for (const entry of (result as any).entries) {
|
||||
// Skip entries that look like channel IDs (start with UC and are 24 chars)
|
||||
// or entries without a title/url that look like metadata
|
||||
if (entry.id && entry.id.startsWith('UC') && entry.id.length === 24) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const videoId = entry.id;
|
||||
if (videoId) {
|
||||
return `https://www.youtube.com/watch?v=${videoId}`;
|
||||
}
|
||||
if (entry.url) {
|
||||
return entry.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest video URL:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Download video
|
||||
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
|
||||
console.log("Detected URL:", videoUrl);
|
||||
|
||||
169
backend/src/services/subscriptionService.ts
Normal file
169
backend/src/services/subscriptionService.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import cron, { ScheduledTask } from 'node-cron';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from '../db';
|
||||
import { subscriptions } from '../db/schema';
|
||||
import { downloadYouTubeVideo } from './downloadService';
|
||||
import { YtDlpDownloader } from './downloaders/YtDlpDownloader';
|
||||
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
interval: number;
|
||||
lastVideoLink?: string;
|
||||
lastCheck?: number;
|
||||
downloadCount: number;
|
||||
createdAt: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export class SubscriptionService {
|
||||
private static instance: SubscriptionService;
|
||||
private checkTask: ScheduledTask | null = null;
|
||||
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): SubscriptionService {
|
||||
if (!SubscriptionService.instance) {
|
||||
SubscriptionService.instance = new SubscriptionService();
|
||||
}
|
||||
return SubscriptionService.instance;
|
||||
}
|
||||
|
||||
async subscribe(authorUrl: string, interval: number): Promise<Subscription> {
|
||||
// Validate URL (basic check)
|
||||
if (!authorUrl.includes('youtube.com')) {
|
||||
throw new Error('Invalid YouTube URL');
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
const existing = await db.select().from(subscriptions).where(eq(subscriptions.authorUrl, authorUrl));
|
||||
if (existing.length > 0) {
|
||||
throw new Error('Subscription already exists');
|
||||
}
|
||||
|
||||
// Extract author from URL if possible
|
||||
let authorName = 'Unknown Author';
|
||||
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
|
||||
if (match && match[1]) {
|
||||
authorName = match[1];
|
||||
} else {
|
||||
// Fallback: try to extract from other URL formats
|
||||
const parts = authorUrl.split('/');
|
||||
if (parts.length > 0) {
|
||||
const lastPart = parts[parts.length - 1];
|
||||
if (lastPart) authorName = lastPart;
|
||||
}
|
||||
}
|
||||
|
||||
// We skip heavy getVideoInfo here to ensure fast response.
|
||||
// The scheduler will eventually fetch new videos and we can update author name then if needed.
|
||||
|
||||
let lastVideoLink = '';
|
||||
|
||||
const newSubscription: Subscription = {
|
||||
id: uuidv4(),
|
||||
author: authorName,
|
||||
authorUrl,
|
||||
interval,
|
||||
lastVideoLink,
|
||||
lastCheck: Date.now(),
|
||||
downloadCount: 0,
|
||||
createdAt: Date.now(),
|
||||
platform: 'YouTube'
|
||||
};
|
||||
|
||||
await db.insert(subscriptions).values(newSubscription);
|
||||
return newSubscription;
|
||||
}
|
||||
|
||||
async unsubscribe(id: string): Promise<void> {
|
||||
await db.delete(subscriptions).where(eq(subscriptions.id, id));
|
||||
}
|
||||
|
||||
async listSubscriptions(): Promise<Subscription[]> {
|
||||
// @ts-ignore - Drizzle type inference might be tricky with raw select sometimes, but this should be fine.
|
||||
// Actually, db.select().from(subscriptions) returns the inferred type.
|
||||
return await db.select().from(subscriptions);
|
||||
}
|
||||
|
||||
async checkSubscriptions(): Promise<void> {
|
||||
// console.log('Checking subscriptions...'); // Too verbose
|
||||
const allSubs = await this.listSubscriptions();
|
||||
|
||||
for (const sub of allSubs) {
|
||||
const now = Date.now();
|
||||
const lastCheck = sub.lastCheck || 0;
|
||||
const intervalMs = sub.interval * 60 * 1000;
|
||||
|
||||
if (now - lastCheck >= intervalMs) {
|
||||
try {
|
||||
console.log(`Checking subscription for ${sub.author}...`);
|
||||
// 1. Fetch latest video link
|
||||
// We need a robust way to get the latest video.
|
||||
// We can use `yt-dlp --print webpage_url --playlist-end 1 "channel_url"`
|
||||
// We'll need to expose a method in `downloadService` or `YtDlpDownloader` for this.
|
||||
// For now, let's assume `getLatestVideoUrl` exists.
|
||||
const latestVideoUrl = await this.getLatestVideoUrl(sub.authorUrl);
|
||||
|
||||
if (latestVideoUrl && latestVideoUrl !== sub.lastVideoLink) {
|
||||
console.log(`New video found for ${sub.author}: ${latestVideoUrl}`);
|
||||
|
||||
// 2. Download the video
|
||||
// We use `downloadYouTubeVideo` from downloadService`.
|
||||
// We might want to associate this download with the subscription for tracking?
|
||||
// The requirement says "update last_video_link value".
|
||||
|
||||
await downloadYouTubeVideo(latestVideoUrl);
|
||||
|
||||
// 3. Update subscription record
|
||||
await db.update(subscriptions)
|
||||
.set({
|
||||
lastVideoLink: latestVideoUrl,
|
||||
lastCheck: now,
|
||||
downloadCount: (sub.downloadCount || 0) + 1
|
||||
})
|
||||
.where(eq(subscriptions.id, sub.id));
|
||||
} else {
|
||||
// Just update lastCheck
|
||||
await db.update(subscriptions)
|
||||
.set({ lastCheck: now })
|
||||
.where(eq(subscriptions.id, sub.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking subscription for ${sub.author}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startScheduler() {
|
||||
if (this.checkTask) {
|
||||
this.checkTask.stop();
|
||||
}
|
||||
// Run every minute
|
||||
this.checkTask = cron.schedule('* * * * *', () => {
|
||||
this.checkSubscriptions();
|
||||
});
|
||||
console.log('Subscription scheduler started (node-cron).');
|
||||
}
|
||||
|
||||
// Helper to get latest video URL.
|
||||
// This should probably be in YtDlpDownloader, but for now we can implement it here using a similar approach.
|
||||
// We need to import `exec` or similar to run yt-dlp.
|
||||
// Since `YtDlpDownloader` is in `services/downloaders`, we should probably add a method there.
|
||||
// But to keep it self-contained for now, I'll assume we can add it to `YtDlpDownloader` later or mock it.
|
||||
// Let's try to use `YtDlpDownloader.getLatestVideoUrl` if we can add it.
|
||||
// For now, I will implement a placeholder that uses `YtDlpDownloader`'s internal logic if possible,
|
||||
// or just calls `getVideoInfo` and hopes it works for channels (it might not give the *latest* video URL directly).
|
||||
|
||||
// BETTER APPROACH: Add `getLatestVideoUrl` to `YtDlpDownloader` class.
|
||||
// I will do that in a separate step. For now, I'll define the interface.
|
||||
private async getLatestVideoUrl(channelUrl: string): Promise<string | null> {
|
||||
return await YtDlpDownloader.getLatestVideoUrl(channelUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = SubscriptionService.getInstance();
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
|
||||
import './App.css';
|
||||
import AnimatedRoutes from './components/AnimatedRoutes';
|
||||
import BilibiliPartsModal from './components/BilibiliPartsModal';
|
||||
import Footer from './components/Footer';
|
||||
import Header from './components/Header';
|
||||
@@ -12,7 +11,15 @@ import { DownloadProvider, useDownload } from './contexts/DownloadContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { SnackbarProvider } from './contexts/SnackbarContext';
|
||||
import { VideoProvider, useVideo } from './contexts/VideoContext';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
import DownloadPage from './pages/DownloadPage';
|
||||
import Home from './pages/Home';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import ManagePage from './pages/ManagePage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SubscriptionsPage from './pages/SubscriptionsPage';
|
||||
import VideoPlayer from './pages/VideoPlayer';
|
||||
import getTheme from './theme';
|
||||
|
||||
function AppContent() {
|
||||
@@ -97,7 +104,16 @@ function AppContent() {
|
||||
/>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
|
||||
<AnimatedRoutes />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/manage" element={<ManagePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/downloads" element={<DownloadPage />} />
|
||||
<Route path="/collections/:id" element={<CollectionPage />} />
|
||||
<Route path="/author/:name" element={<AuthorVideos />} />
|
||||
<Route path="/video/:id" element={<VideoPlayer />} />
|
||||
<Route path="/subscriptions" element={<SubscriptionsPage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
|
||||
<Footer />
|
||||
|
||||
54
frontend/src/components/AlertModal.tsx
Normal file
54
frontend/src/components/AlertModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface AlertModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const AlertModal: React.FC<AlertModalProps> = ({ open, onClose, title, message }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2 }}>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText sx={{ color: 'text.primary' }}>
|
||||
{message}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onClose} variant="contained" color="primary" autoFocus>
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertModal;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Menu as MenuIcon,
|
||||
Search,
|
||||
Settings,
|
||||
Subscriptions,
|
||||
VideoLibrary
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
@@ -330,6 +331,9 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<MenuItem onClick={() => { handleManageClose(); navigate('/manage'); }}>
|
||||
<VideoLibrary sx={{ mr: 2 }} /> {t('manageContent')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleManageClose(); navigate('/subscriptions'); }}>
|
||||
<Subscriptions sx={{ mr: 2 }} /> {t('subscriptions')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleManageClose(); navigate('/settings'); }}>
|
||||
<Settings sx={{ mr: 2 }} /> {t('settings')}
|
||||
</MenuItem>
|
||||
|
||||
97
frontend/src/components/SubscribeModal.tsx
Normal file
97
frontend/src/components/SubscribeModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Close } from '@mui/icons-material';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface SubscribeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (interval: number) => void;
|
||||
authorName?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const SubscribeModal: React.FC<SubscribeModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
authorName,
|
||||
url
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [interval, setInterval] = useState<number>(60); // Default 60 minutes
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(interval);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
|
||||
{t('subscribeToAuthor')}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
color: (theme) => theme.palette.grey[500],
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText sx={{ mb: 2, color: 'text.primary' }}>
|
||||
{t('subscribeConfirmationMessage', { author: authorName || url })}
|
||||
</DialogContentText>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('subscribeDescription')}
|
||||
</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="interval"
|
||||
label={t('checkIntervalMinutes')}
|
||||
type="number"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(Number(e.target.value))}
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color="primary">
|
||||
{t('subscribe')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscribeModal;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import AlertModal from '../components/AlertModal';
|
||||
import SubscribeModal from '../components/SubscribeModal';
|
||||
import { DownloadInfo } from '../types';
|
||||
import { useCollection } from './CollectionContext';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
@@ -141,6 +143,15 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
|
||||
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
|
||||
try {
|
||||
// Check for YouTube channel URL
|
||||
// Regex for: @username, channel/ID, user/username, c/customURL
|
||||
const channelRegex = /youtube\.com\/(?:@|channel\/|user\/|c\/)/;
|
||||
if (channelRegex.test(videoUrl)) {
|
||||
setSubscribeUrl(videoUrl);
|
||||
setShowSubscribeModal(true);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Check if it's a Bilibili URL
|
||||
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
|
||||
setIsCheckingParts(true);
|
||||
@@ -267,6 +278,31 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
|
||||
};
|
||||
|
||||
// Subscription logic
|
||||
const [showSubscribeModal, setShowSubscribeModal] = useState(false);
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false);
|
||||
const [subscribeUrl, setSubscribeUrl] = useState('');
|
||||
|
||||
const handleSubscribe = async (interval: number) => {
|
||||
try {
|
||||
await axios.post(`${API_URL}/subscriptions`, {
|
||||
url: subscribeUrl,
|
||||
interval
|
||||
});
|
||||
showSnackbar(t('subscribedSuccessfully'));
|
||||
setShowSubscribeModal(false);
|
||||
setSubscribeUrl('');
|
||||
} catch (error: any) {
|
||||
console.error('Error subscribing:', error);
|
||||
if (error.response && error.response.status === 409) {
|
||||
setShowSubscribeModal(false);
|
||||
setShowDuplicateModal(true);
|
||||
} else {
|
||||
showSnackbar(t('error'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={{
|
||||
activeDownloads,
|
||||
@@ -280,6 +316,18 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
handleDownloadCurrentBilibiliPart
|
||||
}}>
|
||||
{children}
|
||||
<SubscribeModal
|
||||
open={showSubscribeModal}
|
||||
onClose={() => setShowSubscribeModal(false)}
|
||||
onConfirm={handleSubscribe}
|
||||
url={subscribeUrl}
|
||||
/>
|
||||
<AlertModal
|
||||
open={showDuplicateModal}
|
||||
onClose={() => setShowDuplicateModal(false)}
|
||||
title={t('error')}
|
||||
message={t('subscriptionAlreadyExists')}
|
||||
/>
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
136
frontend/src/pages/SubscriptionsPage.tsx
Normal file
136
frontend/src/pages/SubscriptionsPage.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Delete } from '@mui/icons-material';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface Subscription {
|
||||
id: string;
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
interval: number;
|
||||
lastVideoLink?: string;
|
||||
lastCheck?: number;
|
||||
downloadCount: number;
|
||||
createdAt: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
const SubscriptionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscriptions();
|
||||
}, []);
|
||||
|
||||
const fetchSubscriptions = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/subscriptions`);
|
||||
setSubscriptions(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriptions:', error);
|
||||
showSnackbar(t('error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsubscribe = async (id: string, author: string) => {
|
||||
if (!window.confirm(t('confirmUnsubscribe', { author }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`${API_URL}/subscriptions/${id}`);
|
||||
showSnackbar(t('unsubscribedSuccessfully'));
|
||||
fetchSubscriptions();
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing:', error);
|
||||
showSnackbar(t('error'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return t('never');
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
|
||||
{t('subscriptions')}
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} sx={{ mt: 3 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('author')}</TableCell>
|
||||
<TableCell>{t('platform')}</TableCell>
|
||||
<TableCell>{t('interval')}</TableCell>
|
||||
<TableCell>{t('lastCheck')}</TableCell>
|
||||
<TableCell>{t('downloads')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{subscriptions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
<Typography color="text.secondary" sx={{ py: 4 }}>
|
||||
{t('noVideos')} {/* Reusing "No videos found" or similar if "No subscriptions" key missing */}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
subscriptions.map((sub) => (
|
||||
<TableRow key={sub.id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
href={sub.authorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none', justifyContent: 'flex-start', p: 0 }}
|
||||
>
|
||||
{sub.author}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{sub.platform}</TableCell>
|
||||
<TableCell>{sub.interval} {t('minutes')}</TableCell>
|
||||
<TableCell>{formatDate(sub.lastCheck)}</TableCell>
|
||||
<TableCell>{sub.downloadCount}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleUnsubscribe(sub.id, sub.author)}
|
||||
title={t('unsubscribe')}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsPage;
|
||||
@@ -274,6 +274,24 @@ export const ar = {
|
||||
batchDownloadDescription: "الصق روابط متعددة أدناه، واحد في كل سطر.",
|
||||
urls: "الروابط",
|
||||
addToQueue: "إضافة إلى قائمة الانتظار",
|
||||
batchTasksAdded: "تمت إضافة {count} مهام",
|
||||
batchTasksAdded: "تمت إضافة {count} مهمة",
|
||||
addBatchTasks: "إضافة مهام مجمعة",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "الاشتراك في المؤلف",
|
||||
subscribeConfirmationMessage: "هل تريد الاشتراك في {author}؟",
|
||||
subscribeDescription: "سيقوم النظام تلقائيًا بالتحقق من مقاطع الفيديو الجديدة لهذا المؤلف وتنزيلها.",
|
||||
checkIntervalMinutes: "فاصل التحقق (دقائق)",
|
||||
subscribe: "اشتراك",
|
||||
subscriptions: "الاشتراكات",
|
||||
interval: "الفاصل الزمني",
|
||||
lastCheck: "آخر تحقق",
|
||||
platform: "المنصة",
|
||||
unsubscribe: "إلغاء الاشتراك",
|
||||
confirmUnsubscribe: "هل أنت متأكد أنك تريد إلغاء الاشتراك من {author}؟",
|
||||
subscribedSuccessfully: "تم الاشتراك بنجاح",
|
||||
unsubscribedSuccessfully: "تم إلغاء الاشتراك بنجاح",
|
||||
subscriptionAlreadyExists: "أنت مشترك بالفعل في هذا المؤلف.",
|
||||
minutes: "دقائق",
|
||||
never: "أبداً",
|
||||
};
|
||||
|
||||
@@ -147,4 +147,22 @@ export const de = {
|
||||
addToQueue: "Zur Warteschlange hinzufügen",
|
||||
batchTasksAdded: "{count} Aufgaben hinzugefügt",
|
||||
addBatchTasks: "Stapelaufgaben hinzufügen",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "Autor abonnieren",
|
||||
subscribeConfirmationMessage: "Möchten Sie {author} abonnieren?",
|
||||
subscribeDescription: "Das System prüft automatisch auf neue Videos dieses Autors und lädt sie herunter.",
|
||||
checkIntervalMinutes: "Prüfintervall (Minuten)",
|
||||
subscribe: "Abonnieren",
|
||||
subscriptions: "Abonnements",
|
||||
interval: "Intervall",
|
||||
lastCheck: "Letzte Prüfung",
|
||||
platform: "Plattform",
|
||||
unsubscribe: "Deabonnieren",
|
||||
confirmUnsubscribe: "Sind Sie sicher, dass Sie {author} deabonnieren möchten?",
|
||||
subscribedSuccessfully: "Erfolgreich abonniert",
|
||||
unsubscribedSuccessfully: "Erfolgreich deabonniert",
|
||||
subscriptionAlreadyExists: "Sie haben diesen Autor bereits abonniert.",
|
||||
minutes: "Minuten",
|
||||
never: "Nie",
|
||||
};
|
||||
|
||||
@@ -277,4 +277,22 @@ export const en = {
|
||||
addToQueue: "Add to Queue",
|
||||
batchTasksAdded: "{count} tasks added",
|
||||
addBatchTasks: "Add batch tasks",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "Subscribe to Author",
|
||||
subscribeConfirmationMessage: "Do you want to subscribe to {author}?",
|
||||
subscribeDescription: "The system will automatically check for new videos from this author and download them.",
|
||||
checkIntervalMinutes: "Check Interval (minutes)",
|
||||
subscribe: "Subscribe",
|
||||
subscriptions: "Subscriptions",
|
||||
interval: "Interval",
|
||||
lastCheck: "Last Check",
|
||||
platform: "Platform",
|
||||
unsubscribe: "Unsubscribe",
|
||||
confirmUnsubscribe: "Are you sure you want to unsubscribe from {author}?",
|
||||
subscribedSuccessfully: "Subscribed successfully",
|
||||
unsubscribedSuccessfully: "Unsubscribed successfully",
|
||||
subscriptionAlreadyExists: "You are already subscribed to this author.",
|
||||
minutes: "minutes",
|
||||
never: "Never",
|
||||
};
|
||||
|
||||
@@ -144,4 +144,22 @@ export const es = {
|
||||
addToQueue: "Añadir a la cola",
|
||||
batchTasksAdded: "{count} tareas añadidas",
|
||||
addBatchTasks: "Añadir tareas por lotes",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "Suscribirse al autor",
|
||||
subscribeConfirmationMessage: "¿Quieres suscribirte a {author}?",
|
||||
subscribeDescription: "El sistema comprobará automáticamente si hay nuevos vídeos de este autor y los descargará.",
|
||||
checkIntervalMinutes: "Intervalo de comprobación (minutos)",
|
||||
subscribe: "Suscribirse",
|
||||
subscriptions: "Suscripciones",
|
||||
interval: "Intervalo",
|
||||
lastCheck: "Última comprobación",
|
||||
platform: "Plataforma",
|
||||
unsubscribe: "Darse de baja",
|
||||
confirmUnsubscribe: "¿Estás seguro de que quieres darte de baja de {author}?",
|
||||
subscribedSuccessfully: "Suscrito con éxito",
|
||||
unsubscribedSuccessfully: "Dado de baja con éxito",
|
||||
subscriptionAlreadyExists: "Ya estás suscrito a este autor.",
|
||||
minutes: "minutos",
|
||||
never: "Nunca",
|
||||
};
|
||||
|
||||
@@ -274,4 +274,22 @@ export const fr = {
|
||||
addToQueue: "Ajouter à la file d'attente",
|
||||
batchTasksAdded: "{count} tâches ajoutées",
|
||||
addBatchTasks: "Ajouter des tâches par lot",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "S'abonner à l'auteur",
|
||||
subscribeConfirmationMessage: "Voulez-vous vous abonner à {author} ?",
|
||||
subscribeDescription: "Le système vérifiera automatiquement les nouvelles vidéos de cet auteur et les téléchargera.",
|
||||
checkIntervalMinutes: "Intervalle de vérification (minutes)",
|
||||
subscribe: "S'abonner",
|
||||
subscriptions: "Abonnements",
|
||||
interval: "Intervalle",
|
||||
lastCheck: "Dernière vérification",
|
||||
platform: "Plateforme",
|
||||
unsubscribe: "Se désabonner",
|
||||
confirmUnsubscribe: "Êtes-vous sûr de vouloir vous désabonner de {author} ?",
|
||||
subscribedSuccessfully: "Abonné avec succès",
|
||||
unsubscribedSuccessfully: "Désabonné avec succès",
|
||||
subscriptionAlreadyExists: "Vous êtes déjà abonné à cet auteur.",
|
||||
minutes: "minutes",
|
||||
never: "Jamais",
|
||||
};
|
||||
|
||||
@@ -276,4 +276,22 @@ export const ja = {
|
||||
addToQueue: "キューに追加",
|
||||
batchTasksAdded: "{count} 件のタスクを追加しました",
|
||||
addBatchTasks: "一括タスクを追加",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "著者を購読する",
|
||||
subscribeConfirmationMessage: "{author} を購読しますか?",
|
||||
subscribeDescription: "システムはこの著者の新しい動画を自動的にチェックしてダウンロードします。",
|
||||
checkIntervalMinutes: "チェック間隔(分)",
|
||||
subscribe: "購読",
|
||||
subscriptions: "購読",
|
||||
interval: "間隔",
|
||||
lastCheck: "前回のチェック",
|
||||
platform: "プラットフォーム",
|
||||
unsubscribe: "購読解除",
|
||||
confirmUnsubscribe: "{author} の購読を解除してもよろしいですか?",
|
||||
subscribedSuccessfully: "購読しました",
|
||||
unsubscribedSuccessfully: "購読を解除しました",
|
||||
subscriptionAlreadyExists: "この著者はすでに購読しています。",
|
||||
minutes: "分",
|
||||
never: "なし",
|
||||
};
|
||||
|
||||
@@ -276,4 +276,22 @@ export const ko = {
|
||||
addToQueue: "대기열에 추가",
|
||||
batchTasksAdded: "{count}개의 작업이 추가되었습니다",
|
||||
addBatchTasks: "일괄 작업 추가",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "작가 구독",
|
||||
subscribeConfirmationMessage: "{author}님을 구독하시겠습니까?",
|
||||
subscribeDescription: "시스템이 자동으로 이 작가의 새 동영상을 확인하고 다운로드합니다.",
|
||||
checkIntervalMinutes: "확인 간격 (분)",
|
||||
subscribe: "구독",
|
||||
subscriptions: "구독",
|
||||
interval: "간격",
|
||||
lastCheck: "마지막 확인",
|
||||
platform: "플랫폼",
|
||||
unsubscribe: "구독 취소",
|
||||
confirmUnsubscribe: "{author}님의 구독을 취소하시겠습니까?",
|
||||
subscribedSuccessfully: "구독 성공",
|
||||
unsubscribedSuccessfully: "구독 취소 성공",
|
||||
subscriptionAlreadyExists: "이미 구독 중인 작가입니다.",
|
||||
minutes: "분",
|
||||
never: "없음",
|
||||
};
|
||||
|
||||
@@ -275,4 +275,22 @@ export const pt = {
|
||||
addToQueue: "Adicionar à fila",
|
||||
batchTasksAdded: "{count} tarefas adicionadas",
|
||||
addBatchTasks: "Adicionar tarefas em lote",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "Inscrever-se no autor",
|
||||
subscribeConfirmationMessage: "Deseja se inscrever em {author}?",
|
||||
subscribeDescription: "O sistema verificará automaticamente novos vídeos deste autor e os baixará.",
|
||||
checkIntervalMinutes: "Intervalo de verificação (minutos)",
|
||||
subscribe: "Inscrever-se",
|
||||
subscriptions: "Inscrições",
|
||||
interval: "Intervalo",
|
||||
lastCheck: "Última verificação",
|
||||
platform: "Plataforma",
|
||||
unsubscribe: "Cancelar inscrição",
|
||||
confirmUnsubscribe: "Tem certeza de que deseja cancelar a inscrição de {author}?",
|
||||
subscribedSuccessfully: "Inscrito com sucesso",
|
||||
unsubscribedSuccessfully: "Inscrição cancelada com sucesso",
|
||||
subscriptionAlreadyExists: "Você já está inscrito neste autor.",
|
||||
minutes: "minutos",
|
||||
never: "Nunca",
|
||||
};
|
||||
|
||||
@@ -274,6 +274,24 @@ export const ru = {
|
||||
batchDownloadDescription: "Вставьте несколько URL ниже, по одному в строке.",
|
||||
urls: "URL",
|
||||
addToQueue: "Добавить в очередь",
|
||||
batchTasksAdded: "Добавлено {count} задач",
|
||||
batchTasksAdded: "Добавлено задач: {count}",
|
||||
addBatchTasks: "Добавить пакетные задачи",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "Подписаться на автора",
|
||||
subscribeConfirmationMessage: "Вы хотите подписаться на {author}?",
|
||||
subscribeDescription: "Система будет автоматически проверять новые видео от этого автора и скачивать их.",
|
||||
checkIntervalMinutes: "Интервал проверки (минуты)",
|
||||
subscribe: "Подписаться",
|
||||
subscriptions: "Подписки",
|
||||
interval: "Интервал",
|
||||
lastCheck: "Последняя проверка",
|
||||
platform: "Платформа",
|
||||
unsubscribe: "Отписаться",
|
||||
confirmUnsubscribe: "Вы уверены, что хотите отписаться от {author}?",
|
||||
subscribedSuccessfully: "Успешно подписаны",
|
||||
unsubscribedSuccessfully: "Успешно отписаны",
|
||||
subscriptionAlreadyExists: "Вы уже подписаны на этого автора.",
|
||||
minutes: "минуты",
|
||||
never: "Никогда",
|
||||
};
|
||||
|
||||
@@ -277,4 +277,22 @@ export const zh = {
|
||||
addToQueue: "添加到队列",
|
||||
batchTasksAdded: "已添加 {count} 个任务",
|
||||
addBatchTasks: "添加批量任务",
|
||||
|
||||
// Subscriptions
|
||||
subscribeToAuthor: "订阅作者",
|
||||
subscribeConfirmationMessage: "您确定要订阅 {author} 吗?",
|
||||
subscribeDescription: "系统将自动检查此作者的新视频并下载。",
|
||||
checkIntervalMinutes: "检查间隔(分钟)",
|
||||
subscribe: "订阅",
|
||||
subscriptions: "订阅",
|
||||
interval: "间隔",
|
||||
lastCheck: "上次检查",
|
||||
platform: "平台",
|
||||
unsubscribe: "取消订阅",
|
||||
confirmUnsubscribe: "您确定要取消订阅 {author} 吗?",
|
||||
subscribedSuccessfully: "订阅成功",
|
||||
unsubscribedSuccessfully: "取消订阅成功",
|
||||
subscriptionAlreadyExists: "您已订阅此作者。",
|
||||
minutes: "分钟",
|
||||
never: "从未",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user