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(

View File

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

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

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

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