diff --git a/backend/drizzle/0003_puzzling_energizer.sql b/backend/drizzle/0003_puzzling_energizer.sql new file mode 100644 index 0000000..86212f0 --- /dev/null +++ b/backend/drizzle/0003_puzzling_energizer.sql @@ -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' +); \ No newline at end of file diff --git a/backend/drizzle/meta/0003_snapshot.json b/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..69b6f27 --- /dev/null +++ b/backend/drizzle/meta/0003_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 89eeace..59eabf9 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1764190450949, "tag": "0002_romantic_colossus", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1764631012929, + "tag": "0003_puzzling_energizer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 8f936df..7b2667e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 566cc69..f7b365e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/controllers/subscriptionController.ts b/backend/src/controllers/subscriptionController.ts new file mode 100644 index 0000000..eec43ac --- /dev/null +++ b/backend/src/controllers/subscriptionController.ts @@ -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' }); + } +}; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 988adcb..8993bb9 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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'), +}); diff --git a/backend/src/routes/api.ts b/backend/src/routes/api.ts index 6276aa5..8987249 100644 --- a/backend/src/routes/api.ts +++ b/backend/src/routes/api.ts @@ -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; diff --git a/backend/src/server.ts b/backend/src/server.ts index 93598eb..a5a83f1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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(); diff --git a/backend/src/services/downloaders/YtDlpDownloader.ts b/backend/src/services/downloaders/YtDlpDownloader.ts index 1de5748..bb25aa8 100644 --- a/backend/src/services/downloaders/YtDlpDownloader.ts +++ b/backend/src/services/downloaders/YtDlpDownloader.ts @@ -106,6 +106,56 @@ export class YtDlpDownloader { } } + // Get the latest video URL from a channel + static async getLatestVideoUrl(channelUrl: string): Promise { + 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