├── data └── mytube.db ├── backend ├── uploads │ └── .gitkeep ├── .env.example ├── drizzle │ ├── 0002_romantic_colossus.sql │ ├── 0005_tired_demogoblin.sql │ ├── 0001_worthless_blur.sql │ ├── 0003_puzzling_energizer.sql │ ├── 0004_video_downloads.sql │ ├── meta │ │ └── _journal.json │ └── 0000_known_guardsmen.sql ├── data │ └── login-attempts.json ├── nodemon.json ├── drizzle.config.ts ├── src │ ├── services │ │ ├── CloudStorageService.ts │ │ ├── downloaders │ │ │ ├── ytdlp │ │ │ │ ├── types.ts │ │ │ │ ├── ytdlpHelpers.ts │ │ │ │ ├── ytdlpSearch.ts │ │ │ │ ├── ytdlpChannel.ts │ │ │ │ └── ytdlpMetadata.ts │ │ │ ├── bilibili │ │ │ │ ├── bilibiliCookie.ts │ │ │ │ └── types.ts │ │ │ └── YtDlpDownloader.ts │ │ ├── cloudStorage │ │ │ ├── types.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ └── pathUtils.ts │ │ ├── storageService.ts │ │ ├── storageService │ │ │ ├── index.ts │ │ │ ├── settings.ts │ │ │ ├── fileHelpers.ts │ │ │ ├── types.ts │ │ │ └── downloadHistory.ts │ │ └── commentService.ts │ ├── test_sanitize.ts │ ├── version.ts │ ├── config │ │ └── paths.ts │ ├── utils │ │ ├── response.ts │ │ └── bccToVtt.ts │ ├── middleware │ │ ├── visitorModeSettingsMiddleware.ts │ │ ├── visitorModeMiddleware.ts │ │ └── errorHandler.ts │ ├── db │ │ ├── index.ts │ │ └── migrate.ts │ ├── routes │ │ └── settingsRoutes.ts │ ├── controllers │ │ ├── subscriptionController.ts │ │ ├── downloadController.ts │ │ └── cleanupController.ts │ ├── __tests__ │ │ ├── controllers │ │ │ ├── scanController.test.ts │ │ │ └── cleanupController.test.ts │ │ ├── services │ │ │ ├── downloaders │ │ │ │ └── MissAVDownloader.test.ts │ │ │ └── commentService.test.ts │ │ └── utils │ │ │ └── cleanupVideoArtifacts.test.ts │ └── scripts │ │ └── cleanVttFiles.ts ├── .dockerignore ├── tsconfig.json ├── vitest.config.ts ├── scripts │ ├── test-duration.ts │ └── update-durations.ts ├── package.json └── Dockerfile ├── frontend ├── .dockerignore ├── src │ ├── vite-env.d.ts │ ├── utils │ │ ├── constants.ts │ │ ├── translations.ts │ │ ├── urlValidation.ts │ │ ├── __tests__ │ │ │ └── translations.test.ts │ │ └── consoleManager.ts │ ├── App.css │ ├── hooks │ │ ├── useDebounce.ts │ │ ├── useCloudStorageUrl.ts │ │ └── useShareVideo.ts │ ├── setupTests.ts │ ├── components │ │ ├── Header │ │ │ ├── types.ts │ │ │ ├── Logo.tsx │ │ │ ├── ActionButtons.tsx │ │ │ └── ManageMenu.tsx │ │ ├── PageTransition.tsx │ │ ├── Settings │ │ │ ├── VideoDefaultSettings.tsx │ │ │ ├── AdvancedSettings.tsx │ │ │ ├── SecuritySettings.tsx │ │ │ ├── TagsSettings.tsx │ │ │ └── DownloadSettings.tsx │ │ ├── Disclaimer.tsx │ │ ├── VideoPlayer │ │ │ ├── VideoInfo │ │ │ │ ├── VideoRating.tsx │ │ │ │ └── VideoDescription.tsx │ │ │ └── CommentsSection.tsx │ │ ├── __tests__ │ │ │ ├── Disclaimer.test.tsx │ │ │ ├── Footer.test.tsx │ │ │ └── ConfirmationModal.test.tsx │ │ ├── AlertModal.tsx │ │ ├── Footer.tsx │ │ ├── BatchDownloadModal.tsx │ │ ├── TagsList.tsx │ │ ├── ConfirmationModal.tsx │ │ ├── SubscribeModal.tsx │ │ └── AuthorsList.tsx │ ├── version.ts │ ├── main.tsx │ ├── pages │ │ └── DownloadPage │ │ │ ├── CustomTabPanel.tsx │ │ │ ├── HistoryTab.tsx │ │ │ └── ActiveDownloadsTab.tsx │ ├── index.css │ ├── contexts │ │ ├── VisitorModeContext.tsx │ │ ├── ThemeContext.tsx │ │ ├── SnackbarContext.tsx │ │ └── AuthContext.tsx │ ├── assets │ │ └── logo.svg │ ├── types.ts │ └── theme.ts ├── .env ├── public │ ├── favicon.ico │ ├── favicon.png │ ├── site.webmanifest │ └── favicon.svg ├── tsconfig.node.json ├── .gitignore ├── vite.config.js ├── Dockerfile ├── README.md ├── index.html ├── tsconfig.json ├── eslint.config.js ├── package.json ├── entrypoint.sh └── nginx.conf ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ └── master.yml └── PULL_REQUEST_TEMPLATE.md ├── LICENSE ├── package.json ├── .gitignore ├── docker-compose.yml ├── SECURITY.md ├── RELEASING.md ├── release.sh └── CONTRIBUTING.md /data/mytube.db: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT={backend_port} -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /backend/drizzle/0002_romantic_colossus.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `videos` ADD `file_size` text; -------------------------------------------------------------------------------- /frontend/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const SNACKBAR_AUTO_HIDE_DURATION = 6000; 2 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:5551/api 2 | VITE_BACKEND_URL=http://localhost:5551 -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franklioxygen/MyTube/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franklioxygen/MyTube/HEAD/frontend/public/favicon.png -------------------------------------------------------------------------------- /backend/data/login-attempts.json: -------------------------------------------------------------------------------- 1 | { 2 | "failedAttempts": 0, 3 | "lastFailedAttemptTime": 0, 4 | "waitUntil": 0 5 | } 6 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts,json", 4 | "ignore": ["src/**/*.test.ts", "src/**/*.spec.ts", "data/*", "uploads/*", "node_modules"], 5 | "exec": "ts-node ./src/server.ts" 6 | } 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .git 4 | .gitignore 5 | .env 6 | docker-compose.yml 7 | README.md 8 | *.log 9 | .DS_Store 10 | backend/node_modules 11 | backend/dist 12 | frontend/node_modules 13 | frontend/dist 14 | -------------------------------------------------------------------------------- /backend/drizzle/0005_tired_demogoblin.sql: -------------------------------------------------------------------------------- 1 | -- Add channel_url column to videos table 2 | -- Note: SQLite doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN 3 | -- This migration assumes the column doesn't exist yet 4 | ALTER TABLE `videos` ADD `channel_url` text; -------------------------------------------------------------------------------- /backend/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit'; 2 | 3 | export default defineConfig({ 4 | schema: './src/db/schema.ts', 5 | out: './drizzle', 6 | dialect: 'sqlite', 7 | dbCredentials: { 8 | url: './data/mytube.db', 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | /* App.css */ 2 | /* Most styles have been moved to MUI components and theme.ts */ 3 | /* Keep this file for any global custom styles that don't fit in the theme */ 4 | 5 | .app { 6 | display: flex; 7 | flex-direction: column; 8 | min-height: 100vh; 9 | } -------------------------------------------------------------------------------- /backend/src/services/CloudStorageService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CloudStorageService - Re-exported from modular structure 3 | * This file maintains backward compatibility with existing imports 4 | * The actual implementation is in ./cloudStorage/index.ts 5 | */ 6 | 7 | export { CloudStorageService } from "./cloudStorage"; 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.js" 11 | ] 12 | } -------------------------------------------------------------------------------- /backend/drizzle/0001_worthless_blur.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `download_history` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `title` text NOT NULL, 4 | `author` text, 5 | `source_url` text, 6 | `finished_at` integer NOT NULL, 7 | `status` text NOT NULL, 8 | `error` text, 9 | `video_path` text, 10 | `thumbnail_path` text, 11 | `total_size` text 12 | ); -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | 5 | # Testing 6 | src/__tests__ 7 | coverage 8 | vitest.config.ts 9 | *.test.ts 10 | *.spec.ts 11 | 12 | # Development 13 | .git 14 | .gitignore 15 | README.md 16 | *.md 17 | 18 | # Editor 19 | .vscode 20 | .idea 21 | *.swp 22 | *.swo 23 | *~ 24 | 25 | # Logs 26 | logs 27 | *.log 28 | npm-debug.log* 29 | -------------------------------------------------------------------------------- /backend/drizzle/0003_puzzling_energizer.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `subscriptions` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `author` text NOT NULL, 4 | `author_url` text NOT NULL, 5 | `interval` integer NOT NULL, 6 | `last_video_link` text, 7 | `last_check` integer, 8 | `download_count` integer DEFAULT 0, 9 | `created_at` integer NOT NULL, 10 | `platform` text DEFAULT 'YouTube' 11 | ); -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true 10 | }, 11 | "include": [ 12 | "src/**/*", 13 | "scripts/**/*" 14 | ], 15 | "exclude": [ 16 | "node_modules" 17 | ] 18 | } -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Environment variables 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /frontend/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyTube", 3 | "short_name": "MyTube", 4 | "icons": [ 5 | { 6 | "src": "/favicon.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/favicon.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /frontend/src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(timer); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { vi } from 'vitest'; 3 | 4 | // Mock matchMedia for MUI 5 | Object.defineProperty(window, 'matchMedia', { 6 | writable: true, 7 | value: vi.fn().mockImplementation(query => ({ 8 | matches: false, 9 | media: query, 10 | onchange: null, 11 | addListener: vi.fn(), // deprecated 12 | removeListener: vi.fn(), // deprecated 13 | addEventListener: vi.fn(), 14 | removeEventListener: vi.fn(), 15 | dispatchEvent: vi.fn(), 16 | })), 17 | }); 18 | -------------------------------------------------------------------------------- /backend/src/test_sanitize.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeFilename } from './utils/helpers'; 2 | 3 | const testCases = [ 4 | "Video Title #hashtag", 5 | "Video #cool #viral Title", 6 | "Just a Title", 7 | "Title with # and space", 8 | "Title with #tag1 #tag2", 9 | "Chinese Title #你好", 10 | "Title with #1", 11 | "Title with #", 12 | ]; 13 | 14 | console.log("Testing sanitizeFilename:"); 15 | testCases.forEach(title => { 16 | console.log(`Original: "${title}"`); 17 | console.log(`Sanitized: "${sanitizeFilename(title)}"`); 18 | console.log("---"); 19 | }); 20 | -------------------------------------------------------------------------------- /backend/src/services/downloaders/ytdlp/types.ts: -------------------------------------------------------------------------------- 1 | import { Video } from "../../storageService"; 2 | 3 | export interface YtDlpVideoInfo { 4 | title: string; 5 | author: string; 6 | date: string; 7 | thumbnailUrl: string | null; 8 | thumbnailSaved: boolean; 9 | description?: string; 10 | source?: string; 11 | } 12 | 13 | export interface YtDlpDownloadContext { 14 | videoUrl: string; 15 | downloadId?: string; 16 | onStart?: (cancel: () => void) => void; 17 | baseFilename: string; 18 | videoFilename: string; 19 | thumbnailFilename: string; 20 | videoPath: string; 21 | thumbnailPath: string; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /backend/drizzle/0004_video_downloads.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `video_downloads` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `source_video_id` text NOT NULL, 4 | `source_url` text NOT NULL, 5 | `platform` text NOT NULL, 6 | `video_id` text, 7 | `title` text, 8 | `author` text, 9 | `status` text DEFAULT 'exists' NOT NULL, 10 | `downloaded_at` integer NOT NULL, 11 | `deleted_at` integer 12 | ); 13 | --> statement-breakpoint 14 | CREATE INDEX `video_downloads_source_video_id_idx` ON `video_downloads` (`source_video_id`); 15 | --> statement-breakpoint 16 | CREATE INDEX `video_downloads_source_url_idx` ON `video_downloads` (`source_url`); 17 | 18 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | import packageJson from './package.json'; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | server: { 9 | port: 5556, 10 | watch: { 11 | usePolling: true, 12 | interval: 2000, 13 | ignored: ['/node_modules/'] 14 | }, 15 | }, 16 | define: { 17 | 'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version) 18 | }, 19 | test: { 20 | globals: true, 21 | environment: 'jsdom', 22 | setupFiles: './src/setupTests.ts', 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /backend/src/version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MyTube Backend Version Information 3 | */ 4 | 5 | export const VERSION = { 6 | number: "1.1.0", 7 | buildDate: new Date().toISOString().split("T")[0], 8 | name: "MyTube Backend Server", 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 | -------------------------------------------------------------------------------- /frontend/src/components/Header/types.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Video } from "../../types"; 2 | 3 | export interface DownloadInfo { 4 | id: string; 5 | title: string; 6 | timestamp?: number; 7 | filename?: string; 8 | totalSize?: string; 9 | downloadedSize?: string; 10 | progress?: number; 11 | speed?: string; 12 | } 13 | 14 | export interface HeaderProps { 15 | onSubmit: (url: string) => Promise; 16 | onSearch: (term: string) => Promise; 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 | 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 | MyTube Logo 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 |