feat: add youtube playlist download feature

This commit is contained in:
Peifan Li
2025-12-26 17:10:31 -05:00
parent 99187245e5
commit e5fcf665a5
30 changed files with 2194 additions and 236 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `continuous_download_tasks` ADD `collection_id` text;

View 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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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,
});
};

View File

@@ -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"
});
}
};

View File

@@ -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.

View File

@@ -71,6 +71,10 @@ router.get(
"/check-bilibili-collection",
asyncHandler(videoDownloadController.checkBilibiliCollection)
);
router.get(
"/check-playlist",
asyncHandler(videoDownloadController.checkPlaylist)
);
// Download management
router.post(
@@ -137,6 +141,10 @@ router.delete(
"/subscriptions/tasks/:id/delete",
asyncHandler(subscriptionController.deleteContinuousDownloadTask)
);
router.post(
"/subscriptions/tasks/playlist",
asyncHandler(subscriptionController.createPlaylistTask)
);
// Cloud storage routes
router.get(

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -53,6 +53,7 @@ export {
getCollectionById,
getCollectionByVideoId,
getCollectionByName,
generateUniqueCollectionName,
saveCollection,
atomicUpdateCollection,
deleteCollection,

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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 }}>

View File

@@ -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>

View File

@@ -42,6 +42,7 @@ const Header: React.FC<HeaderProps> = ({
const [websiteName, setWebsiteName] = useState('MyTube');
const [isScrolled, setIsScrolled] = useState<boolean>(false);
const [infiniteScroll, setInfiniteScroll] = useState<boolean>(false);
const [hasActiveSubscriptions, setHasActiveSubscriptions] = useState<boolean>(false);
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
@@ -58,6 +59,45 @@ const Header: React.FC<HeaderProps> = ({
console.log('Header props:', { activeDownloads, queuedDownloads });
}, [activeDownloads, queuedDownloads]);
// Check for active subscriptions and tasks
useEffect(() => {
if (visitorMode) {
setHasActiveSubscriptions(false);
return;
}
const checkActiveSubscriptions = async () => {
try {
const API_URL = import.meta.env.VITE_API_URL;
const axios = await import('axios');
// Fetch subscriptions and tasks
const [subscriptionsRes, tasksRes] = await Promise.all([
axios.default.get(`${API_URL}/subscriptions`).catch(() => ({ data: [] })),
axios.default.get(`${API_URL}/subscriptions/tasks`).catch(() => ({ data: [] }))
]);
const subscriptions = subscriptionsRes.data || [];
const tasks = tasksRes.data || [];
// Check if there are active subscriptions or active tasks
const hasActiveTasks = tasks.some((task: any) =>
task.status === 'active' || task.status === 'paused'
);
setHasActiveSubscriptions(subscriptions.length > 0 || hasActiveTasks);
} catch (error) {
console.error('Error checking subscriptions:', error);
setHasActiveSubscriptions(false);
}
};
checkActiveSubscriptions();
// Poll every 10 seconds to update indicator
const interval = setInterval(checkActiveSubscriptions, 10000);
return () => clearInterval(interval);
}, [visitorMode]);
useEffect(() => {
// Fetch settings to get website name and infinite scroll setting
const fetchSettings = async () => {
@@ -82,7 +122,7 @@ const Header: React.FC<HeaderProps> = ({
// Scroll detection - for mobile always, for desktop when infinite scroll is enabled on home page
useEffect(() => {
const shouldDetectScroll = isMobile || (infiniteScroll && isHomePage);
if (!shouldDetectScroll) {
setIsScrolled(false);
return;
@@ -262,6 +302,7 @@ const Header: React.FC<HeaderProps> = ({
onDownloadsClose={handleDownloadsClose}
onManageClick={handleManageClick}
onManageClose={handleManageClose}
hasActiveSubscriptions={hasActiveSubscriptions}
/>
<IconButton onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
<MenuIcon />
@@ -297,6 +338,7 @@ const Header: React.FC<HeaderProps> = ({
onDownloadsClose={handleDownloadsClose}
onManageClick={handleManageClick}
onManageClose={handleManageClose}
hasActiveSubscriptions={hasActiveSubscriptions}
/>
</Box>
</>
@@ -345,14 +387,14 @@ const Header: React.FC<HeaderProps> = ({
/>
{/* Scroll to top button - mobile always, desktop when infinite scroll is enabled on home page */}
<Slide
direction="up"
<Slide
direction="up"
in={
isScrolled &&
!isSettingsPage &&
isScrolled &&
!isSettingsPage &&
(isMobile || (infiniteScroll && isHomePage))
}
mountOnEnter
}
mountOnEnter
unmountOnExit
>
<Fab
@@ -367,9 +409,9 @@ const Header: React.FC<HeaderProps> = ({
bottom: 16,
left: 16,
zIndex: (theme) => theme.zIndex.speedDial,
display: {
xs: 'flex',
md: (infiniteScroll && isHomePage) ? 'flex' : 'none'
display: {
xs: 'flex',
md: (infiniteScroll && isHomePage) ? 'flex' : 'none'
},
opacity: 0.8,
'&:hover': {

View File

@@ -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();
});
});

View File

@@ -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();
});

View File

@@ -17,7 +17,7 @@ interface BilibiliPartsInfo {
videosNumber: number;
title: string;
url: string;
type: 'parts' | 'collection' | 'series';
type: 'parts' | 'collection' | 'series' | 'playlist';
collectionInfo: any;
}
@@ -156,6 +156,36 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false, skipPartsCheck = false, forceDownload = false): Promise<any> => {
try {
// Check for YouTube playlist URL (must check before channel check)
const playlistRegex = /[?&]list=([a-zA-Z0-9_-]+)/;
if (playlistRegex.test(videoUrl) && !skipCollectionCheck) {
setIsCheckingParts(true);
try {
const playlistResponse = await axios.get(`${API_URL}/check-playlist`, {
params: { url: videoUrl }
});
if (playlistResponse.data.success) {
const { title, videoCount } = playlistResponse.data;
setBilibiliPartsInfo({
videosNumber: videoCount,
title: title,
url: videoUrl,
type: 'playlist',
collectionInfo: null
});
setShowBilibiliPartsModal(true);
setIsCheckingParts(false);
return { success: true };
}
} catch (err) {
console.error('Error checking playlist:', err);
// Continue with normal download if check fails
} finally {
setIsCheckingParts(false);
}
}
// Check for YouTube channel URL
// Regex for: @username, channel/ID, user/username, c/customURL
const channelRegex = /youtube\.com\/(?:@|channel\/|user\/|c\/)/;
@@ -233,9 +263,9 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
}
// Normal download flow
const response = await axios.post(`${API_URL}/download`, {
const response = await axios.post(`${API_URL}/download`, {
youtubeUrl: videoUrl,
forceDownload: forceDownload
forceDownload: forceDownload
});
// Check if video was skipped (already exists or previously deleted)
@@ -272,7 +302,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return {
success: false,
error: err.response?.data?.error || 'Failed to download video. Please try again.'
error: err.response?.data?.error || t('failedToDownloadVideo')
};
}
};
@@ -283,6 +313,26 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setShowBilibiliPartsModal(false);
const isCollection = bilibiliPartsInfo.type === 'collection' || bilibiliPartsInfo.type === 'series';
const isPlaylist = bilibiliPartsInfo.type === 'playlist';
// Handle playlist differently - create continuous download task
if (isPlaylist) {
const response = await axios.post(`${API_URL}/subscriptions/tasks/playlist`, {
playlistUrl: bilibiliPartsInfo.url,
collectionName: collectionName || bilibiliPartsInfo.title
});
// Trigger immediate status check
checkBackendDownloadStatus();
// If a collection was created, refresh collections
if (response.data.collectionId) {
await fetchCollections();
}
showSnackbar(t('playlistDownloadStarted'));
return { success: true };
}
const response = await axios.post(`${API_URL}/download`, {
youtubeUrl: bilibiliPartsInfo.url,
@@ -307,7 +357,7 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return {
success: false,
error: err.response?.data?.error || 'Failed to download. Please try again.'
error: err.response?.data?.error || t('failedToDownload')
};
}
};

View File

@@ -14,8 +14,9 @@ import {
TableRow,
Typography
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import ConfirmationModal from '../components/ConfirmationModal';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
@@ -52,48 +53,39 @@ interface ContinuousDownloadTask {
updatedAt?: number;
completedAt?: number;
error?: string;
playlistName?: string;
}
const SubscriptionsPage: React.FC = () => {
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const { visitorMode } = useVisitorMode();
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [tasks, setTasks] = useState<ContinuousDownloadTask[]>([]);
const [isUnsubscribeModalOpen, setIsUnsubscribeModalOpen] = useState(false);
const [selectedSubscription, setSelectedSubscription] = useState<{ id: string; author: string } | null>(null);
const [isCancelTaskModalOpen, setIsCancelTaskModalOpen] = useState(false);
const [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<ContinuousDownloadTask | null>(null);
useEffect(() => {
fetchSubscriptions();
fetchTasks();
// Poll for task updates every 5 seconds
const interval = setInterval(() => {
fetchTasks();
}, 5000);
return () => clearInterval(interval);
}, []);
const fetchSubscriptions = async () => {
try {
// Use React Query for better caching and memory management
const { data: subscriptions = [], refetch: refetchSubscriptions } = useQuery({
queryKey: ['subscriptions'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/subscriptions`);
setSubscriptions(response.data);
} catch (error) {
console.error('Error fetching subscriptions:', error);
showSnackbar(t('error'));
}
};
return response.data as Subscription[];
},
refetchInterval: 30000, // Refetch every 30 seconds (less frequent)
staleTime: 10000, // Consider data fresh for 10 seconds
});
const fetchTasks = async () => {
try {
const { data: tasks = [], refetch: refetchTasks } = useQuery({
queryKey: ['subscriptionTasks'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/subscriptions/tasks`);
setTasks(response.data);
} catch (error) {
console.error('Error fetching tasks:', error);
}
};
return response.data as ContinuousDownloadTask[];
},
refetchInterval: 10000, // Poll every 10 seconds
staleTime: 5000, // Consider data fresh for 5 seconds
});
const handleUnsubscribeClick = (id: string, author: string) => {
setSelectedSubscription({ id, author });
@@ -106,7 +98,7 @@ const SubscriptionsPage: React.FC = () => {
try {
await axios.delete(`${API_URL}/subscriptions/${selectedSubscription.id}`);
showSnackbar(t('unsubscribedSuccessfully'));
fetchSubscriptions();
refetchSubscriptions();
} catch (error) {
console.error('Error unsubscribing:', error);
showSnackbar(t('error'));
@@ -132,7 +124,7 @@ const SubscriptionsPage: React.FC = () => {
try {
await axios.delete(`${API_URL}/subscriptions/tasks/${selectedTask.id}`);
showSnackbar(t('taskCancelled'));
fetchTasks();
refetchTasks();
} catch (error) {
console.error('Error cancelling task:', error);
showSnackbar(t('error'));
@@ -153,7 +145,7 @@ const SubscriptionsPage: React.FC = () => {
try {
await axios.delete(`${API_URL}/subscriptions/tasks/${selectedTask.id}/delete`);
showSnackbar(t('taskDeleted'));
fetchTasks();
refetchTasks();
} catch (error) {
console.error('Error deleting task:', error);
showSnackbar(t('error'));
@@ -239,7 +231,7 @@ const SubscriptionsPage: React.FC = () => {
<Table>
<TableHead>
<TableRow>
<TableCell>{t('author')}</TableCell>
<TableCell>{t('authorOrPlaylist')}</TableCell>
<TableCell>{t('platform')}</TableCell>
<TableCell>{t('status')}</TableCell>
<TableCell>{t('progress')}</TableCell>
@@ -250,9 +242,9 @@ const SubscriptionsPage: React.FC = () => {
</TableRow>
</TableHead>
<TableBody>
{tasks.map((task) => (
{tasks.slice().reverse().map((task) => (
<TableRow key={task.id}>
<TableCell>{task.author}</TableCell>
<TableCell>{task.playlistName || task.author}</TableCell>
<TableCell>{task.platform}</TableCell>
<TableCell>
<Typography
@@ -261,8 +253,8 @@ const SubscriptionsPage: React.FC = () => {
task.status === 'completed'
? 'success.main'
: task.status === 'cancelled'
? 'error.main'
: 'info.main'
? 'error.main'
: 'info.main'
}
>
{t(`taskStatus${task.status.charAt(0).toUpperCase() + task.status.slice(1)}` as TranslationKey)}

View File

@@ -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: "بدأ تنزيل قائمة التشغيل",
};

View File

@@ -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",
};

View File

@@ -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.",
};

View File

@@ -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",
};

View File

@@ -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é",
};

View File

@@ -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: "プレイリストのダウンロードが開始されました",
};

View File

@@ -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: "재생 목록 다운로드가 시작되었습니다",
};

View File

@@ -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",
};

View File

@@ -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: "Скачивание плейлиста началось",
};

View File

@@ -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: "播放列表下载已开始",
};