feat: subscription for youtube platfrom

This commit is contained in:
Peifan Li
2025-12-01 22:51:39 -05:00
parent 35f87e4e53
commit 102dd3d52d
27 changed files with 1466 additions and 40 deletions

View File

@@ -0,0 +1,11 @@
CREATE TABLE `subscriptions` (
`id` text PRIMARY KEY NOT NULL,
`author` text NOT NULL,
`author_url` text NOT NULL,
`interval` integer NOT NULL,
`last_video_link` text,
`last_check` integer,
`download_count` integer DEFAULT 0,
`created_at` integer NOT NULL,
`platform` text DEFAULT 'YouTube'
);

View File

@@ -0,0 +1,581 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e34144d1-add0-4bb0-b9d3-852c5fa0384e",
"prevId": "a4f15b55-7d41-46eb-a976-c89e80c42797",
"tables": {
"collection_videos": {
"name": "collection_videos",
"columns": {
"collection_id": {
"name": "collection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"collection_videos_collection_id_collections_id_fk": {
"name": "collection_videos_collection_id_collections_id_fk",
"tableFrom": "collection_videos",
"tableTo": "collections",
"columnsFrom": [
"collection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"collection_videos_video_id_videos_id_fk": {
"name": "collection_videos_video_id_videos_id_fk",
"tableFrom": "collection_videos",
"tableTo": "videos",
"columnsFrom": [
"video_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"collection_videos_collection_id_video_id_pk": {
"columns": [
"collection_id",
"video_id"
],
"name": "collection_videos_collection_id_video_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections": {
"name": "collections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"downloads": {
"name": "downloads",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_size": {
"name": "downloaded_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"speed": {
"name": "speed",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"interval": {
"name": "interval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_video_link": {
"name": "last_video_link",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_check": {
"name": "last_check",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"download_count": {
"name": "download_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'YouTube'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"videos": {
"name": "videos",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_filename": {
"name": "video_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_filename": {
"name": "thumbnail_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"part_number": {
"name": "part_number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_parts": {
"name": "total_parts",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"series_title": {
"name": "series_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1764190450949,
"tag": "0002_romantic_colossus",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1764631012929,
"tag": "0003_puzzling_energizer",
"breakpoints": true
}
]
}

View File

@@ -20,8 +20,9 @@
"express": "^4.18.2",
"fs-extra": "^11.2.0",
"multer": "^1.4.5-lts.1",
"path": "^0.12.7",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
"uuid": "^13.0.0",
"youtube-dl-exec": "^2.4.17"
},
"devDependencies": {
@@ -32,7 +33,9 @@
"@types/fs-extra": "^11.0.4",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.7",
"nodemon": "^3.0.3",
@@ -1788,6 +1791,13 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -1859,6 +1869,13 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -5358,6 +5375,15 @@
"node": ">=10"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
@@ -5700,16 +5726,6 @@
"node": ">= 0.8"
}
},
"node_modules/path": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
"license": "MIT",
"dependencies": {
"process": "^0.11.1",
"util": "^0.10.3"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5888,15 +5904,6 @@
"node": ">=6"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -7309,27 +7316,12 @@
"node": ">= 0.8"
}
},
"node_modules/util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
"license": "MIT",
"dependencies": {
"inherits": "2.0.3"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/util/node_modules/inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
"license": "ISC"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -7339,6 +7331,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -26,7 +26,9 @@
"express": "^4.18.2",
"fs-extra": "^11.2.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
"uuid": "^13.0.0",
"youtube-dl-exec": "^2.4.17"
},
"devDependencies": {
@@ -37,7 +39,9 @@
"@types/fs-extra": "^11.0.4",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.7",
"nodemon": "^3.0.3",

View File

@@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import { subscriptionService } from '../services/subscriptionService';
export const createSubscription = async (req: Request, res: Response) => {
try {
const { url, interval } = req.body;
console.log('Creating subscription:', { url, interval, body: req.body });
if (!url || !interval) {
return res.status(400).json({ error: 'URL and interval are required' });
}
const subscription = await subscriptionService.subscribe(url, parseInt(interval));
res.status(201).json(subscription);
} catch (error: any) {
console.error('Error creating subscription:', error);
if (error.message === 'Subscription already exists') {
return res.status(409).json({ error: 'Subscription already exists' });
}
res.status(500).json({ error: error.message || 'Failed to create subscription' });
}
};
export const getSubscriptions = async (req: Request, res: Response) => {
try {
const subscriptions = await subscriptionService.listSubscriptions();
res.json(subscriptions);
} catch (error) {
console.error('Error fetching subscriptions:', error);
res.status(500).json({ error: 'Failed to fetch subscriptions' });
}
};
export const deleteSubscription = async (req: Request, res: Response) => {
try {
const { id } = req.params;
await subscriptionService.unsubscribe(id);
res.status(200).json({ success: true });
} catch (error) {
console.error('Error deleting subscription:', error);
res.status(500).json({ error: 'Failed to delete subscription' });
}
};

View File

@@ -105,3 +105,15 @@ export const downloadHistory = sqliteTable('download_history', {
thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful
totalSize: text('total_size'),
});
export const subscriptions = sqliteTable('subscriptions', {
id: text('id').primaryKey(),
author: text('author').notNull(),
authorUrl: text('author_url').notNull(),
interval: integer('interval').notNull(), // Check interval in minutes
lastVideoLink: text('last_video_link'),
lastCheck: integer('last_check'), // Timestamp
downloadCount: integer('download_count').default(0),
createdAt: integer('created_at').notNull(),
platform: text('platform').default('YouTube'),
});

View File

@@ -42,4 +42,10 @@ router.post("/collections", collectionController.createCollection);
router.put("/collections/:id", collectionController.updateCollection);
router.delete("/collections/:id", collectionController.deleteCollection);
// Subscription routes
import * as subscriptionController from "../controllers/subscriptionController";
router.post("/subscriptions", subscriptionController.createSubscription);
router.get("/subscriptions", subscriptionController.getSubscriptions);
router.delete("/subscriptions/:id", subscriptionController.deleteSubscription);
export default router;

View File

@@ -44,6 +44,11 @@ app.use('/api/settings', settingsRoutes);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
// Start subscription scheduler
import("./services/subscriptionService").then(({ subscriptionService }) => {
subscriptionService.startScheduler();
}).catch(err => console.error("Failed to start subscription service:", err));
// Run duration backfill in background
import("./services/metadataService").then(service => {
service.backfillDurations();

View File

@@ -106,6 +106,56 @@ export class YtDlpDownloader {
}
}
// Get the latest video URL from a channel
static async getLatestVideoUrl(channelUrl: string): Promise<string | null> {
try {
console.log("Fetching latest video for channel:", channelUrl);
// Append /videos to channel URL to ensure we get videos and not the channel tab
let targetUrl = channelUrl;
if (channelUrl.includes('youtube.com/') && !channelUrl.includes('/videos') && !channelUrl.includes('/shorts') && !channelUrl.includes('/streams')) {
// Check if it looks like a channel URL
if (channelUrl.includes('/@') || channelUrl.includes('/channel/') || channelUrl.includes('/c/') || channelUrl.includes('/user/')) {
targetUrl = `${channelUrl}/videos`;
console.log("Modified channel URL to:", targetUrl);
}
}
// Use yt-dlp to get the first video in the channel (playlist)
const result = await youtubedl(targetUrl, {
dumpSingleJson: true,
playlistEnd: 5,
noWarnings: true,
flatPlaylist: true, // We only need the ID/URL, not full info
} as any);
// If it's a playlist/channel, 'entries' will contain the videos
if ((result as any).entries && (result as any).entries.length > 0) {
// Iterate through entries to find a valid video
// Sometimes the first entry is the channel/tab itself (e.g. id starts with UC)
for (const entry of (result as any).entries) {
// Skip entries that look like channel IDs (start with UC and are 24 chars)
// or entries without a title/url that look like metadata
if (entry.id && entry.id.startsWith('UC') && entry.id.length === 24) {
continue;
}
const videoId = entry.id;
if (videoId) {
return `https://www.youtube.com/watch?v=${videoId}`;
}
if (entry.url) {
return entry.url;
}
}
}
return null;
} catch (error) {
console.error("Error fetching latest video URL:", error);
return null;
}
}
// Download video
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
console.log("Detected URL:", videoUrl);

View File

@@ -0,0 +1,169 @@
import { eq } from 'drizzle-orm';
import cron, { ScheduledTask } from 'node-cron';
import { v4 as uuidv4 } from 'uuid';
import { db } from '../db';
import { subscriptions } from '../db/schema';
import { downloadYouTubeVideo } from './downloadService';
import { YtDlpDownloader } from './downloaders/YtDlpDownloader';
export interface Subscription {
id: string;
author: string;
authorUrl: string;
interval: number;
lastVideoLink?: string;
lastCheck?: number;
downloadCount: number;
createdAt: number;
platform: string;
}
export class SubscriptionService {
private static instance: SubscriptionService;
private checkTask: ScheduledTask | null = null;
private constructor() { }
public static getInstance(): SubscriptionService {
if (!SubscriptionService.instance) {
SubscriptionService.instance = new SubscriptionService();
}
return SubscriptionService.instance;
}
async subscribe(authorUrl: string, interval: number): Promise<Subscription> {
// Validate URL (basic check)
if (!authorUrl.includes('youtube.com')) {
throw new Error('Invalid YouTube URL');
}
// Check if already subscribed
const existing = await db.select().from(subscriptions).where(eq(subscriptions.authorUrl, authorUrl));
if (existing.length > 0) {
throw new Error('Subscription already exists');
}
// Extract author from URL if possible
let authorName = 'Unknown Author';
const match = authorUrl.match(/youtube\.com\/(@[^\/]+)/);
if (match && match[1]) {
authorName = match[1];
} else {
// Fallback: try to extract from other URL formats
const parts = authorUrl.split('/');
if (parts.length > 0) {
const lastPart = parts[parts.length - 1];
if (lastPart) authorName = lastPart;
}
}
// We skip heavy getVideoInfo here to ensure fast response.
// The scheduler will eventually fetch new videos and we can update author name then if needed.
let lastVideoLink = '';
const newSubscription: Subscription = {
id: uuidv4(),
author: authorName,
authorUrl,
interval,
lastVideoLink,
lastCheck: Date.now(),
downloadCount: 0,
createdAt: Date.now(),
platform: 'YouTube'
};
await db.insert(subscriptions).values(newSubscription);
return newSubscription;
}
async unsubscribe(id: string): Promise<void> {
await db.delete(subscriptions).where(eq(subscriptions.id, id));
}
async listSubscriptions(): Promise<Subscription[]> {
// @ts-ignore - Drizzle type inference might be tricky with raw select sometimes, but this should be fine.
// Actually, db.select().from(subscriptions) returns the inferred type.
return await db.select().from(subscriptions);
}
async checkSubscriptions(): Promise<void> {
// console.log('Checking subscriptions...'); // Too verbose
const allSubs = await this.listSubscriptions();
for (const sub of allSubs) {
const now = Date.now();
const lastCheck = sub.lastCheck || 0;
const intervalMs = sub.interval * 60 * 1000;
if (now - lastCheck >= intervalMs) {
try {
console.log(`Checking subscription for ${sub.author}...`);
// 1. Fetch latest video link
// We need a robust way to get the latest video.
// We can use `yt-dlp --print webpage_url --playlist-end 1 "channel_url"`
// We'll need to expose a method in `downloadService` or `YtDlpDownloader` for this.
// For now, let's assume `getLatestVideoUrl` exists.
const latestVideoUrl = await this.getLatestVideoUrl(sub.authorUrl);
if (latestVideoUrl && latestVideoUrl !== sub.lastVideoLink) {
console.log(`New video found for ${sub.author}: ${latestVideoUrl}`);
// 2. Download the video
// We use `downloadYouTubeVideo` from downloadService`.
// We might want to associate this download with the subscription for tracking?
// The requirement says "update last_video_link value".
await downloadYouTubeVideo(latestVideoUrl);
// 3. Update subscription record
await db.update(subscriptions)
.set({
lastVideoLink: latestVideoUrl,
lastCheck: now,
downloadCount: (sub.downloadCount || 0) + 1
})
.where(eq(subscriptions.id, sub.id));
} else {
// Just update lastCheck
await db.update(subscriptions)
.set({ lastCheck: now })
.where(eq(subscriptions.id, sub.id));
}
} catch (error) {
console.error(`Error checking subscription for ${sub.author}:`, error);
}
}
}
}
startScheduler() {
if (this.checkTask) {
this.checkTask.stop();
}
// Run every minute
this.checkTask = cron.schedule('* * * * *', () => {
this.checkSubscriptions();
});
console.log('Subscription scheduler started (node-cron).');
}
// Helper to get latest video URL.
// This should probably be in YtDlpDownloader, but for now we can implement it here using a similar approach.
// We need to import `exec` or similar to run yt-dlp.
// Since `YtDlpDownloader` is in `services/downloaders`, we should probably add a method there.
// But to keep it self-contained for now, I'll assume we can add it to `YtDlpDownloader` later or mock it.
// Let's try to use `YtDlpDownloader.getLatestVideoUrl` if we can add it.
// For now, I will implement a placeholder that uses `YtDlpDownloader`'s internal logic if possible,
// or just calls `getVideoInfo` and hopes it works for channels (it might not give the *latest* video URL directly).
// BETTER APPROACH: Add `getLatestVideoUrl` to `YtDlpDownloader` class.
// I will do that in a separate step. For now, I'll define the interface.
private async getLatestVideoUrl(channelUrl: string): Promise<string | null> {
return await YtDlpDownloader.getLatestVideoUrl(channelUrl);
}
}
export const subscriptionService = SubscriptionService.getInstance();

View File

@@ -1,8 +1,7 @@
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
import { useEffect, useMemo, useState } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import './App.css';
import AnimatedRoutes from './components/AnimatedRoutes';
import BilibiliPartsModal from './components/BilibiliPartsModal';
import Footer from './components/Footer';
import Header from './components/Header';
@@ -12,7 +11,15 @@ import { DownloadProvider, useDownload } from './contexts/DownloadContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { SnackbarProvider } from './contexts/SnackbarContext';
import { VideoProvider, useVideo } from './contexts/VideoContext';
import AuthorVideos from './pages/AuthorVideos';
import CollectionPage from './pages/CollectionPage';
import DownloadPage from './pages/DownloadPage';
import Home from './pages/Home';
import LoginPage from './pages/LoginPage';
import ManagePage from './pages/ManagePage';
import SettingsPage from './pages/SettingsPage';
import SubscriptionsPage from './pages/SubscriptionsPage';
import VideoPlayer from './pages/VideoPlayer';
import getTheme from './theme';
function AppContent() {
@@ -97,7 +104,16 @@ function AppContent() {
/>
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
<AnimatedRoutes />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/manage" element={<ManagePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/downloads" element={<DownloadPage />} />
<Route path="/collections/:id" element={<CollectionPage />} />
<Route path="/author/:name" element={<AuthorVideos />} />
<Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/subscriptions" element={<SubscriptionsPage />} />
</Routes>
</Box>
<Footer />

View File

@@ -0,0 +1,54 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Typography
} from '@mui/material';
import React from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface AlertModalProps {
open: boolean;
onClose: () => void;
title: string;
message: string;
}
const AlertModal: React.FC<AlertModalProps> = ({ open, onClose, title, message }) => {
const { t } = useLanguage();
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
slotProps={{
paper: {
sx: { borderRadius: 2 }
}
}}
>
<DialogTitle sx={{ m: 0, p: 2 }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{title}
</Typography>
</DialogTitle>
<DialogContent dividers>
<DialogContentText sx={{ color: 'text.primary' }}>
{message}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} variant="contained" color="primary" autoFocus>
{t('confirm')}
</Button>
</DialogActions>
</Dialog>
);
};
export default AlertModal;

View File

@@ -6,6 +6,7 @@ import {
Menu as MenuIcon,
Search,
Settings,
Subscriptions,
VideoLibrary
} from '@mui/icons-material';
import {
@@ -330,6 +331,9 @@ const Header: React.FC<HeaderProps> = ({
<MenuItem onClick={() => { handleManageClose(); navigate('/manage'); }}>
<VideoLibrary sx={{ mr: 2 }} /> {t('manageContent')}
</MenuItem>
<MenuItem onClick={() => { handleManageClose(); navigate('/subscriptions'); }}>
<Subscriptions sx={{ mr: 2 }} /> {t('subscriptions')}
</MenuItem>
<MenuItem onClick={() => { handleManageClose(); navigate('/settings'); }}>
<Settings sx={{ mr: 2 }} /> {t('settings')}
</MenuItem>

View File

@@ -0,0 +1,97 @@
import { Close } from '@mui/icons-material';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
TextField,
Typography
} from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface SubscribeModalProps {
open: boolean;
onClose: () => void;
onConfirm: (interval: number) => void;
authorName?: string;
url: string;
}
const SubscribeModal: React.FC<SubscribeModalProps> = ({
open,
onClose,
onConfirm,
authorName,
url
}) => {
const { t } = useLanguage();
const [interval, setInterval] = useState<number>(60); // Default 60 minutes
const handleConfirm = () => {
onConfirm(interval);
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: { borderRadius: 2 }
}
}}
>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{t('subscribeToAuthor')}
</Typography>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
color: (theme) => theme.palette.grey[500],
}}
>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<DialogContentText sx={{ mb: 2, color: 'text.primary' }}>
{t('subscribeConfirmationMessage', { author: authorName || url })}
</DialogContentText>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('subscribeDescription')}
</Typography>
<TextField
autoFocus
margin="dense"
id="interval"
label={t('checkIntervalMinutes')}
type="number"
fullWidth
variant="outlined"
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
inputProps={{ min: 1 }}
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} color="inherit">
{t('cancel')}
</Button>
<Button onClick={handleConfirm} variant="contained" color="primary">
{t('subscribe')}
</Button>
</DialogActions>
</Dialog>
);
};
export default SubscribeModal;

View File

@@ -1,6 +1,8 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import AlertModal from '../components/AlertModal';
import SubscribeModal from '../components/SubscribeModal';
import { DownloadInfo } from '../types';
import { useCollection } from './CollectionContext';
import { useLanguage } from './LanguageContext';
@@ -141,6 +143,15 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
try {
// Check for YouTube channel URL
// Regex for: @username, channel/ID, user/username, c/customURL
const channelRegex = /youtube\.com\/(?:@|channel\/|user\/|c\/)/;
if (channelRegex.test(videoUrl)) {
setSubscribeUrl(videoUrl);
setShowSubscribeModal(true);
return { success: true };
}
// Check if it's a Bilibili URL
if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
setIsCheckingParts(true);
@@ -267,6 +278,31 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
};
// Subscription logic
const [showSubscribeModal, setShowSubscribeModal] = useState(false);
const [showDuplicateModal, setShowDuplicateModal] = useState(false);
const [subscribeUrl, setSubscribeUrl] = useState('');
const handleSubscribe = async (interval: number) => {
try {
await axios.post(`${API_URL}/subscriptions`, {
url: subscribeUrl,
interval
});
showSnackbar(t('subscribedSuccessfully'));
setShowSubscribeModal(false);
setSubscribeUrl('');
} catch (error: any) {
console.error('Error subscribing:', error);
if (error.response && error.response.status === 409) {
setShowSubscribeModal(false);
setShowDuplicateModal(true);
} else {
showSnackbar(t('error'));
}
}
};
return (
<DownloadContext.Provider value={{
activeDownloads,
@@ -280,6 +316,18 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
handleDownloadCurrentBilibiliPart
}}>
{children}
<SubscribeModal
open={showSubscribeModal}
onClose={() => setShowSubscribeModal(false)}
onConfirm={handleSubscribe}
url={subscribeUrl}
/>
<AlertModal
open={showDuplicateModal}
onClose={() => setShowDuplicateModal(false)}
title={t('error')}
message={t('subscriptionAlreadyExists')}
/>
</DownloadContext.Provider>
);
};

View File

@@ -0,0 +1,136 @@
import { Delete } from '@mui/icons-material';
import {
Button,
Container,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from '@mui/material';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
const API_URL = import.meta.env.VITE_API_URL;
interface Subscription {
id: string;
author: string;
authorUrl: string;
interval: number;
lastVideoLink?: string;
lastCheck?: number;
downloadCount: number;
createdAt: number;
platform: string;
}
const SubscriptionsPage: React.FC = () => {
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
useEffect(() => {
fetchSubscriptions();
}, []);
const fetchSubscriptions = async () => {
try {
const response = await axios.get(`${API_URL}/subscriptions`);
setSubscriptions(response.data);
} catch (error) {
console.error('Error fetching subscriptions:', error);
showSnackbar(t('error'));
}
};
const handleUnsubscribe = async (id: string, author: string) => {
if (!window.confirm(t('confirmUnsubscribe', { author }))) {
return;
}
try {
await axios.delete(`${API_URL}/subscriptions/${id}`);
showSnackbar(t('unsubscribedSuccessfully'));
fetchSubscriptions();
} catch (error) {
console.error('Error unsubscribing:', error);
showSnackbar(t('error'));
}
};
const formatDate = (timestamp?: number) => {
if (!timestamp) return t('never');
return new Date(timestamp).toLocaleString();
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
{t('subscriptions')}
</Typography>
<TableContainer component={Paper} sx={{ mt: 3 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('author')}</TableCell>
<TableCell>{t('platform')}</TableCell>
<TableCell>{t('interval')}</TableCell>
<TableCell>{t('lastCheck')}</TableCell>
<TableCell>{t('downloads')}</TableCell>
<TableCell align="right">{t('actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{subscriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="text.secondary" sx={{ py: 4 }}>
{t('noVideos')} {/* Reusing "No videos found" or similar if "No subscriptions" key missing */}
</Typography>
</TableCell>
</TableRow>
) : (
subscriptions.map((sub) => (
<TableRow key={sub.id}>
<TableCell>
<Button
href={sub.authorUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', justifyContent: 'flex-start', p: 0 }}
>
{sub.author}
</Button>
</TableCell>
<TableCell>{sub.platform}</TableCell>
<TableCell>{sub.interval} {t('minutes')}</TableCell>
<TableCell>{formatDate(sub.lastCheck)}</TableCell>
<TableCell>{sub.downloadCount}</TableCell>
<TableCell align="right">
<IconButton
color="error"
onClick={() => handleUnsubscribe(sub.id, sub.author)}
title={t('unsubscribe')}
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Container>
);
};
export default SubscriptionsPage;

View File

@@ -274,6 +274,24 @@ export const ar = {
batchDownloadDescription: "الصق روابط متعددة أدناه، واحد في كل سطر.",
urls: "الروابط",
addToQueue: "إضافة إلى قائمة الانتظار",
batchTasksAdded: "تمت إضافة {count} مهام",
batchTasksAdded: "تمت إضافة {count} مهمة",
addBatchTasks: "إضافة مهام مجمعة",
// Subscriptions
subscribeToAuthor: "الاشتراك في المؤلف",
subscribeConfirmationMessage: "هل تريد الاشتراك في {author}؟",
subscribeDescription: "سيقوم النظام تلقائيًا بالتحقق من مقاطع الفيديو الجديدة لهذا المؤلف وتنزيلها.",
checkIntervalMinutes: "فاصل التحقق (دقائق)",
subscribe: "اشتراك",
subscriptions: "الاشتراكات",
interval: "الفاصل الزمني",
lastCheck: "آخر تحقق",
platform: "المنصة",
unsubscribe: "إلغاء الاشتراك",
confirmUnsubscribe: "هل أنت متأكد أنك تريد إلغاء الاشتراك من {author}؟",
subscribedSuccessfully: "تم الاشتراك بنجاح",
unsubscribedSuccessfully: "تم إلغاء الاشتراك بنجاح",
subscriptionAlreadyExists: "أنت مشترك بالفعل في هذا المؤلف.",
minutes: "دقائق",
never: "أبداً",
};

View File

@@ -147,4 +147,22 @@ export const de = {
addToQueue: "Zur Warteschlange hinzufügen",
batchTasksAdded: "{count} Aufgaben hinzugefügt",
addBatchTasks: "Stapelaufgaben hinzufügen",
// Subscriptions
subscribeToAuthor: "Autor abonnieren",
subscribeConfirmationMessage: "Möchten Sie {author} abonnieren?",
subscribeDescription: "Das System prüft automatisch auf neue Videos dieses Autors und lädt sie herunter.",
checkIntervalMinutes: "Prüfintervall (Minuten)",
subscribe: "Abonnieren",
subscriptions: "Abonnements",
interval: "Intervall",
lastCheck: "Letzte Prüfung",
platform: "Plattform",
unsubscribe: "Deabonnieren",
confirmUnsubscribe: "Sind Sie sicher, dass Sie {author} deabonnieren möchten?",
subscribedSuccessfully: "Erfolgreich abonniert",
unsubscribedSuccessfully: "Erfolgreich deabonniert",
subscriptionAlreadyExists: "Sie haben diesen Autor bereits abonniert.",
minutes: "Minuten",
never: "Nie",
};

View File

@@ -277,4 +277,22 @@ export const en = {
addToQueue: "Add to Queue",
batchTasksAdded: "{count} tasks added",
addBatchTasks: "Add batch tasks",
// Subscriptions
subscribeToAuthor: "Subscribe to Author",
subscribeConfirmationMessage: "Do you want to subscribe to {author}?",
subscribeDescription: "The system will automatically check for new videos from this author and download them.",
checkIntervalMinutes: "Check Interval (minutes)",
subscribe: "Subscribe",
subscriptions: "Subscriptions",
interval: "Interval",
lastCheck: "Last Check",
platform: "Platform",
unsubscribe: "Unsubscribe",
confirmUnsubscribe: "Are you sure you want to unsubscribe from {author}?",
subscribedSuccessfully: "Subscribed successfully",
unsubscribedSuccessfully: "Unsubscribed successfully",
subscriptionAlreadyExists: "You are already subscribed to this author.",
minutes: "minutes",
never: "Never",
};

View File

@@ -144,4 +144,22 @@ export const es = {
addToQueue: "Añadir a la cola",
batchTasksAdded: "{count} tareas añadidas",
addBatchTasks: "Añadir tareas por lotes",
// Subscriptions
subscribeToAuthor: "Suscribirse al autor",
subscribeConfirmationMessage: "¿Quieres suscribirte a {author}?",
subscribeDescription: "El sistema comprobará automáticamente si hay nuevos vídeos de este autor y los descargará.",
checkIntervalMinutes: "Intervalo de comprobación (minutos)",
subscribe: "Suscribirse",
subscriptions: "Suscripciones",
interval: "Intervalo",
lastCheck: "Última comprobación",
platform: "Plataforma",
unsubscribe: "Darse de baja",
confirmUnsubscribe: "¿Estás seguro de que quieres darte de baja de {author}?",
subscribedSuccessfully: "Suscrito con éxito",
unsubscribedSuccessfully: "Dado de baja con éxito",
subscriptionAlreadyExists: "Ya estás suscrito a este autor.",
minutes: "minutos",
never: "Nunca",
};

View File

@@ -274,4 +274,22 @@ export const fr = {
addToQueue: "Ajouter à la file d'attente",
batchTasksAdded: "{count} tâches ajoutées",
addBatchTasks: "Ajouter des tâches par lot",
// Subscriptions
subscribeToAuthor: "S'abonner à l'auteur",
subscribeConfirmationMessage: "Voulez-vous vous abonner à {author} ?",
subscribeDescription: "Le système vérifiera automatiquement les nouvelles vidéos de cet auteur et les téléchargera.",
checkIntervalMinutes: "Intervalle de vérification (minutes)",
subscribe: "S'abonner",
subscriptions: "Abonnements",
interval: "Intervalle",
lastCheck: "Dernière vérification",
platform: "Plateforme",
unsubscribe: "Se désabonner",
confirmUnsubscribe: "Êtes-vous sûr de vouloir vous désabonner de {author} ?",
subscribedSuccessfully: "Abonné avec succès",
unsubscribedSuccessfully: "Désabonné avec succès",
subscriptionAlreadyExists: "Vous êtes déjà abonné à cet auteur.",
minutes: "minutes",
never: "Jamais",
};

View File

@@ -276,4 +276,22 @@ export const ja = {
addToQueue: "キューに追加",
batchTasksAdded: "{count} 件のタスクを追加しました",
addBatchTasks: "一括タスクを追加",
// Subscriptions
subscribeToAuthor: "著者を購読する",
subscribeConfirmationMessage: "{author} を購読しますか?",
subscribeDescription: "システムはこの著者の新しい動画を自動的にチェックしてダウンロードします。",
checkIntervalMinutes: "チェック間隔(分)",
subscribe: "購読",
subscriptions: "購読",
interval: "間隔",
lastCheck: "前回のチェック",
platform: "プラットフォーム",
unsubscribe: "購読解除",
confirmUnsubscribe: "{author} の購読を解除してもよろしいですか?",
subscribedSuccessfully: "購読しました",
unsubscribedSuccessfully: "購読を解除しました",
subscriptionAlreadyExists: "この著者はすでに購読しています。",
minutes: "分",
never: "なし",
};

View File

@@ -276,4 +276,22 @@ export const ko = {
addToQueue: "대기열에 추가",
batchTasksAdded: "{count}개의 작업이 추가되었습니다",
addBatchTasks: "일괄 작업 추가",
// Subscriptions
subscribeToAuthor: "작가 구독",
subscribeConfirmationMessage: "{author}님을 구독하시겠습니까?",
subscribeDescription: "시스템이 자동으로 이 작가의 새 동영상을 확인하고 다운로드합니다.",
checkIntervalMinutes: "확인 간격 (분)",
subscribe: "구독",
subscriptions: "구독",
interval: "간격",
lastCheck: "마지막 확인",
platform: "플랫폼",
unsubscribe: "구독 취소",
confirmUnsubscribe: "{author}님의 구독을 취소하시겠습니까?",
subscribedSuccessfully: "구독 성공",
unsubscribedSuccessfully: "구독 취소 성공",
subscriptionAlreadyExists: "이미 구독 중인 작가입니다.",
minutes: "분",
never: "없음",
};

View File

@@ -275,4 +275,22 @@ export const pt = {
addToQueue: "Adicionar à fila",
batchTasksAdded: "{count} tarefas adicionadas",
addBatchTasks: "Adicionar tarefas em lote",
// Subscriptions
subscribeToAuthor: "Inscrever-se no autor",
subscribeConfirmationMessage: "Deseja se inscrever em {author}?",
subscribeDescription: "O sistema verificará automaticamente novos vídeos deste autor e os baixará.",
checkIntervalMinutes: "Intervalo de verificação (minutos)",
subscribe: "Inscrever-se",
subscriptions: "Inscrições",
interval: "Intervalo",
lastCheck: "Última verificação",
platform: "Plataforma",
unsubscribe: "Cancelar inscrição",
confirmUnsubscribe: "Tem certeza de que deseja cancelar a inscrição de {author}?",
subscribedSuccessfully: "Inscrito com sucesso",
unsubscribedSuccessfully: "Inscrição cancelada com sucesso",
subscriptionAlreadyExists: "Você já está inscrito neste autor.",
minutes: "minutos",
never: "Nunca",
};

View File

@@ -274,6 +274,24 @@ export const ru = {
batchDownloadDescription: "Вставьте несколько URL ниже, по одному в строке.",
urls: "URL",
addToQueue: "Добавить в очередь",
batchTasksAdded: "Добавлено {count} задач",
batchTasksAdded: "Добавлено задач: {count}",
addBatchTasks: "Добавить пакетные задачи",
// Subscriptions
subscribeToAuthor: "Подписаться на автора",
subscribeConfirmationMessage: "Вы хотите подписаться на {author}?",
subscribeDescription: "Система будет автоматически проверять новые видео от этого автора и скачивать их.",
checkIntervalMinutes: "Интервал проверки (минуты)",
subscribe: "Подписаться",
subscriptions: "Подписки",
interval: "Интервал",
lastCheck: "Последняя проверка",
platform: "Платформа",
unsubscribe: "Отписаться",
confirmUnsubscribe: "Вы уверены, что хотите отписаться от {author}?",
subscribedSuccessfully: "Успешно подписаны",
unsubscribedSuccessfully: "Успешно отписаны",
subscriptionAlreadyExists: "Вы уже подписаны на этого автора.",
minutes: "минуты",
never: "Никогда",
};

View File

@@ -277,4 +277,22 @@ export const zh = {
addToQueue: "添加到队列",
batchTasksAdded: "已添加 {count} 个任务",
addBatchTasks: "添加批量任务",
// Subscriptions
subscribeToAuthor: "订阅作者",
subscribeConfirmationMessage: "您确定要订阅 {author} 吗?",
subscribeDescription: "系统将自动检查此作者的新视频并下载。",
checkIntervalMinutes: "检查间隔(分钟)",
subscribe: "订阅",
subscriptions: "订阅",
interval: "间隔",
lastCheck: "上次检查",
platform: "平台",
unsubscribe: "取消订阅",
confirmUnsubscribe: "您确定要取消订阅 {author} 吗?",
subscribedSuccessfully: "订阅成功",
unsubscribedSuccessfully: "取消订阅成功",
subscriptionAlreadyExists: "您已订阅此作者。",
minutes: "分钟",
never: "从未",
};