;
17 | activeDownloads?: DownloadInfo[];
18 | queuedDownloads?: DownloadInfo[];
19 | isSearchMode?: boolean;
20 | searchTerm?: string;
21 | onResetSearch?: () => void;
22 | collections?: Collection[];
23 | videos?: Video[];
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/version.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * MyTube Frontend Version Information
3 | */
4 |
5 | const VERSION = {
6 | number: "1.0.0",
7 | buildDate: new Date().toISOString().split("T")[0],
8 | name: "MyTube Frontend",
9 | displayVersion: function () {
10 | console.log(`
11 | ╔═══════════════════════════════════════════════╗
12 | ║ ║
13 | ║ ${this.name} ║
14 | ║ Version: ${this.number} ║
15 | ║ Build Date: ${this.buildDate} ║
16 | ║ ║
17 | ╚═══════════════════════════════════════════════╝
18 | `);
19 | },
20 | };
21 |
22 | export default VERSION;
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/frontend/src/utils/translations.ts:
--------------------------------------------------------------------------------
1 | import { ar } from './locales/ar';
2 | import { de } from './locales/de';
3 | import { en } from './locales/en';
4 | import { es } from './locales/es';
5 | import { fr } from './locales/fr';
6 | import { ja } from './locales/ja';
7 | import { ko } from './locales/ko';
8 | import { pt } from './locales/pt';
9 | import { ru } from './locales/ru';
10 | import { zh } from './locales/zh';
11 |
12 | export const translations = {
13 | en,
14 | zh,
15 | es,
16 | de,
17 | ja,
18 | fr,
19 | ko,
20 | ar,
21 | pt,
22 | ru
23 | };
24 |
25 | export type Language = 'en' | 'zh' | 'es' | 'de' | 'ja' | 'fr' | 'ko' | 'ar' | 'pt' | 'ru';
26 | export type TranslationKey = keyof typeof translations.en;
27 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import App from './App';
4 | import './index.css';
5 | import VERSION from './version';
6 |
7 | import { SnackbarProvider } from './contexts/SnackbarContext';
8 |
9 | import ConsoleManager from './utils/consoleManager';
10 |
11 | // Initialize console manager
12 | ConsoleManager.init();
13 |
14 | // Display version information
15 | VERSION.displayVersion();
16 |
17 | const rootElement = document.getElementById('root');
18 | if (rootElement) {
19 | createRoot(rootElement).render(
20 |
21 |
22 |
23 |
24 | ,
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/backend/src/config/paths.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 |
3 | // Assuming the application is started from the 'backend' directory
4 | export const ROOT_DIR: string = process.cwd();
5 |
6 | export const UPLOADS_DIR: string = path.join(ROOT_DIR, "uploads");
7 | export const VIDEOS_DIR: string = path.join(UPLOADS_DIR, "videos");
8 | export const IMAGES_DIR: string = path.join(UPLOADS_DIR, "images");
9 | export const SUBTITLES_DIR: string = path.join(UPLOADS_DIR, "subtitles");
10 | export const DATA_DIR: string = path.join(ROOT_DIR, "data");
11 |
12 | export const VIDEOS_DATA_PATH: string = path.join(DATA_DIR, "videos.json");
13 | export const STATUS_DATA_PATH: string = path.join(DATA_DIR, "status.json");
14 | export const COLLECTIONS_DATA_PATH: string = path.join(DATA_DIR, "collections.json");
15 |
--------------------------------------------------------------------------------
/backend/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | environment: 'node',
7 | coverage: {
8 | provider: 'v8',
9 | reporter: ['text', 'json', 'html'],
10 | exclude: [
11 | 'node_modules/**',
12 | 'dist/**',
13 | '**/*.config.ts',
14 | '**/__tests__/**',
15 | 'scripts/**',
16 | 'src/test_sanitize.ts',
17 | 'src/version.ts',
18 | 'src/services/downloaders/**',
19 | 'src/services/migrationService.ts',
20 | 'src/server.ts', // Entry point
21 | 'src/db/**', // Database config
22 | 'src/scripts/**', // Scripts
23 | 'src/routes/**', // Route configuration files
24 | ],
25 | },
26 | },
27 | });
28 |
--------------------------------------------------------------------------------
/frontend/src/pages/DownloadPage/CustomTabPanel.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@mui/material';
2 | import React from 'react';
3 |
4 | interface TabPanelProps {
5 | children?: React.ReactNode;
6 | index: number;
7 | value: number;
8 | }
9 |
10 | export function CustomTabPanel(props: TabPanelProps) {
11 | const { children, value, index, ...other } = props;
12 |
13 | return (
14 |
21 | {value === index && (
22 |
23 | {children}
24 |
25 | )}
26 |
27 | );
28 | }
29 |
30 |
--------------------------------------------------------------------------------
/frontend/src/utils/urlValidation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Validates a URL to prevent open redirect attacks
3 | * Only allows http/https protocols
4 | */
5 | export function isValidUrl(url: string): boolean {
6 | try {
7 | const urlObj = new URL(url);
8 | // Only allow http and https protocols
9 | return urlObj.protocol === "http:" || urlObj.protocol === "https:";
10 | } catch {
11 | return false;
12 | }
13 | }
14 |
15 | /**
16 | * Validates and sanitizes a URL for safe use in window.open
17 | * Returns null if URL is invalid
18 | */
19 | export function validateUrlForOpen(
20 | url: string | null | undefined
21 | ): string | null {
22 | if (!url) return null;
23 |
24 | if (!isValidUrl(url)) {
25 | console.warn(`Invalid URL blocked: ${url}`);
26 | return null;
27 | }
28 |
29 | return url;
30 | }
31 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '...'
17 | 3. Scroll down to '...'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Environment (please complete the following information):**
27 | - OS: [e.g. macOS, Windows]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:21-alpine AS build
2 |
3 | WORKDIR /app
4 |
5 | COPY package*.json ./
6 | RUN npm install
7 |
8 | COPY . .
9 |
10 | # Set default build-time arguments that can be overridden during build
11 | ARG VITE_API_URL=http://localhost:5551/api
12 | ARG VITE_BACKEND_URL=http://localhost:5551
13 | ENV VITE_API_URL=${VITE_API_URL}
14 | ENV VITE_BACKEND_URL=${VITE_BACKEND_URL}
15 |
16 | RUN npm run build
17 |
18 | # Production stage
19 | FROM nginx:stable-alpine
20 | COPY --from=build /app/dist /usr/share/nginx/html
21 | COPY nginx.conf /etc/nginx/conf.d/default.conf
22 | EXPOSE 5556
23 |
24 | # Add a script to replace environment variables at runtime
25 | RUN apk add --no-cache bash
26 | COPY ./entrypoint.sh /entrypoint.sh
27 | RUN chmod +x /entrypoint.sh
28 |
29 | ENTRYPOINT ["/entrypoint.sh"]
30 | CMD ["nginx", "-g", "daemon off;"]
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
13 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | MyTube - My Videos, My Rules.
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/backend/src/services/cloudStorage/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Types and interfaces for cloud storage operations
3 | */
4 |
5 | export interface CloudDriveConfig {
6 | enabled: boolean;
7 | apiUrl: string;
8 | token: string;
9 | publicUrl?: string;
10 | uploadPath: string;
11 | scanPaths?: string[];
12 | }
13 |
14 | export interface CachedSignedUrl {
15 | url: string;
16 | timestamp: number;
17 | expiresAt: number;
18 | }
19 |
20 | export interface CachedFileList {
21 | files: any[];
22 | timestamp: number;
23 | }
24 |
25 | export interface FileWithPath {
26 | file: any;
27 | path: string;
28 | }
29 |
30 | export interface FileUrlsResult {
31 | videoUrl?: string;
32 | thumbnailUrl?: string;
33 | thumbnailThumbUrl?: string;
34 | }
35 |
36 | export interface ScanResult {
37 | added: number;
38 | errors: string[];
39 | }
40 |
41 | export type FileType = "video" | "thumbnail";
42 |
43 |
--------------------------------------------------------------------------------
/backend/src/services/storageService.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * StorageService - Main entry point
3 | *
4 | * This file re-exports all functionality from the modular storageService directory
5 | * to maintain backward compatibility with existing imports.
6 | *
7 | * The actual implementation has been split into separate modules:
8 | * - types.ts - Type definitions
9 | * - initialization.ts - Database initialization and migrations
10 | * - downloadStatus.ts - Active/queued download management
11 | * - downloadHistory.ts - Download history operations
12 | * - videoDownloadTracking.ts - Duplicate download prevention
13 | * - settings.ts - Application settings
14 | * - videos.ts - Video CRUD operations
15 | * - collections.ts - Collection/playlist operations
16 | * - fileHelpers.ts - File system utilities
17 | */
18 |
19 | // Re-export everything from the modular structure
20 | export * from "./storageService/index";
21 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "ES2020",
7 | "DOM",
8 | "DOM.Iterable"
9 | ],
10 | "module": "ESNext",
11 | "skipLibCheck": true,
12 | /* Bundler mode */
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true
24 | },
25 | "include": [
26 | "src"
27 | ],
28 | "references": [
29 | {
30 | "path": "./tsconfig.node.json"
31 | }
32 | ]
33 | }
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | /* index.css */
2 | :root {
3 | color-scheme: dark;
4 | }
5 |
6 | body {
7 | margin: 0;
8 | padding: 0;
9 | box-sizing: border-box;
10 | width: 100%;
11 | overflow-x: hidden;
12 | }
13 |
14 | html {
15 | width: 100%;
16 | overflow-x: hidden;
17 | scrollbar-gutter: stable;
18 | overflow-y: scroll;
19 | }
20 |
21 | /* Scrollbar styling for dark theme */
22 | ::-webkit-scrollbar {
23 | width: 10px;
24 | height: 10px;
25 | }
26 |
27 | ::-webkit-scrollbar-track {
28 | background: #1e1e1e;
29 | }
30 |
31 | ::-webkit-scrollbar-thumb {
32 | background: #333;
33 | border-radius: 5px;
34 | }
35 |
36 | ::-webkit-scrollbar-thumb:hover {
37 | background: #444;
38 | }
39 |
40 | /* Smooth theme transition */
41 | *, *::before, *::after {
42 | transition-property: background-color, border-color, fill, stroke, box-shadow;
43 | transition-duration: 0.3s;
44 | transition-timing-function: ease-out;
45 | }
--------------------------------------------------------------------------------
/frontend/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 |
6 | export default [
7 | { ignores: ['dist'] },
8 | {
9 | files: ['**/*.{js,jsx}'],
10 | languageOptions: {
11 | ecmaVersion: 2020,
12 | globals: globals.browser,
13 | parserOptions: {
14 | ecmaVersion: 'latest',
15 | ecmaFeatures: { jsx: true },
16 | sourceType: 'module',
17 | },
18 | },
19 | plugins: {
20 | 'react-hooks': reactHooks,
21 | 'react-refresh': reactRefresh,
22 | },
23 | rules: {
24 | ...js.configs.recommended.rules,
25 | ...reactHooks.configs.recommended.rules,
26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27 | 'react-refresh/only-export-components': [
28 | 'warn',
29 | { allowConstantExport: true },
30 | ],
31 | },
32 | },
33 | ]
34 |
--------------------------------------------------------------------------------
/frontend/src/components/PageTransition.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { ReactNode } from 'react';
3 |
4 | interface PageTransitionProps {
5 | children: ReactNode;
6 | }
7 |
8 | const pageVariants = {
9 | initial: {
10 | opacity: 0,
11 | y: 20,
12 | },
13 | in: {
14 | opacity: 1,
15 | y: 0,
16 | },
17 | out: {
18 | opacity: 0,
19 | y: -20,
20 | },
21 | };
22 |
23 | const pageTransition = {
24 | type: 'tween',
25 | ease: 'anticipate',
26 | duration: 0.3,
27 | } as const;
28 |
29 | const PageTransition = ({ children }: PageTransitionProps) => {
30 | return (
31 |
39 | {children}
40 |
41 | );
42 | };
43 |
44 | export default PageTransition;
45 |
--------------------------------------------------------------------------------
/.github/workflows/master.yml:
--------------------------------------------------------------------------------
1 | name: Build and Test
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build-and-test:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [20.x]
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - name: Use Node.js ${{ matrix.node-version }}
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 | cache: 'npm'
25 |
26 | - name: Install Dependencies
27 | run: npm run install:all
28 |
29 | - name: Lint Frontend
30 | run: |
31 | cd frontend
32 | npm run lint
33 |
34 | - name: Build Frontend
35 | run: |
36 | cd frontend
37 | npm run build
38 |
39 | - name: Build Backend
40 | run: |
41 | cd backend
42 | npm run build
43 |
44 | - name: Test Backend
45 | run: |
46 | cd backend
47 | npm run test -- run
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Peifan Li
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mytube",
3 | "version": "1.6.36",
4 | "description": "Multiple platform video downloader and player application",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
8 | "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
9 | "start:frontend": "cd frontend && npm run dev",
10 | "start:backend": "cd backend && npm run start",
11 | "dev:frontend": "cd frontend && npm run dev",
12 | "dev:backend": "cd backend && npm run dev",
13 | "install:all": "npm install && cd frontend && npm install && cd ../backend && npm install",
14 | "build": "cd frontend && npm run build",
15 | "test": "concurrently \"npm run test:backend\" \"npm run test:frontend\"",
16 | "test:frontend": "cd frontend && npm run test",
17 | "test:backend": "cd backend && npm run test"
18 | },
19 | "keywords": [
20 | "youtube",
21 | "video",
22 | "downloader",
23 | "player"
24 | ],
25 | "author": "",
26 | "license": "MIT",
27 | "dependencies": {
28 | "concurrently": "^8.2.2"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/backend/drizzle/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "sqlite",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "6",
8 | "when": 1764043254513,
9 | "tag": "0000_known_guardsmen",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "6",
15 | "when": 1764182291372,
16 | "tag": "0001_worthless_blur",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "6",
22 | "when": 1764190450949,
23 | "tag": "0002_romantic_colossus",
24 | "breakpoints": true
25 | },
26 | {
27 | "idx": 3,
28 | "version": "6",
29 | "when": 1764631012929,
30 | "tag": "0003_puzzling_energizer",
31 | "breakpoints": true
32 | },
33 | {
34 | "idx": 4,
35 | "version": "6",
36 | "when": 1733644800000,
37 | "tag": "0004_video_downloads",
38 | "breakpoints": true
39 | },
40 | {
41 | "idx": 5,
42 | "version": "6",
43 | "when": 1766096471960,
44 | "tag": "0005_tired_demogoblin",
45 | "breakpoints": true
46 | }
47 | ]
48 | }
--------------------------------------------------------------------------------
/backend/src/services/downloaders/bilibili/bilibiliCookie.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs-extra";
2 | import path from "path";
3 | import { logger } from "../../../utils/logger";
4 |
5 | /**
6 | * Get cookies from cookies.txt file (Netscape format)
7 | * @returns Cookie header string or empty string if not found
8 | */
9 | export function getCookieHeader(): string {
10 | try {
11 | const { DATA_DIR } = require("../../../config/paths");
12 | const cookiesPath = path.join(DATA_DIR, "cookies.txt");
13 | if (fs.existsSync(cookiesPath)) {
14 | const content = fs.readFileSync(cookiesPath, "utf8");
15 | const lines = content.split("\n");
16 | const cookies = [];
17 | for (const line of lines) {
18 | if (line.startsWith("#") || !line.trim()) continue;
19 | const parts = line.split("\t");
20 | if (parts.length >= 7) {
21 | const name = parts[5];
22 | const value = parts[6].trim();
23 | cookies.push(`${name}=${value}`);
24 | }
25 | }
26 | return cookies.join("; ");
27 | }
28 | } catch (e) {
29 | logger.error("Error reading cookies.txt:", e);
30 | }
31 | return "";
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/components/Header/Logo.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from '@mui/material';
2 | import { Link } from 'react-router-dom';
3 | import logo from '../../assets/logo.svg';
4 |
5 | interface LogoProps {
6 | websiteName: string;
7 | onResetSearch?: () => void;
8 | }
9 |
10 | const Logo: React.FC = ({ websiteName, onResetSearch }) => {
11 | return (
12 |
13 |
14 |
15 |
16 | {websiteName}
17 |
18 | {websiteName !== 'MyTube' && (
19 |
20 | Powered by MyTube
21 |
22 | )}
23 |
24 |
25 | );
26 | };
27 |
28 | export default Logo;
29 |
30 |
--------------------------------------------------------------------------------
/backend/src/services/downloaders/bilibili/types.ts:
--------------------------------------------------------------------------------
1 | import { Video } from "../../storageService";
2 |
3 | export interface BilibiliVideoInfo {
4 | title: string;
5 | author: string;
6 | date: string;
7 | thumbnailUrl: string | null;
8 | thumbnailSaved: boolean;
9 | description?: string;
10 | error?: string;
11 | }
12 |
13 | export interface BilibiliPartsCheckResult {
14 | success: boolean;
15 | videosNumber: number;
16 | title?: string;
17 | }
18 |
19 | export interface BilibiliCollectionCheckResult {
20 | success: boolean;
21 | type: "collection" | "series" | "none";
22 | id?: number;
23 | title?: string;
24 | count?: number;
25 | mid?: number;
26 | }
27 |
28 | export interface BilibiliVideoItem {
29 | bvid: string;
30 | title: string;
31 | aid: number;
32 | }
33 |
34 | export interface BilibiliVideosResult {
35 | success: boolean;
36 | videos: BilibiliVideoItem[];
37 | }
38 |
39 | export interface DownloadResult {
40 | success: boolean;
41 | videoData?: Video;
42 | error?: string;
43 | }
44 |
45 | export interface CollectionDownloadResult {
46 | success: boolean;
47 | collectionId?: string;
48 | videosDownloaded?: number;
49 | error?: string;
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/src/components/Settings/VideoDefaultSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
2 | import React from 'react';
3 | import { useLanguage } from '../../contexts/LanguageContext';
4 | import { Settings } from '../../types';
5 |
6 | interface VideoDefaultSettingsProps {
7 | settings: Settings;
8 | onChange: (field: keyof Settings, value: any) => void;
9 | }
10 |
11 | const VideoDefaultSettings: React.FC = ({ settings, onChange }) => {
12 | const { t } = useLanguage();
13 |
14 | return (
15 |
16 | {t('videoDefaults')}
17 |
18 | onChange('defaultAutoPlay', e.target.checked)}
23 | />
24 | }
25 | label={t('autoPlay')}
26 | />
27 |
28 |
29 | );
30 | };
31 |
32 | export default VideoDefaultSettings;
33 |
--------------------------------------------------------------------------------
/backend/src/utils/response.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Standardized API response utilities
3 | * Provides consistent response formats across all controllers
4 | */
5 |
6 | export interface ApiResponse {
7 | success: boolean;
8 | data?: T;
9 | error?: string;
10 | message?: string;
11 | }
12 |
13 | /**
14 | * Create a successful API response
15 | * @param data - The data to return
16 | * @param message - Optional success message
17 | * @returns Standardized success response
18 | */
19 | export function successResponse(data: T, message?: string): ApiResponse {
20 | return {
21 | success: true,
22 | data,
23 | ...(message && { message }),
24 | };
25 | }
26 |
27 | /**
28 | * Create an error API response
29 | * @param error - Error message
30 | * @returns Standardized error response
31 | */
32 | export function errorResponse(error: string): ApiResponse {
33 | return {
34 | success: false,
35 | error,
36 | };
37 | }
38 |
39 | /**
40 | * Create a success response with a message (no data)
41 | * @param message - Success message
42 | * @returns Standardized success response
43 | */
44 | export function successMessage(message: string): ApiResponse {
45 | return {
46 | success: true,
47 | message,
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 | .pnp
4 | .pnp.js
5 |
6 | # Build files
7 | dist
8 | dist-ssr
9 | build
10 | *.local
11 |
12 | # Environment variables
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | # Logs
19 | logs
20 | *.log
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | pnpm-debug.log*
25 | lerna-debug.log*
26 |
27 | # Editor directories and files
28 | .vscode/*
29 | !.vscode/extensions.json
30 | .idea
31 | .DS_Store
32 | *.suo
33 | *.ntvs*
34 | *.njsproj
35 | *.sln
36 | *.sw?
37 |
38 | # Backend specific
39 | # Test coverage reports
40 | backend/coverage
41 | frontend/coverage
42 |
43 | # Ignore all files in uploads directory and subdirectories
44 | backend/uploads/*
45 | backend/uploads/videos/*
46 | backend/uploads/images/*
47 | # But keep the directory structure
48 | !backend/uploads/.gitkeep
49 | !backend/uploads/videos/.gitkeep
50 | !backend/uploads/images/.gitkeep
51 | # Ignore entire data directory
52 | backend/data/*
53 | # But keep the directory structure if needed
54 | !backend/data/.gitkeep
55 |
56 | # Large video files (test files)
57 | *.webm
58 | *.mp4
59 | *.mkv
60 | *.avi
61 |
62 | # Snyk Security Extension - AI Rules (auto-generated)
63 | .cursor/rules/snyk_rules.mdc
64 |
--------------------------------------------------------------------------------
/frontend/src/components/Disclaimer.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Paper, Typography } from '@mui/material';
2 | import React from 'react';
3 | import { en } from '../utils/locales/en';
4 |
5 | const Disclaimer: React.FC = () => {
6 | return (
7 |
8 | theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.6)' : 'background.paper',
13 | border: '1px solid',
14 | borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
15 | borderRadius: 4,
16 | backdropFilter: 'blur(10px)'
17 | }}
18 | >
19 |
20 | {en.disclaimerTitle}
21 |
22 |
23 | {en.disclaimerText}
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default Disclaimer;
31 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useCloudStorageUrl.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { getFileUrl, isCloudStoragePath } from '../utils/cloudStorage';
3 |
4 | /**
5 | * Hook to get file URL, handling cloud storage paths dynamically
6 | * Returns the URL string, or undefined if not available
7 | */
8 | export const useCloudStorageUrl = (
9 | path: string | null | undefined,
10 | type: 'video' | 'thumbnail' = 'video'
11 | ): string | undefined => {
12 | const [url, setUrl] = useState(undefined);
13 |
14 | useEffect(() => {
15 | if (!path) {
16 | setUrl(undefined);
17 | return;
18 | }
19 |
20 | // If already a full URL, use it directly
21 | if (path.startsWith('http://') || path.startsWith('https://')) {
22 | setUrl(path);
23 | return;
24 | }
25 |
26 | // If cloud storage path, fetch signed URL
27 | if (isCloudStoragePath(path)) {
28 | getFileUrl(path, type).then((signedUrl) => {
29 | setUrl(signedUrl);
30 | });
31 | } else {
32 | // Regular path, construct URL synchronously
33 | const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5551';
34 | setUrl(`${BACKEND_URL}${path}`);
35 | }
36 | }, [path, type]);
37 |
38 | return url;
39 | };
40 |
41 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | backend:
5 | image: franklioxygen/mytube:backend-latest
6 | pull_policy: always
7 | container_name: mytube-backend
8 | volumes:
9 | - /share/CACHEDEV2_DATA/Medias/MyTube/uploads:/app/uploads
10 | - /share/CACHEDEV2_DATA/Medias/MyTube/data:/app/data
11 | environment:
12 | - PORT=5551
13 | restart: unless-stopped
14 | networks:
15 | - mytube-network
16 |
17 | frontend:
18 | image: franklioxygen/mytube:frontend-latest
19 | pull_policy: always
20 | container_name: mytube-frontend
21 | ports:
22 | - "5556:5556"
23 | environment:
24 | # For internal container communication, use the service name
25 | # These will be replaced at runtime by the entrypoint script
26 | - VITE_API_URL=/api
27 | - VITE_BACKEND_URL=
28 | # For QNAP or other environments where service discovery doesn't work,
29 | # you can override these values using a .env file with:
30 | # - API_HOST=your-ip-or-hostname
31 | # - API_PORT=5551
32 | depends_on:
33 | - backend
34 | restart: unless-stopped
35 | networks:
36 | - mytube-network
37 |
38 | volumes:
39 | backend-data:
40 | driver: local
41 |
42 | networks:
43 | mytube-network:
44 | driver: bridge
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Use this section to tell people about which versions of your project are currently being supported with security updates.
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | 1.1.x | :white_check_mark: |
10 | | 1.0.x | :x: |
11 | | < 1.0 | :x: |
12 |
13 | ## Reporting a Vulnerability
14 |
15 | We take the security of our software seriously. If you believe you have found a security vulnerability in MyTube, please report it to us as described below.
16 |
17 | **Please do not report security vulnerabilities through public GitHub issues.**
18 |
19 | Instead, please report them by:
20 |
21 | 1. Sending an email to [INSERT EMAIL HERE].
22 | 2. Opening a draft Security Advisory if you are a collaborator.
23 |
24 | You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message.
25 |
26 | We prefer all communications to be in English or Chinese.
27 |
28 | ## Disclosure Policy
29 |
30 | 1. We will investigate the issue and verify the vulnerability.
31 | 2. We will work on a patch to fix the vulnerability.
32 | 3. We will release a new version of the software with the fix.
33 | 4. We will publish a Security Advisory to inform users about the vulnerability and the fix.
34 |
--------------------------------------------------------------------------------
/frontend/src/components/VideoPlayer/VideoInfo/VideoRating.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Rating, Typography } from '@mui/material';
2 | import React from 'react';
3 | import { useLanguage } from '../../../contexts/LanguageContext';
4 |
5 | interface VideoRatingProps {
6 | rating: number | undefined;
7 | viewCount: number | undefined;
8 | onRatingChange: (newRating: number) => Promise;
9 | }
10 |
11 | const VideoRating: React.FC = ({ rating, viewCount, onRatingChange }) => {
12 | const { t } = useLanguage();
13 |
14 | const handleRatingChangeInternal = (_: React.SyntheticEvent, newValue: number | null) => {
15 | if (newValue) {
16 | onRatingChange(newValue);
17 | }
18 | };
19 |
20 | return (
21 |
22 |
26 |
27 | {rating ? `` : t('rateThisVideo')}
28 |
29 |
30 | {viewCount || 0} {t('views')}
31 |
32 |
33 | );
34 | };
35 |
36 | export default VideoRating;
37 |
38 |
--------------------------------------------------------------------------------
/frontend/src/components/__tests__/Disclaimer.test.tsx:
--------------------------------------------------------------------------------
1 | import { createTheme, ThemeProvider } from '@mui/material/styles';
2 | import { render, screen } from '@testing-library/react';
3 | import { describe, expect, it } from 'vitest';
4 | import { en } from '../../utils/locales/en';
5 | import Disclaimer from '../Disclaimer';
6 |
7 | describe('Disclaimer', () => {
8 | it('renders disclaimer title and text', () => {
9 | const theme = createTheme();
10 | render(
11 |
12 |
13 |
14 | );
15 |
16 | expect(screen.getByText(en.disclaimerTitle)).toBeInTheDocument();
17 | // Disclaimer text has newlines, so check for key parts instead
18 | expect(screen.getByText(/Purpose and Restrictions/i)).toBeInTheDocument();
19 | expect(screen.getByText(/Liability/i)).toBeInTheDocument();
20 | });
21 |
22 | it('renders with proper styling structure', () => {
23 | const theme = createTheme();
24 | const { container } = render(
25 |
26 |
27 |
28 | );
29 |
30 | // Should render Paper component
31 | const paper = container.querySelector('.MuiPaper-root');
32 | expect(paper).toBeInTheDocument();
33 | });
34 | });
35 |
36 |
--------------------------------------------------------------------------------
/backend/src/services/cloudStorage/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Configuration management for cloud storage
3 | */
4 |
5 | import { getSettings } from "../storageService";
6 | import { CloudDriveConfig } from "./types";
7 |
8 | /**
9 | * Get cloud drive configuration from settings
10 | */
11 | export function getConfig(): CloudDriveConfig {
12 | const settings = getSettings();
13 |
14 | // Parse scan paths from multi-line string
15 | let scanPaths: string[] | undefined = undefined;
16 | if (settings.cloudDriveScanPaths) {
17 | scanPaths = settings.cloudDriveScanPaths
18 | .split('\n')
19 | .map((line: string) => line.trim())
20 | .filter((line: string) => line.length > 0 && line.startsWith('/'));
21 |
22 | // If no valid paths found, set to undefined
23 | if (scanPaths && scanPaths.length === 0) {
24 | scanPaths = undefined;
25 | }
26 | }
27 |
28 | return {
29 | enabled: settings.cloudDriveEnabled || false,
30 | apiUrl: settings.openListApiUrl || "",
31 | token: settings.openListToken || "",
32 | publicUrl: settings.openListPublicUrl || undefined,
33 | uploadPath: settings.cloudDrivePath || "/",
34 | scanPaths: scanPaths,
35 | };
36 | }
37 |
38 | /**
39 | * Check if cloud storage is properly configured
40 | */
41 | export function isConfigured(config: CloudDriveConfig): boolean {
42 | return config.enabled && !!config.apiUrl && !!config.token;
43 | }
44 |
45 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
4 |
5 | Fixes # (issue)
6 |
7 | ## Type of change
8 |
9 | Please delete options that are not relevant.
10 |
11 | - [ ] Bug fix (non-breaking change which fixes an issue)
12 | - [ ] New feature (non-breaking change which adds functionality)
13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
14 | - [ ] This change requires a documentation update
15 |
16 | ## How Has This Been Tested?
17 |
18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
19 |
20 | - [ ] Test A
21 | - [ ] Test B
22 |
23 | ## Checklist:
24 |
25 | - [ ] My code follows the style guidelines of this project
26 | - [ ] I have performed a self-review of my own code
27 | - [ ] I have commented my code, particularly in hard-to-understand areas
28 | - [ ] I have made corresponding changes to the documentation
29 | - [ ] My changes generate no new warnings
30 | - [ ] I have added tests that prove my fix is effective or that my feature works
31 | - [ ] New and existing unit tests pass locally with my changes
32 | - [ ] Any dependent changes have been merged and published in downstream modules
33 |
--------------------------------------------------------------------------------
/frontend/src/components/Settings/AdvancedSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
2 | import React from 'react';
3 | import { useLanguage } from '../../contexts/LanguageContext';
4 | import ConsoleManager from '../../utils/consoleManager';
5 |
6 | interface AdvancedSettingsProps {
7 | debugMode: boolean;
8 | onDebugModeChange: (enabled: boolean) => void;
9 | }
10 |
11 | const AdvancedSettings: React.FC = ({ debugMode, onDebugModeChange }) => {
12 | const { t } = useLanguage();
13 |
14 | const handleChange = (e: React.ChangeEvent) => {
15 | const checked = e.target.checked;
16 | onDebugModeChange(checked);
17 | ConsoleManager.setDebugMode(checked);
18 | };
19 |
20 | return (
21 |
22 | {t('debugMode')}
23 |
24 | {t('debugModeDescription')}
25 |
26 |
32 | }
33 | label={t('debugMode')}
34 | />
35 |
36 | );
37 | };
38 |
39 | export default AdvancedSettings;
40 |
--------------------------------------------------------------------------------
/frontend/src/components/__tests__/Footer.test.tsx:
--------------------------------------------------------------------------------
1 | import { createTheme, ThemeProvider } from '@mui/material/styles';
2 | import { render, screen } from '@testing-library/react';
3 | import { beforeEach, describe, expect, it, vi } from 'vitest';
4 | import Footer from '../Footer';
5 |
6 | describe('Footer', () => {
7 | beforeEach(() => {
8 | vi.clearAllMocks();
9 | });
10 |
11 | it('renders version number', () => {
12 | const theme = createTheme();
13 | render(
14 |
15 |
16 |
17 | );
18 |
19 | expect(screen.getByText(/^v\d+\.\d+\.\d+/)).toBeInTheDocument();
20 | });
21 |
22 | it('renders GitHub link', () => {
23 | const theme = createTheme();
24 | render(
25 |
26 |
27 |
28 | );
29 |
30 | const link = screen.getByRole('link', { name: /MyTube/i });
31 | expect(link).toHaveAttribute('href', 'https://github.com/franklioxygen/MyTube');
32 | });
33 |
34 | it('renders creation text', () => {
35 | const theme = createTheme();
36 | render(
37 |
38 |
39 |
40 | );
41 |
42 | expect(screen.getByText('Created by franklioxygen')).toBeInTheDocument();
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "1.6.36",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview",
11 | "test": "vitest"
12 | },
13 | "dependencies": {
14 | "@emotion/react": "^11.14.0",
15 | "@emotion/styled": "^11.14.1",
16 | "@mui/icons-material": "^7.3.5",
17 | "@mui/material": "^7.3.5",
18 | "@tanstack/react-query": "^5.90.11",
19 | "@tanstack/react-query-devtools": "^5.91.1",
20 | "axios": "^1.8.1",
21 | "dotenv": "^16.4.7",
22 | "framer-motion": "^12.23.24",
23 | "react": "^19.0.0",
24 | "react-dom": "^19.0.0",
25 | "react-router-dom": "^7.2.0",
26 | "react-virtuoso": "^4.17.0"
27 | },
28 | "devDependencies": {
29 | "@eslint/js": "^9.21.0",
30 | "@testing-library/jest-dom": "^6.9.1",
31 | "@testing-library/react": "^16.3.1",
32 | "@testing-library/user-event": "^14.6.1",
33 | "@types/node": "^24.10.1",
34 | "@types/react": "^19.2.6",
35 | "@types/react-dom": "^19.2.3",
36 | "@vitejs/plugin-react": "^4.3.4",
37 | "@vitest/coverage-v8": "^4.0.15",
38 | "eslint": "^9.21.0",
39 | "eslint-plugin-react-hooks": "^5.1.0",
40 | "eslint-plugin-react-refresh": "^0.4.19",
41 | "globals": "^15.15.0",
42 | "jsdom": "^27.3.0",
43 | "typescript": "^5.9.3",
44 | "vite": "^6.2.0",
45 | "vitest": "^4.0.15"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/src/contexts/VisitorModeContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
2 | import axios from 'axios';
3 | import { useQuery } from '@tanstack/react-query';
4 |
5 | const API_URL = import.meta.env.VITE_API_URL;
6 |
7 | interface VisitorModeContextType {
8 | visitorMode: boolean;
9 | isLoading: boolean;
10 | }
11 |
12 | const VisitorModeContext = createContext({
13 | visitorMode: false,
14 | isLoading: true,
15 | });
16 |
17 | export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
18 | const { data: settingsData, isLoading } = useQuery({
19 | queryKey: ['settings'],
20 | queryFn: async () => {
21 | const response = await axios.get(`${API_URL}/settings`);
22 | return response.data;
23 | },
24 | refetchInterval: 5000, // Refetch every 5 seconds to keep visitor mode state in sync
25 | });
26 |
27 | const visitorMode = settingsData?.visitorMode === true;
28 |
29 | return (
30 |
31 | {children}
32 |
33 | );
34 | };
35 |
36 | export const useVisitorMode = () => {
37 | const context = useContext(VisitorModeContext);
38 | if (!context) {
39 | throw new Error('useVisitorMode must be used within a VisitorModeProvider');
40 | }
41 | return context;
42 | };
43 |
44 |
--------------------------------------------------------------------------------
/frontend/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/scripts/test-duration.ts:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 | import fs from "fs";
3 | import path from "path";
4 | import { getVideoDuration } from "../src/services/metadataService";
5 |
6 | const TEST_VIDEO_PATH = path.join(__dirname, "test_video.mp4");
7 |
8 | async function createTestVideo() {
9 | return new Promise((resolve, reject) => {
10 | // Create a 5-second black video
11 | exec(`ffmpeg -f lavfi -i color=c=black:s=320x240:d=5 -c:v libx264 "${TEST_VIDEO_PATH}" -y`, (error) => {
12 | if (error) {
13 | reject(error);
14 | } else {
15 | resolve();
16 | }
17 | });
18 | });
19 | }
20 |
21 | async function runTest() {
22 | try {
23 | console.log("Creating test video...");
24 | await createTestVideo();
25 | console.log("Test video created.");
26 |
27 | console.log("Getting duration...");
28 | const duration = await getVideoDuration(TEST_VIDEO_PATH);
29 | console.log(`Duration: ${duration}`);
30 |
31 | if (duration === 5) {
32 | console.log("SUCCESS: Duration is correct.");
33 | } else {
34 | console.error(`FAILURE: Expected duration 5, got ${duration}`);
35 | process.exit(1);
36 | }
37 | } catch (error) {
38 | console.error("Test failed:", error);
39 | process.exit(1);
40 | } finally {
41 | if (fs.existsSync(TEST_VIDEO_PATH)) {
42 | fs.unlinkSync(TEST_VIDEO_PATH);
43 | console.log("Test video deleted.");
44 | }
45 | }
46 | }
47 |
48 | runTest();
49 |
--------------------------------------------------------------------------------
/frontend/src/components/AlertModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Dialog,
4 | DialogActions,
5 | DialogContent,
6 | DialogContentText,
7 | DialogTitle,
8 | Typography
9 | } from '@mui/material';
10 | import React from 'react';
11 | import { useLanguage } from '../contexts/LanguageContext';
12 |
13 | interface AlertModalProps {
14 | open: boolean;
15 | onClose: () => void;
16 | title: string;
17 | message: string;
18 | }
19 |
20 | const AlertModal: React.FC = ({ open, onClose, title, message }) => {
21 | const { t } = useLanguage();
22 |
23 | return (
24 |
51 | );
52 | };
53 |
54 | export default AlertModal;
55 |
--------------------------------------------------------------------------------
/backend/src/services/storageService/index.ts:
--------------------------------------------------------------------------------
1 | // Main index file that re-exports all storage service functionality
2 | // This maintains backward compatibility while allowing modular organization
3 |
4 | // Types
5 | export * from "./types";
6 |
7 | // Initialization
8 | export { initializeStorage } from "./initialization";
9 |
10 | // Download Status
11 | export {
12 | addActiveDownload,
13 | updateActiveDownload,
14 | removeActiveDownload,
15 | setQueuedDownloads,
16 | getDownloadStatus,
17 | } from "./downloadStatus";
18 |
19 | // Download History
20 | export {
21 | addDownloadHistoryItem,
22 | getDownloadHistory,
23 | removeDownloadHistoryItem,
24 | clearDownloadHistory,
25 | } from "./downloadHistory";
26 |
27 | // Video Download Tracking
28 | export {
29 | checkVideoDownloadBySourceId,
30 | checkVideoDownloadByUrl,
31 | recordVideoDownload,
32 | markVideoDownloadDeleted,
33 | updateVideoDownloadRecord,
34 | } from "./videoDownloadTracking";
35 |
36 | // Settings
37 | export { getSettings, saveSettings } from "./settings";
38 |
39 | // Videos
40 | export {
41 | getVideos,
42 | getVideoBySourceUrl,
43 | getVideoById,
44 | formatLegacyFilenames,
45 | saveVideo,
46 | updateVideo,
47 | deleteVideo,
48 | } from "./videos";
49 |
50 | // Collections
51 | export {
52 | getCollections,
53 | getCollectionById,
54 | getCollectionByVideoId,
55 | getCollectionByName,
56 | saveCollection,
57 | atomicUpdateCollection,
58 | deleteCollection,
59 | addVideoToCollection,
60 | removeVideoFromCollection,
61 | deleteCollectionWithFiles,
62 | deleteCollectionAndVideos,
63 | } from "./collections";
64 |
65 | // File Helpers
66 | export { findVideoFile, findImageFile, moveFile } from "./fileHelpers";
67 |
68 |
--------------------------------------------------------------------------------
/backend/drizzle/0000_known_guardsmen.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS `collection_videos` (
2 | `collection_id` text NOT NULL,
3 | `video_id` text NOT NULL,
4 | `order` integer,
5 | PRIMARY KEY(`collection_id`, `video_id`),
6 | FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade,
7 | FOREIGN KEY (`video_id`) REFERENCES `videos`(`id`) ON UPDATE no action ON DELETE cascade
8 | );
9 | --> statement-breakpoint
10 | CREATE TABLE IF NOT EXISTS `collections` (
11 | `id` text PRIMARY KEY NOT NULL,
12 | `name` text NOT NULL,
13 | `title` text,
14 | `created_at` text NOT NULL,
15 | `updated_at` text
16 | );
17 | --> statement-breakpoint
18 | CREATE TABLE IF NOT EXISTS `downloads` (
19 | `id` text PRIMARY KEY NOT NULL,
20 | `title` text NOT NULL,
21 | `timestamp` integer,
22 | `filename` text,
23 | `total_size` text,
24 | `downloaded_size` text,
25 | `progress` integer,
26 | `speed` text,
27 | `status` text DEFAULT 'active' NOT NULL
28 | );
29 | --> statement-breakpoint
30 | CREATE TABLE IF NOT EXISTS `settings` (
31 | `key` text PRIMARY KEY NOT NULL,
32 | `value` text NOT NULL
33 | );
34 | --> statement-breakpoint
35 | CREATE TABLE IF NOT EXISTS `videos` (
36 | `id` text PRIMARY KEY NOT NULL,
37 | `title` text NOT NULL,
38 | `author` text,
39 | `date` text,
40 | `source` text,
41 | `source_url` text,
42 | `video_filename` text,
43 | `thumbnail_filename` text,
44 | `video_path` text,
45 | `thumbnail_path` text,
46 | `thumbnail_url` text,
47 | `added_at` text,
48 | `created_at` text NOT NULL,
49 | `updated_at` text,
50 | `part_number` integer,
51 | `total_parts` integer,
52 | `series_title` text,
53 | `rating` integer,
54 | `description` text,
55 | `view_count` integer,
56 | `duration` text
57 | );
58 |
--------------------------------------------------------------------------------
/backend/src/services/storageService/settings.ts:
--------------------------------------------------------------------------------
1 | import { DatabaseError } from "../../errors/DownloadErrors";
2 | import { db } from "../../db";
3 | import { settings } from "../../db/schema";
4 | import { logger } from "../../utils/logger";
5 |
6 | export function getSettings(): Record {
7 | try {
8 | const allSettings = db.select().from(settings).all();
9 | const settingsMap: Record = {};
10 |
11 | for (const setting of allSettings) {
12 | try {
13 | settingsMap[setting.key] = JSON.parse(setting.value);
14 | } catch (e) {
15 | settingsMap[setting.key] = setting.value;
16 | }
17 | }
18 |
19 | return settingsMap;
20 | } catch (error) {
21 | logger.error("Error getting settings", error instanceof Error ? error : new Error(String(error)));
22 | // Return empty object for backward compatibility
23 | return {};
24 | }
25 | }
26 |
27 | export function saveSettings(newSettings: Record): void {
28 | try {
29 | db.transaction(() => {
30 | for (const [key, value] of Object.entries(newSettings)) {
31 | db.insert(settings)
32 | .values({
33 | key,
34 | value: JSON.stringify(value),
35 | })
36 | .onConflictDoUpdate({
37 | target: settings.key,
38 | set: { value: JSON.stringify(value) },
39 | })
40 | .run();
41 | }
42 | });
43 | } catch (error) {
44 | logger.error("Error saving settings", error instanceof Error ? error : new Error(String(error)));
45 | throw new DatabaseError(
46 | "Failed to save settings",
47 | error instanceof Error ? error : new Error(String(error)),
48 | "saveSettings"
49 | );
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/frontend/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Default values from build time
5 | DEFAULT_API_URL="http://localhost:5551/api"
6 | DEFAULT_BACKEND_URL="http://localhost:5551"
7 |
8 | # Runtime values from docker-compose environment variables
9 | DOCKER_API_URL="${VITE_API_URL-http://backend:5551/api}"
10 | DOCKER_BACKEND_URL="${VITE_BACKEND_URL-http://backend:5551}"
11 |
12 | # If API_HOST is provided, override with custom host configuration
13 | if [ ! -z "$API_HOST" ]; then
14 | API_PORT="${API_PORT:-5551}"
15 | DOCKER_API_URL="http://${API_HOST}:${API_PORT}/api"
16 | DOCKER_BACKEND_URL="http://${API_HOST}:${API_PORT}"
17 | echo "Using custom host configuration: $API_HOST:$API_PORT"
18 | fi
19 |
20 | echo "Configuring frontend with the following settings:"
21 | echo "API URL: $DOCKER_API_URL"
22 | echo "Backend URL: $DOCKER_BACKEND_URL"
23 |
24 | # Replace environment variables in the JavaScript files
25 | # We need to escape special characters for sed
26 | ESCAPED_DEFAULT_API_URL=$(echo $DEFAULT_API_URL | sed 's/\//\\\//g')
27 | ESCAPED_API_URL=$(echo $DOCKER_API_URL | sed 's/\//\\\//g')
28 | ESCAPED_DEFAULT_BACKEND_URL=$(echo $DEFAULT_BACKEND_URL | sed 's/\//\\\//g')
29 | ESCAPED_BACKEND_URL=$(echo $DOCKER_BACKEND_URL | sed 's/\//\\\//g')
30 |
31 | echo "Replacing $DEFAULT_API_URL with $DOCKER_API_URL in JavaScript files..."
32 | find /usr/share/nginx/html -type f -name "*.js" -exec sed -i "s/$ESCAPED_DEFAULT_API_URL/$ESCAPED_API_URL/g" {} \;
33 |
34 | echo "Replacing $DEFAULT_BACKEND_URL with $DOCKER_BACKEND_URL in JavaScript files..."
35 | find /usr/share/nginx/html -type f -name "*.js" -exec sed -i "s/$ESCAPED_DEFAULT_BACKEND_URL/$ESCAPED_BACKEND_URL/g" {} \;
36 |
37 | echo "Environment variable substitution completed."
38 |
39 | # Execute CMD
40 | exec "$@"
--------------------------------------------------------------------------------
/backend/src/utils/bccToVtt.ts:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Convert Bilibili BCC subtitle format to WebVTT
4 | */
5 |
6 | interface BccItem {
7 | from: number;
8 | to: number;
9 | location: number;
10 | content: string;
11 | }
12 |
13 | interface BccBody {
14 | font_size: number;
15 | font_color: string;
16 | background_alpha: number;
17 | background_color: string;
18 | Stroke: string;
19 | type: string;
20 | lang: string;
21 | version: string;
22 | body: BccItem[];
23 | }
24 |
25 | function formatTime(seconds: number): string {
26 | const date = new Date(0);
27 | date.setMilliseconds(seconds * 1000);
28 | const hh = date.getUTCHours().toString().padStart(2, '0');
29 | const mm = date.getUTCMinutes().toString().padStart(2, '0');
30 | const ss = date.getUTCSeconds().toString().padStart(2, '0');
31 | const ms = date.getUTCMilliseconds().toString().padStart(3, '0');
32 | return `${hh}:${mm}:${ss}.${ms}`;
33 | }
34 |
35 | export function bccToVtt(bccContent: BccBody | string): string {
36 | let bcc: BccBody;
37 |
38 | if (typeof bccContent === 'string') {
39 | try {
40 | bcc = JSON.parse(bccContent);
41 | } catch (e) {
42 | console.error('Failed to parse BCC content', e);
43 | return '';
44 | }
45 | } else {
46 | bcc = bccContent;
47 | }
48 |
49 | if (!bcc.body || !Array.isArray(bcc.body)) {
50 | return '';
51 | }
52 |
53 | let vtt = 'WEBVTT\n\n';
54 |
55 | bcc.body.forEach((item) => {
56 | const start = formatTime(item.from);
57 | const end = formatTime(item.to);
58 | vtt += `${start} --> ${end}\n`;
59 | vtt += `${item.content}\n\n`;
60 | });
61 |
62 | return vtt;
63 | }
64 |
--------------------------------------------------------------------------------
/backend/src/services/downloaders/ytdlp/ytdlpHelpers.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { logger } from "../../../utils/logger";
3 |
4 | /**
5 | * Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
6 | */
7 | export async function extractXiaoHongShuAuthor(
8 | url: string
9 | ): Promise {
10 | try {
11 | logger.info("Attempting to extract XiaoHongShu author from webpage...");
12 | const response = await axios.get(url, {
13 | headers: {
14 | "User-Agent":
15 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
16 | },
17 | timeout: 10000,
18 | });
19 |
20 | const html = response.data;
21 |
22 | // Try to find author name in the JSON data embedded in the page
23 | // XiaoHongShu embeds data in window.__INITIAL_STATE__
24 | const match = html.match(/"nickname":"([^"]+)"/);
25 | if (match && match[1]) {
26 | logger.info("Found XiaoHongShu author:", match[1]);
27 | return match[1];
28 | }
29 |
30 | // Alternative: try to find in user info
31 | const userMatch = html.match(/"user":\{[^}]*"nickname":"([^"]+)"/);
32 | if (userMatch && userMatch[1]) {
33 | logger.info("Found XiaoHongShu author (user):", userMatch[1]);
34 | return userMatch[1];
35 | }
36 |
37 | logger.info("Could not extract XiaoHongShu author from webpage");
38 | return null;
39 | } catch (error) {
40 | logger.error("Error extracting XiaoHongShu author:", error);
41 | return null;
42 | }
43 | }
44 |
45 | /**
46 | * Get the PO Token provider script path from environment
47 | */
48 | export function getProviderScript(): string {
49 | return process.env.BGUTIL_SCRIPT_PATH || "";
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/backend/src/middleware/visitorModeSettingsMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 | import * as storageService from "../services/storageService";
3 |
4 | /**
5 | * Middleware specifically for settings routes
6 | * Allows disabling visitor mode even when visitor mode is enabled
7 | */
8 | export const visitorModeSettingsMiddleware = (
9 | req: Request,
10 | res: Response,
11 | next: NextFunction
12 | ): void => {
13 | const settings = storageService.getSettings();
14 | const visitorMode = settings.visitorMode === true;
15 |
16 | if (!visitorMode) {
17 | // Visitor mode is not enabled, allow all requests
18 | next();
19 | return;
20 | }
21 |
22 | // Visitor mode is enabled
23 | // Allow GET requests (read-only)
24 | if (req.method === "GET") {
25 | next();
26 | return;
27 | }
28 |
29 | // For POST requests, check if it's trying to disable visitor mode or verify password
30 | if (req.method === "POST") {
31 | // Allow verify-password requests
32 | if (req.path.includes("/verify-password") || req.url.includes("/verify-password")) {
33 | next();
34 | return;
35 | }
36 |
37 | const body = req.body || {};
38 | // Check if the request is trying to disable visitor mode
39 | if (body.visitorMode === false) {
40 | // Allow disabling visitor mode
41 | next();
42 | return;
43 | }
44 | // Block all other settings updates
45 | res.status(403).json({
46 | success: false,
47 | error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
48 | });
49 | return;
50 | }
51 |
52 | // Block all other write operations (PUT, DELETE, PATCH)
53 | res.status(403).json({
54 | success: false,
55 | error: "Visitor mode is enabled. Write operations are not allowed.",
56 | });
57 | };
58 |
--------------------------------------------------------------------------------
/frontend/src/components/Settings/SecuritySettings.tsx:
--------------------------------------------------------------------------------
1 | import { Box, FormControlLabel, Switch, TextField, Typography } from '@mui/material';
2 | import React from 'react';
3 | import { useLanguage } from '../../contexts/LanguageContext';
4 | import { Settings } from '../../types';
5 |
6 | interface SecuritySettingsProps {
7 | settings: Settings;
8 | onChange: (field: keyof Settings, value: any) => void;
9 | }
10 |
11 | const SecuritySettings: React.FC = ({ settings, onChange }) => {
12 | const { t } = useLanguage();
13 |
14 | return (
15 |
16 | {t('security')}
17 | onChange('loginEnabled', e.target.checked)}
22 | />
23 | }
24 | label={t('enableLogin')}
25 | />
26 |
27 | {settings.loginEnabled && (
28 |
29 | onChange('password', e.target.value)}
35 | helperText={
36 | settings.isPasswordSet
37 | ? t('passwordHelper')
38 | : t('passwordSetHelper')
39 | }
40 | />
41 |
42 | )}
43 |
44 | );
45 | };
46 |
47 | export default SecuritySettings;
48 |
--------------------------------------------------------------------------------
/backend/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import Database from "better-sqlite3";
2 | import { drizzle } from "drizzle-orm/better-sqlite3";
3 | import fs from "fs-extra";
4 | import path from "path";
5 | import { DATA_DIR } from "../config/paths";
6 | import * as schema from "./schema";
7 |
8 | // Ensure data directory exists
9 | fs.ensureDirSync(DATA_DIR);
10 |
11 | const dbPath = path.join(DATA_DIR, "mytube.db");
12 |
13 | // Create database connection with getters that auto-reopen if closed
14 | let sqliteInstance: Database.Database = new Database(dbPath);
15 | let dbInstance = drizzle(sqliteInstance, { schema });
16 |
17 | // Helper to ensure connection is open
18 | function ensureConnection(): void {
19 | if (!sqliteInstance.open) {
20 | sqliteInstance = new Database(dbPath);
21 | dbInstance = drizzle(sqliteInstance, { schema });
22 | }
23 | }
24 |
25 | // Export sqlite with auto-reconnect
26 | // Using an empty object as target so we always use the current sqliteInstance
27 | export const sqlite = new Proxy({} as Database.Database, {
28 | get(_target, prop) {
29 | ensureConnection();
30 | return (sqliteInstance as any)[prop];
31 | },
32 | set(_target, prop, value) {
33 | ensureConnection();
34 | (sqliteInstance as any)[prop] = value;
35 | return true;
36 | },
37 | });
38 |
39 | // Export db with auto-reconnect
40 | // Using an empty object as target so we always use the current dbInstance
41 | export const db = new Proxy({} as ReturnType, {
42 | get(_target, prop) {
43 | ensureConnection();
44 | return (dbInstance as any)[prop];
45 | },
46 | });
47 |
48 | // Function to reinitialize the database connection
49 | export function reinitializeDatabase(): void {
50 | if (sqliteInstance.open) {
51 | sqliteInstance.close();
52 | }
53 | sqliteInstance = new Database(dbPath);
54 | dbInstance = drizzle(sqliteInstance, { schema });
55 | }
56 |
--------------------------------------------------------------------------------
/backend/src/routes/settingsRoutes.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import multer from "multer";
3 | import os from "os";
4 | import {
5 | checkCookies,
6 | cleanupBackupDatabases,
7 | deleteCookies,
8 | deleteLegacyData,
9 | exportDatabase,
10 | formatFilenames,
11 | getLastBackupInfo,
12 | getPasswordEnabled,
13 | getSettings,
14 | importDatabase,
15 | migrateData,
16 | restoreFromLastBackup,
17 | resetPassword,
18 | updateSettings,
19 | uploadCookies,
20 | verifyPassword,
21 | } from "../controllers/settingsController";
22 | import { asyncHandler } from "../middleware/errorHandler";
23 |
24 | const router = express.Router();
25 | const upload = multer({ dest: os.tmpdir() });
26 |
27 | router.get("/", asyncHandler(getSettings));
28 | router.post("/", asyncHandler(updateSettings));
29 | router.get("/password-enabled", asyncHandler(getPasswordEnabled));
30 | router.post("/verify-password", asyncHandler(verifyPassword));
31 | router.post("/reset-password", asyncHandler(resetPassword));
32 | router.post("/migrate", asyncHandler(migrateData));
33 | router.post("/delete-legacy", asyncHandler(deleteLegacyData));
34 | router.post("/format-filenames", asyncHandler(formatFilenames));
35 | router.post(
36 | "/upload-cookies",
37 | upload.single("file"),
38 | asyncHandler(uploadCookies)
39 | );
40 | router.post("/delete-cookies", asyncHandler(deleteCookies));
41 | router.get("/check-cookies", asyncHandler(checkCookies));
42 | router.get("/export-database", asyncHandler(exportDatabase));
43 | router.post(
44 | "/import-database",
45 | upload.single("file"),
46 | asyncHandler(importDatabase)
47 | );
48 | router.post("/cleanup-backup-databases", asyncHandler(cleanupBackupDatabases));
49 | router.get("/last-backup-info", asyncHandler(getLastBackupInfo));
50 | router.post("/restore-from-last-backup", asyncHandler(restoreFromLastBackup));
51 |
52 | export default router;
53 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "1.6.36",
4 | "main": "server.js",
5 | "scripts": {
6 | "start": "ts-node src/server.ts",
7 | "dev": "nodemon src/server.ts",
8 | "build": "tsc",
9 | "generate": "drizzle-kit generate",
10 | "test": "vitest",
11 | "test:coverage": "vitest run --coverage",
12 | "postinstall": "node -e \"const fs = require('fs'); const cp = require('child_process'); const p = 'bgutil-ytdlp-pot-provider/server'; if (fs.existsSync(p)) { console.log('Building provider...'); cp.execSync('npm install && npx tsc', { cwd: p, stdio: 'inherit' }); } else { console.log('Skipping provider build: ' + p + ' not found'); }\""
13 | },
14 | "keywords": [],
15 | "author": "",
16 | "license": "ISC",
17 | "description": "Backend for MyTube video streaming website",
18 | "dependencies": {
19 | "axios": "^1.8.1",
20 | "bcryptjs": "^3.0.3",
21 | "better-sqlite3": "^12.4.6",
22 | "cheerio": "^1.1.2",
23 | "cors": "^2.8.5",
24 | "dotenv": "^16.4.7",
25 | "drizzle-orm": "^0.44.7",
26 | "express": "^4.18.2",
27 | "fs-extra": "^11.2.0",
28 | "multer": "^1.4.5-lts.1",
29 | "node-cron": "^4.2.1",
30 | "puppeteer": "^24.31.0",
31 | "uuid": "^13.0.0"
32 | },
33 | "devDependencies": {
34 | "@types/bcryptjs": "^2.4.6",
35 | "@types/better-sqlite3": "^7.6.13",
36 | "@types/cors": "^2.8.19",
37 | "@types/express": "^5.0.5",
38 | "@types/fs-extra": "^11.0.4",
39 | "@types/multer": "^2.0.0",
40 | "@types/node": "^24.10.1",
41 | "@types/node-cron": "^3.0.11",
42 | "@types/supertest": "^6.0.3",
43 | "@types/uuid": "^10.0.0",
44 | "@vitest/coverage-v8": "^3.2.4",
45 | "drizzle-kit": "^0.31.7",
46 | "nodemon": "^3.0.3",
47 | "supertest": "^7.1.4",
48 | "ts-node": "^10.9.2",
49 | "typescript": "^5.9.3",
50 | "vitest": "^3.2.4"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/src/contexts/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | import { CssBaseline, ThemeProvider as MuiThemeProvider } from '@mui/material';
2 | import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
3 | import getTheme from '../theme';
4 |
5 | type ThemeMode = 'light' | 'dark';
6 |
7 | interface ThemeContextType {
8 | mode: ThemeMode;
9 | toggleTheme: () => void;
10 | }
11 |
12 | const ThemeContext = createContext(undefined);
13 |
14 | export const useThemeContext = () => {
15 | const context = useContext(ThemeContext);
16 | if (!context) {
17 | throw new Error('useThemeContext must be used within a ThemeContextProvider');
18 | }
19 | return context;
20 | };
21 |
22 | export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
23 | // Initialize theme from localStorage or system preference
24 | const [mode, setMode] = useState(() => {
25 | const savedMode = localStorage.getItem('themeMode');
26 | if (savedMode === 'light' || savedMode === 'dark') {
27 | return savedMode;
28 | }
29 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
30 | });
31 |
32 | // Update localStorage when theme changes
33 | useEffect(() => {
34 | localStorage.setItem('themeMode', mode);
35 | }, [mode]);
36 |
37 | const toggleTheme = () => {
38 | setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light'));
39 | };
40 |
41 | const theme = useMemo(() => getTheme(mode), [mode]);
42 |
43 | return (
44 |
45 |
46 |
47 | {children}
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/backend/src/services/downloaders/YtDlpDownloader.ts:
--------------------------------------------------------------------------------
1 | import { Video } from "../storageService";
2 | import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
3 | import { getLatestVideoUrl } from "./ytdlp/ytdlpChannel";
4 | import { getVideoInfo as getVideoInfoFromModule } from "./ytdlp/ytdlpMetadata";
5 | import { searchVideos } from "./ytdlp/ytdlpSearch";
6 | import { downloadVideo as downloadVideoFromModule } from "./ytdlp/ytdlpVideo";
7 |
8 | export class YtDlpDownloader extends BaseDownloader {
9 | // Search for videos (primarily for YouTube, but could be adapted)
10 | static async search(
11 | query: string,
12 | limit: number = 8,
13 | offset: number = 1
14 | ): Promise {
15 | return searchVideos(query, limit, offset);
16 | }
17 |
18 | // Implementation of IDownloader.getVideoInfo
19 | async getVideoInfo(url: string): Promise {
20 | return YtDlpDownloader.getVideoInfo(url);
21 | }
22 |
23 | // Get video info without downloading (Static wrapper)
24 | static async getVideoInfo(url: string): Promise {
25 | return getVideoInfoFromModule(url);
26 | }
27 |
28 | // Get the latest video URL from a channel
29 | static async getLatestVideoUrl(channelUrl: string): Promise {
30 | return getLatestVideoUrl(channelUrl);
31 | }
32 |
33 | // Implementation of IDownloader.downloadVideo
34 | async downloadVideo(url: string, options?: DownloadOptions): Promise