This commit is contained in:
Peifan Li
2025-03-08 22:35:41 -05:00
commit 959c54b6f2
40 changed files with 10817 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build files
dist
dist-ssr
build
*.local
# Environment variables
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Backend specific
# Ignore all files in uploads directory and subdirectories
backend/uploads/*
backend/uploads/videos/*
backend/uploads/images/*
# But keep the directory structure
!backend/uploads/.gitkeep
!backend/uploads/videos/.gitkeep
!backend/uploads/images/.gitkeep
# Ignore the videos database
backend/videos.json

107
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,107 @@
# Deployment Guide for MyTube
This guide explains how to deploy MyTube to a QNAP Container Station.
## Prerequisites
- Docker Hub account
- QNAP NAS with Container Station installed
- Docker installed on your development machine
## Docker Images
The application is containerized into two Docker images:
1. Frontend: `franklioxygen/mytube:frontend-latest`
2. Backend: `franklioxygen/mytube:backend-latest`
## Deployment Process
### 1. Build and Push Docker Images
Use the provided script to build and push the Docker images to Docker Hub:
```bash
# Make the script executable
chmod +x build-and-push.sh
# Run the script
./build-and-push.sh
```
The script will:
- Build the backend and frontend Docker images optimized for amd64 architecture
- Push the images to Docker Hub under your account (franklioxygen)
### 2. Deploy on QNAP Container Station
1. Copy the `docker-compose.yml` file to your QNAP NAS
2. Open Container Station on your QNAP
3. Navigate to the "Applications" tab
4. Click on "Create" and select "Create from YAML"
5. Upload the `docker-compose.yml` file or paste its contents
6. Click "Create" to deploy the application
#### Volume Paths on QNAP
The docker-compose file is configured to use the following specific paths on your QNAP:
```yaml
volumes:
- /share/CACHEDEV2_DATA/Medias/MyTube/uploads:/app/uploads
- /share/CACHEDEV2_DATA/Medias/MyTube/data:/app/data
```
Ensure these directories exist on your QNAP before deployment. If they don't exist, create them:
```bash
mkdir -p /share/CACHEDEV2_DATA/Medias/MyTube/uploads
mkdir -p /share/CACHEDEV2_DATA/Medias/MyTube/data
```
### 3. Access the Application
Once deployed:
- Frontend will be accessible at: http://192.168.1.105:5556
- Backend API will be accessible at: http://192.168.1.105:5551/api
## Volume Persistence
The Docker Compose setup includes a volume mount for the backend to store downloaded videos:
```yaml
volumes:
backend-data:
driver: local
```
This ensures that your downloaded videos are persistent even if the container is restarted.
## Network Configuration
The services are connected through a dedicated bridge network called `mytube-network`.
## Environment Variables
The Docker images have been configured with the following default environment variables:
### Frontend
- `VITE_API_URL`: http://192.168.1.105:5551/api
- `VITE_BACKEND_URL`: http://192.168.1.105:5551
### Backend
- `PORT`: 5551
## Troubleshooting
If you encounter issues:
1. Check if the Docker images were successfully pushed to Docker Hub
2. Verify that Container Station has internet access to pull the images
3. Check Container Station logs for any deployment errors
4. Ensure ports 5551 and 5556 are not being used by other services on your QNAP
5. If backend fails with Python-related errors, verify that the container has Python installed

126
README.md Normal file
View File

@@ -0,0 +1,126 @@
# MyTube
A YouTube/Bilibili video downloader and player application that allows you to download and save YouTube/Bilibili videos locally, along with their thumbnails. Organize your videos into collections for easy access and management.
## Features
- Download YouTube videos with a simple URL input
- Automatically save video thumbnails
- Browse and play downloaded videos
- View videos by specific authors
- Organize videos into collections
- Add or remove videos from collections
- Responsive design that works on all devices
## Directory Structure
```
mytube/
├── backend/ # Express.js backend
│ ├── uploads/ # Uploaded files directory
│ │ ├── videos/ # Downloaded videos
│ │ └── images/ # Downloaded thumbnails
│ └── server.js # Main server file
├── frontend/ # React.js frontend
│ ├── src/ # Source code
│ │ ├── components/ # React components
│ │ └── pages/ # Page components
│ └── index.html # HTML entry point
├── start.sh # Unix/Mac startup script
├── start.bat # Windows startup script
└── package.json # Root package.json for running both apps
```
## Getting Started
### Prerequisites
- Node.js (v14 or higher)
- npm (v6 or higher)
### Installation
1. Clone the repository:
```
git clone <repository-url>
cd mytube
```
2. Install dependencies:
```
npm run install:all
```
This will install dependencies for the root project, frontend, and backend.
#### Using npm Scripts
Alternatively, you can use npm scripts:
```
npm run dev # Start both frontend and backend in development mode
```
Other available scripts:
```
npm run start # Start both frontend and backend in production mode
npm run build # Build the frontend for production
```
### Accessing the Application
- Frontend: http://localhost:5556
- Backend API: http://localhost:5551
## API Endpoints
- `POST /api/download` - Download a YouTube video
- `GET /api/videos` - Get all downloaded videos
- `GET /api/videos/:id` - Get a specific video
- `DELETE /api/videos/:id` - Delete a video
## Collections Feature
MyTube allows you to organize your videos into collections:
- **Create Collections**: Create custom collections to categorize your videos
- **Add to Collections**: Add videos to collections directly from the video player
- **Remove from Collections**: Remove videos from collections with a single click
- **Browse Collections**: View all your collections in the sidebar and browse videos by collection
- **Note**: A video can only belong to one collection at a time
## User Interface
The application features a modern, dark-themed UI with:
- Responsive design that works on desktop and mobile devices
- Video grid layout for easy browsing
- Video player with collection management
- Author and collection filtering
- Search functionality for finding videos
## Environment Variables
The application uses environment variables for configuration. Here's how to set them up:
### Frontend (.env file in frontend directory)
```
VITE_API_URL=http://{host}:{backend_port}/api
VITE_BACKEND_URL=http://{host}:{backend_port}
```
### Backend (.env file in backend directory)
```
PORT={backend_port}
```
Copy the `.env.example` files in both frontend and backend directories to create your own `.env` files and replace the placeholders with your desired values.
## License
MIT

1
backend/.env.example Normal file
View File

@@ -0,0 +1 @@
PORT={backend_port}

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:21-alpine
WORKDIR /app
# Install Python and other dependencies needed for youtube-dl-exec
RUN apk add --no-cache python3 ffmpeg py3-pip && \
ln -sf python3 /usr/bin/python
COPY package*.json ./
# Skip Python check as we've already installed it
ENV YOUTUBE_DL_SKIP_PYTHON_CHECK=1
RUN npm install
COPY . .
# Set environment variables
ENV PORT=5551
# Create uploads directory
RUN mkdir -p uploads
RUN mkdir -p data
EXPOSE 5551
CMD ["node", "server.js"]

2923
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "backend",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "Backend for MyTube video streaming website",
"dependencies": {
"axios": "^1.8.1",
"bilibili-save-nodejs": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.18.2",
"fs-extra": "^11.2.0",
"multer": "^1.4.5-lts.1",
"path": "^0.12.7",
"youtube-dl-exec": "^2.4.17"
},
"devDependencies": {
"nodemon": "^3.0.3"
}
}

673
backend/server.js Normal file
View File

@@ -0,0 +1,673 @@
// Load environment variables from .env file
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const multer = require("multer");
const path = require("path");
const fs = require("fs-extra");
const youtubedl = require("youtube-dl-exec");
const axios = require("axios");
const { downloadByVedioPath } = require("bilibili-save-nodejs");
const app = express();
const PORT = process.env.PORT || 5551;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Create uploads directory and subdirectories if they don't exist
const uploadsDir = path.join(__dirname, "uploads");
const videosDir = path.join(uploadsDir, "videos");
const imagesDir = path.join(uploadsDir, "images");
fs.ensureDirSync(uploadsDir);
fs.ensureDirSync(videosDir);
fs.ensureDirSync(imagesDir);
// Serve static files from the uploads directory
app.use("/videos", express.static(videosDir));
app.use("/images", express.static(imagesDir));
// Helper function to check if a string is a valid URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
// Helper function to check if a URL is from Bilibili
function isBilibiliUrl(url) {
return url.includes("bilibili.com");
}
// Helper function to trim Bilibili URL by removing query parameters
function trimBilibiliUrl(url) {
try {
// Extract the base URL and video ID
const regex =
/(https?:\/\/(?:www\.)?bilibili\.com\/video\/(?:BV[\w]+|av\d+))/i;
const match = url.match(regex);
if (match && match[1]) {
console.log(`Trimmed Bilibili URL from "${url}" to "${match[1]}"`);
return match[1];
}
// If regex doesn't match, just remove query parameters
const urlObj = new URL(url);
const cleanUrl = `${urlObj.origin}${urlObj.pathname}`;
console.log(`Trimmed Bilibili URL from "${url}" to "${cleanUrl}"`);
return cleanUrl;
} catch (error) {
console.error("Error trimming Bilibili URL:", error);
return url; // Return original URL if there's an error
}
}
// Helper function to extract video ID from Bilibili URL
function extractBilibiliVideoId(url) {
// Extract BV ID from URL
const bvMatch = url.match(/BV\w+/);
if (bvMatch) {
return bvMatch[0];
}
// Extract av ID from URL
const avMatch = url.match(/av(\d+)/);
if (avMatch) {
return `av${avMatch[1]}`;
}
return null;
}
// Helper function to create a safe filename that preserves non-Latin characters
function sanitizeFilename(filename) {
// Replace only unsafe characters for filesystems
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
return filename
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
.replace(/\s+/g, "_"); // Replace spaces with underscores
}
// Helper function to download Bilibili video
async function downloadBilibiliVideo(url, videoPath, thumbnailPath) {
try {
// Create a temporary directory for the download
const tempDir = path.join(videosDir, "temp");
fs.ensureDirSync(tempDir);
console.log("Downloading Bilibili video to temp directory:", tempDir);
// Download the video using the package
await downloadByVedioPath({
url: url,
type: "mp4",
folder: tempDir,
});
console.log("Download completed, checking for video file");
// Find the downloaded file
const files = fs.readdirSync(tempDir);
console.log("Files in temp directory:", files);
const videoFile = files.find((file) => file.endsWith(".mp4"));
if (!videoFile) {
throw new Error("Downloaded video file not found");
}
console.log("Found video file:", videoFile);
// Move the file to the desired location
const tempVideoPath = path.join(tempDir, videoFile);
fs.moveSync(tempVideoPath, videoPath, { overwrite: true });
console.log("Moved video file to:", videoPath);
// Clean up temp directory
fs.removeSync(tempDir);
// Extract video title from filename (remove extension)
const videoTitle = videoFile.replace(".mp4", "") || "Bilibili Video";
// Try to get thumbnail from Bilibili
let thumbnailSaved = false;
let thumbnailUrl = null;
const videoId = extractBilibiliVideoId(url);
console.log("Extracted video ID:", videoId);
if (videoId) {
try {
// Try to get video info from Bilibili API
const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
console.log("Fetching video info from API:", apiUrl);
const response = await axios.get(apiUrl);
if (response.data && response.data.data) {
const videoInfo = response.data.data;
thumbnailUrl = videoInfo.pic;
console.log("Got video info from API:", {
title: videoInfo.title,
author: videoInfo.owner?.name,
thumbnailUrl: thumbnailUrl,
});
if (thumbnailUrl) {
// Download thumbnail
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(thumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", thumbnailPath);
return {
title: videoInfo.title || videoTitle,
author: videoInfo.owner?.name || "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: thumbnailUrl,
thumbnailSaved,
};
}
}
} catch (thumbnailError) {
console.error("Error downloading Bilibili thumbnail:", thumbnailError);
}
}
console.log("Using basic video info");
// Return basic info if we couldn't get detailed info
return {
title: videoTitle,
author: "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: null,
thumbnailSaved: false,
};
} catch (error) {
console.error("Error in downloadBilibiliVideo:", error);
// Make sure we clean up the temp directory if it exists
const tempDir = path.join(videosDir, "temp");
if (fs.existsSync(tempDir)) {
fs.removeSync(tempDir);
}
// Return a default object to prevent undefined errors
return {
title: "Bilibili Video",
author: "Bilibili User",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: null,
thumbnailSaved: false,
error: error.message,
};
}
}
// API endpoint to search for videos on YouTube
app.get("/api/search", async (req, res) => {
try {
const { query } = req.query;
if (!query) {
return res.status(400).json({ error: "Search query is required" });
}
console.log("Processing search request for query:", query);
// Use youtube-dl to search for videos
const searchResults = await youtubedl(`ytsearch5:${query}`, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
});
if (!searchResults || !searchResults.entries) {
return res.status(200).json({ results: [] });
}
// Format the search results
const formattedResults = searchResults.entries.map((entry) => ({
id: entry.id,
title: entry.title,
author: entry.uploader,
thumbnailUrl: entry.thumbnail,
duration: entry.duration,
viewCount: entry.view_count,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
source: "youtube",
}));
console.log(
`Found ${formattedResults.length} search results for "${query}"`
);
res.status(200).json({ results: formattedResults });
} catch (error) {
console.error("Error searching for videos:", error);
res.status(500).json({
error: "Failed to search for videos",
details: error.message,
});
}
});
// API endpoint to download a video (YouTube or Bilibili)
app.post("/api/download", async (req, res) => {
try {
const { youtubeUrl } = req.body;
let videoUrl = youtubeUrl; // Keep the parameter name for backward compatibility
if (!videoUrl) {
return res.status(400).json({ error: "Video URL is required" });
}
console.log("Processing download request for URL:", videoUrl);
// Check if the input is a valid URL
if (!isValidUrl(videoUrl)) {
// If not a valid URL, treat it as a search term
return res.status(400).json({
error: "Not a valid URL",
isSearchTerm: true,
searchTerm: videoUrl,
});
}
// Trim Bilibili URL if needed
if (isBilibiliUrl(videoUrl)) {
videoUrl = trimBilibiliUrl(videoUrl);
console.log("Using trimmed Bilibili URL:", videoUrl);
}
// Create a safe base filename (without extension)
const timestamp = Date.now();
const safeBaseFilename = `video_${timestamp}`;
// Add extensions for video and thumbnail
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
const videoPath = path.join(videosDir, videoFilename);
const thumbnailPath = path.join(imagesDir, thumbnailFilename);
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
// Check if it's a Bilibili URL
if (isBilibiliUrl(videoUrl)) {
console.log("Detected Bilibili URL");
try {
// Download Bilibili video
const bilibiliInfo = await downloadBilibiliVideo(
videoUrl,
videoPath,
thumbnailPath
);
if (!bilibiliInfo) {
throw new Error("Failed to get Bilibili video info");
}
console.log("Bilibili download info:", bilibiliInfo);
videoTitle = bilibiliInfo.title || "Bilibili Video";
videoAuthor = bilibiliInfo.author || "Bilibili User";
videoDate =
bilibiliInfo.date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = bilibiliInfo.thumbnailUrl;
thumbnailSaved = bilibiliInfo.thumbnailSaved;
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Rename the files
const newVideoPath = path.join(videosDir, newVideoFilename);
const newThumbnailPath = path.join(imagesDir, newThumbnailFilename);
if (fs.existsSync(videoPath)) {
fs.renameSync(videoPath, newVideoPath);
console.log("Renamed video file to:", newVideoFilename);
finalVideoFilename = newVideoFilename;
} else {
console.log("Video file not found at:", videoPath);
}
if (thumbnailSaved && fs.existsSync(thumbnailPath)) {
fs.renameSync(thumbnailPath, newThumbnailPath);
console.log("Renamed thumbnail file to:", newThumbnailFilename);
finalThumbnailFilename = newThumbnailFilename;
}
} catch (bilibiliError) {
console.error("Error in Bilibili download process:", bilibiliError);
return res.status(500).json({
error: "Failed to download Bilibili video",
details: bilibiliError.message,
});
}
} else {
console.log("Detected YouTube URL");
try {
// Get YouTube video info first
const info = await youtubedl(videoUrl, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
});
console.log("YouTube video info:", {
title: info.title,
uploader: info.uploader,
upload_date: info.upload_date,
});
videoTitle = info.title || "YouTube Video";
videoAuthor = info.uploader || "YouTube User";
videoDate =
info.upload_date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
videoTitle
)}_${timestamp}`;
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
// Update the filenames
finalVideoFilename = newVideoFilename;
finalThumbnailFilename = newThumbnailFilename;
// Update paths
const newVideoPath = path.join(videosDir, finalVideoFilename);
const newThumbnailPath = path.join(imagesDir, finalThumbnailFilename);
// Download the YouTube video
console.log("Downloading YouTube video to:", newVideoPath);
await youtubedl(videoUrl, {
output: newVideoPath,
format: "mp4",
});
console.log("YouTube video downloaded successfully");
// Download and save the thumbnail
thumbnailSaved = false;
// Download the thumbnail image
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
const thumbnailResponse = await axios({
method: "GET",
url: thumbnailUrl,
responseType: "stream",
});
const thumbnailWriter = fs.createWriteStream(newThumbnailPath);
thumbnailResponse.data.pipe(thumbnailWriter);
await new Promise((resolve, reject) => {
thumbnailWriter.on("finish", () => {
thumbnailSaved = true;
resolve();
});
thumbnailWriter.on("error", reject);
});
console.log("Thumbnail saved to:", newThumbnailPath);
} catch (thumbnailError) {
console.error("Error downloading thumbnail:", thumbnailError);
// Continue even if thumbnail download fails
}
}
} catch (youtubeError) {
console.error("Error in YouTube download process:", youtubeError);
return res.status(500).json({
error: "Failed to download YouTube video",
details: youtubeError.message,
});
}
}
// Create metadata for the video
const videoData = {
id: timestamp.toString(),
title: videoTitle || "Video",
author: videoAuthor || "Unknown",
date:
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
source: isBilibiliUrl(videoUrl) ? "bilibili" : "youtube",
sourceUrl: videoUrl,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : null,
thumbnailUrl: thumbnailUrl,
videoPath: `/videos/${finalVideoFilename}`,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
: null,
addedAt: new Date().toISOString(),
};
console.log("Video metadata:", videoData);
// Read existing videos data
let videos = [];
const videosDataPath = path.join(__dirname, "videos.json");
if (fs.existsSync(videosDataPath)) {
videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
}
// Add new video to the list
videos.unshift(videoData);
// Save updated videos data
fs.writeFileSync(videosDataPath, JSON.stringify(videos, null, 2));
console.log("Video added to database");
res.status(200).json({ success: true, video: videoData });
} catch (error) {
console.error("Error downloading video:", error);
res
.status(500)
.json({ error: "Failed to download video", details: error.message });
}
});
// API endpoint to get all videos
app.get("/api/videos", (req, res) => {
try {
const videosDataPath = path.join(__dirname, "videos.json");
if (!fs.existsSync(videosDataPath)) {
return res.status(200).json([]);
}
const videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
res.status(200).json(videos);
} catch (error) {
console.error("Error fetching videos:", error);
res.status(500).json({ error: "Failed to fetch videos" });
}
});
// API endpoint to get a single video by ID
app.get("/api/videos/:id", (req, res) => {
try {
const { id } = req.params;
const videosDataPath = path.join(__dirname, "videos.json");
if (!fs.existsSync(videosDataPath)) {
return res.status(404).json({ error: "Video not found" });
}
const videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
const video = videos.find((v) => v.id === id);
if (!video) {
return res.status(404).json({ error: "Video not found" });
}
res.status(200).json(video);
} catch (error) {
console.error("Error fetching video:", error);
res.status(500).json({ error: "Failed to fetch video" });
}
});
// API endpoint to delete a video
app.delete("/api/videos/:id", (req, res) => {
try {
const { id } = req.params;
const videosDataPath = path.join(__dirname, "videos.json");
if (!fs.existsSync(videosDataPath)) {
return res.status(404).json({ error: "Video not found" });
}
// Read existing videos
let videos = JSON.parse(fs.readFileSync(videosDataPath, "utf8"));
// Find the video to delete
const videoToDelete = videos.find((v) => v.id === id);
if (!videoToDelete) {
return res.status(404).json({ error: "Video not found" });
}
// Remove the video file from the videos directory
if (videoToDelete.videoFilename) {
const videoFilePath = path.join(videosDir, videoToDelete.videoFilename);
if (fs.existsSync(videoFilePath)) {
fs.unlinkSync(videoFilePath);
}
}
// Remove the thumbnail file from the images directory
if (videoToDelete.thumbnailFilename) {
const thumbnailFilePath = path.join(
imagesDir,
videoToDelete.thumbnailFilename
);
if (fs.existsSync(thumbnailFilePath)) {
fs.unlinkSync(thumbnailFilePath);
}
}
// Filter out the deleted video from the videos array
videos = videos.filter((v) => v.id !== id);
// Save the updated videos array
fs.writeFileSync(videosDataPath, JSON.stringify(videos, null, 2));
res
.status(200)
.json({ success: true, message: "Video deleted successfully" });
} catch (error) {
console.error("Error deleting video:", error);
res.status(500).json({ error: "Failed to delete video" });
}
});
// Collections API endpoints
app.get("/api/collections", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collections are managed client-side" });
} catch (error) {
console.error("Error getting collections:", error);
res
.status(500)
.json({ success: false, error: "Failed to get collections" });
}
});
app.post("/api/collections", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collection created (client-side)" });
} catch (error) {
console.error("Error creating collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to create collection" });
}
});
app.put("/api/collections/:id", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collection updated (client-side)" });
} catch (error) {
console.error("Error updating collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to update collection" });
}
});
app.delete("/api/collections/:id", (req, res) => {
try {
// Collections are stored client-side in localStorage
// This endpoint is just a placeholder for future server-side implementation
res.json({ success: true, message: "Collection deleted (client-side)" });
} catch (error) {
console.error("Error deleting collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to delete collection" });
}
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

0
backend/uploads/.gitkeep Normal file
View File

41
build-and-push.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -e
DOCKER_PATH="/Applications/Docker.app/Contents/Resources/bin/docker"
USERNAME="franklioxygen"
BACKEND_IMAGE="$USERNAME/mytube:backend-latest"
FRONTEND_IMAGE="$USERNAME/mytube:frontend-latest"
# Ensure Docker is running
echo "🔍 Checking if Docker is running..."
$DOCKER_PATH ps > /dev/null 2>&1 || { echo "❌ Docker is not running. Please start Docker and try again."; exit 1; }
echo "✅ Docker is running!"
# Build backend image with no-cache to force rebuild
echo "🏗️ Building backend image..."
cd backend
$DOCKER_PATH build --no-cache --platform linux/amd64 -t $BACKEND_IMAGE .
cd ..
# Build frontend image with no-cache to force rebuild
echo "🏗️ Building frontend image with correct environment variables..."
cd frontend
$DOCKER_PATH build --no-cache --platform linux/amd64 \
--build-arg VITE_API_URL=http://192.168.1.105:5551/api \
--build-arg VITE_BACKEND_URL=http://192.168.1.105:5551 \
-t $FRONTEND_IMAGE .
cd ..
# Push images to Docker Hub
echo "🚀 Pushing images to Docker Hub..."
$DOCKER_PATH push $BACKEND_IMAGE
$DOCKER_PATH push $FRONTEND_IMAGE
echo "✅ Successfully built and pushed images to Docker Hub!"
echo "Backend image: $BACKEND_IMAGE"
echo "Frontend image: $FRONTEND_IMAGE"
echo ""
echo "To deploy to your QNAP Container Station at 192.168.1.105:"
echo "1. Upload the docker-compose.yml file to your QNAP"
echo "2. Use Container Station to deploy the stack using this compose file"
echo "3. Access your application at http://192.168.1.105:5556"

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
version: '3.8'
services:
backend:
image: franklioxygen/mytube:backend-latest
pull_policy: always
container_name: mytube-backend
ports:
- "5551:5551"
volumes:
- /share/CACHEDEV2_DATA/Medias/MyTube/uploads:/app/uploads
- /share/CACHEDEV2_DATA/Medias/MyTube/data:/app/data
environment:
- PORT=5551
restart: unless-stopped
networks:
- mytube-network
frontend:
image: franklioxygen/mytube:frontend-latest
pull_policy: always
container_name: mytube-frontend
ports:
- "5556:5556"
environment:
- VITE_API_URL=http://192.168.1.105:5551/api
- VITE_BACKEND_URL=http://192.168.1.105:5551
depends_on:
- backend
restart: unless-stopped
networks:
- mytube-network
volumes:
backend-data:
driver: local
networks:
mytube-network:
driver: bridge

2
frontend/.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:5551/api
VITE_BACKEND_URL=http://localhost:5551

30
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env.local
.env.development.local
.env.test.local
.env.production.local

23
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:21-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Create a production build with environment variables
ARG VITE_API_URL=http://192.168.1.105:5551/api
ARG VITE_BACKEND_URL=http://192.168.1.105:5551
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BACKEND_URL=${VITE_BACKEND_URL}
RUN npm run build
# Production stage
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5556
CMD ["nginx", "-g", "daemon off;"]

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@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
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
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.

33
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

23
frontend/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ff3e3e' d='M10,15L15.19,12L10,9V15M21.56,7.17C21.69,7.64 21.78,8.27 21.84,9.07C21.91,9.87 21.94,10.56 21.94,11.16L22,12C22,14.19 21.84,15.8 21.56,16.83C21.31,17.73 20.73,18.31 19.83,18.56C19.36,18.69 18.5,18.78 17.18,18.84C15.88,18.91 14.69,18.94 13.59,18.94L12,19C7.81,19 5.2,18.84 4.17,18.56C3.27,18.31 2.69,17.73 2.44,16.83C2.31,16.36 2.22,15.73 2.16,14.93C2.09,14.13 2.06,13.44 2.06,12.84L2,12C2,9.81 2.16,8.2 2.44,7.17C2.69,6.27 3.27,5.69 4.17,5.44C4.64,5.31 5.5,5.22 6.82,5.16C8.12,5.09 9.31,5.06 10.41,5.06L12,5C16.19,5 18.8,5.16 19.83,5.44C20.73,5.69 21.31,6.27 21.56,7.17Z'/%3E%3C/svg%3E"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Download and watch YouTube videos locally"
/>
<meta name="theme-color" content="#ff3e3e" />
<title>MyTube - Download & Watch YouTube Videos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

16
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 5556;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, max-age=31536000";
}
}

3067
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.8.1",
"dotenv": "^16.4.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"vite": "^6.2.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#ff3e3e" d="M10,15L15.19,12L10,9V15M21.56,7.17C21.69,7.64 21.78,8.27 21.84,9.07C21.91,9.87 21.94,10.56 21.94,11.16L22,12C22,14.19 21.84,15.8 21.56,16.83C21.31,17.73 20.73,18.31 19.83,18.56C19.36,18.69 18.5,18.78 17.18,18.84C15.88,18.91 14.69,18.94 13.59,18.94L12,19C7.81,19 5.2,18.84 4.17,18.56C3.27,18.31 2.69,17.73 2.44,16.83C2.31,16.36 2.22,15.73 2.16,14.93C2.09,14.13 2.06,13.44 2.06,12.84L2,12C2,9.81 2.16,8.2 2.44,7.17C2.69,6.27 3.27,5.69 4.17,5.44C4.64,5.31 5.5,5.22 6.82,5.16C8.12,5.09 9.31,5.06 10.41,5.06L12,5C16.19,5 18.8,5.16 19.83,5.44C20.73,5.69 21.31,6.27 21.56,7.17Z"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1392
frontend/src/App.css Normal file

File diff suppressed because it is too large Load Diff

532
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,532 @@
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import './App.css';
import Header from './components/Header';
import AuthorVideos from './pages/AuthorVideos';
import CollectionPage from './pages/CollectionPage';
import Home from './pages/Home';
import SearchResults from './pages/SearchResults';
import VideoPlayer from './pages/VideoPlayer';
const API_URL = import.meta.env.VITE_API_URL;
const DOWNLOAD_STATUS_KEY = 'mytube_download_status';
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
const COLLECTIONS_KEY = 'mytube_collections';
// Helper function to get download status from localStorage
const getStoredDownloadStatus = () => {
try {
const savedStatus = localStorage.getItem(DOWNLOAD_STATUS_KEY);
if (!savedStatus) return null;
const parsedStatus = JSON.parse(savedStatus);
// Check if the saved status is too old (stale)
if (parsedStatus.timestamp && Date.now() - parsedStatus.timestamp > DOWNLOAD_TIMEOUT) {
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
return null;
}
return parsedStatus;
} catch (error) {
console.error('Error parsing download status from localStorage:', error);
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
return null;
}
};
// Helper function to get collections from localStorage
const getStoredCollections = () => {
try {
const savedCollections = localStorage.getItem(COLLECTIONS_KEY);
if (!savedCollections) return [];
return JSON.parse(savedCollections);
} catch (error) {
console.error('Error parsing collections from localStorage:', error);
localStorage.removeItem(COLLECTIONS_KEY);
return [];
}
};
function App() {
const [videos, setVideos] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [localSearchResults, setLocalSearchResults] = useState([]);
const [loading, setLoading] = useState(true);
const [youtubeLoading, setYoutubeLoading] = useState(false);
const [error, setError] = useState(null);
const [isSearchMode, setIsSearchMode] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [collections, setCollections] = useState(getStoredCollections());
// Reference to the current search request's abort controller
const searchAbortController = useRef(null);
// Get initial download status from localStorage
const initialStatus = getStoredDownloadStatus();
const [downloadingTitle, setDownloadingTitle] = useState(
initialStatus ? initialStatus.title || '' : ''
);
// Save collections to localStorage whenever they change
useEffect(() => {
localStorage.setItem(COLLECTIONS_KEY, JSON.stringify(collections));
}, [collections]);
// Fetch videos on component mount
useEffect(() => {
fetchVideos();
// Also check for stale download status
if (downloadingTitle) {
const checkDownloadStatus = async () => {
try {
// Make a simple API call to check if the server is still processing the download
await axios.get(`${API_URL}/videos`);
// If we've been downloading for more than 3 minutes, assume it's done or failed
const status = getStoredDownloadStatus();
if (status && status.timestamp && Date.now() - status.timestamp > 3 * 60 * 1000) {
console.log('Download has been running too long, clearing status');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
setDownloadingTitle('');
}
} catch (error) {
console.error('Error checking download status:', error);
}
};
checkDownloadStatus();
}
}, [downloadingTitle]);
// Set up localStorage and event listeners
useEffect(() => {
console.log('Setting up localStorage and event listeners');
// Set up event listener for storage changes (for multi-tab support)
const handleStorageChange = (e) => {
if (e.key === DOWNLOAD_STATUS_KEY) {
try {
const newStatus = e.newValue ? JSON.parse(e.newValue) : { title: '' };
console.log('Storage changed, new status:', newStatus);
setDownloadingTitle(newStatus.title || '');
} catch (error) {
console.error('Error handling storage change:', error);
}
} else if (e.key === COLLECTIONS_KEY) {
try {
const newCollections = e.newValue ? JSON.parse(e.newValue) : [];
setCollections(newCollections);
} catch (error) {
console.error('Error handling collections storage change:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
// Set up periodic check for stale download status
const checkDownloadStatus = () => {
const status = getStoredDownloadStatus();
if (!status && downloadingTitle) {
console.log('Clearing stale download status');
setDownloadingTitle('');
}
};
// Check every minute
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(statusCheckInterval);
};
}, [downloadingTitle]);
// Update localStorage whenever downloadingTitle changes
useEffect(() => {
console.log('Download title changed:', downloadingTitle);
if (downloadingTitle) {
const statusData = {
title: downloadingTitle,
timestamp: Date.now()
};
console.log('Saving to localStorage:', statusData);
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
console.log('Removing from localStorage');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
}
}, [downloadingTitle]);
const fetchVideos = async () => {
try {
setLoading(true);
const response = await axios.get(`${API_URL}/videos`);
setVideos(response.data);
setError(null);
// Check if we need to clear a stale download status
if (downloadingTitle) {
const status = getStoredDownloadStatus();
if (!status) {
console.log('Clearing download status after fetching videos');
setDownloadingTitle('');
}
}
} catch (err) {
console.error('Error fetching videos:', err);
setError('Failed to load videos. Please try again later.');
} finally {
setLoading(false);
}
};
const handleVideoSubmit = async (videoUrl) => {
try {
setLoading(true);
// Extract title from URL for display during download
let displayTitle = videoUrl;
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
displayTitle = 'YouTube video';
} else if (videoUrl.includes('bilibili.com') || videoUrl.includes('b23.tv')) {
displayTitle = 'Bilibili video';
}
// Set download status before making the API call
setDownloadingTitle(displayTitle);
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
setVideos(prevVideos => [response.data.video, ...prevVideos]);
setIsSearchMode(false);
return { success: true };
} catch (err) {
console.error('Error downloading video:', err);
// Check if the error is because the input is a search term
if (err.response?.data?.isSearchTerm) {
// Handle as search term
return await handleSearch(err.response.data.searchTerm);
}
return {
success: false,
error: err.response?.data?.error || 'Failed to download video. Please try again.'
};
} finally {
setLoading(false);
setDownloadingTitle('');
}
};
const searchLocalVideos = (query) => {
if (!query || !videos.length) return [];
const searchTermLower = query.toLowerCase();
return videos.filter(video =>
video.title.toLowerCase().includes(searchTermLower) ||
video.author.toLowerCase().includes(searchTermLower)
);
};
const handleSearch = async (query) => {
// Don't enter search mode if the query is empty
if (!query || query.trim() === '') {
resetSearch();
return { success: false, error: 'Please enter a search term' };
}
try {
// Cancel any previous search request
if (searchAbortController.current) {
searchAbortController.current.abort();
}
// Create a new abort controller for this request
searchAbortController.current = new AbortController();
const signal = searchAbortController.current.signal;
// Set search mode and term immediately
setIsSearchMode(true);
setSearchTerm(query);
// Search local videos first (synchronously)
const localResults = searchLocalVideos(query);
setLocalSearchResults(localResults);
// Set loading state only for YouTube results
setYoutubeLoading(true);
// Then search YouTube asynchronously
try {
const response = await axios.get(`${API_URL}/search`, {
params: { query },
signal: signal // Pass the abort signal to axios
});
// Only update results if the request wasn't aborted
if (!signal.aborted) {
setSearchResults(response.data.results);
}
} catch (youtubeErr) {
// Don't handle if it's an abort error
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
console.error('Error searching YouTube:', youtubeErr);
}
// Don't set overall error if only YouTube search fails
} finally {
// Only update loading state if the request wasn't aborted
if (!signal.aborted) {
setYoutubeLoading(false);
}
}
return { success: true };
} catch (err) {
// Don't handle if it's an abort error
if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
console.error('Error in search process:', err);
// Even if there's an error in the overall process,
// we still want to show local results if available
const localResults = searchLocalVideos(query);
if (localResults.length > 0) {
setLocalSearchResults(localResults);
setIsSearchMode(true);
setSearchTerm(query);
return { success: true };
}
return {
success: false,
error: 'Failed to search. Please try again.'
};
}
} finally {
// Only update loading state if the request wasn't aborted
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
setLoading(false);
}
}
};
const handleDeleteVideo = async (id) => {
try {
const response = await axios.delete(`${API_URL}/videos/${id}`);
if (response.data.success) {
// Remove the video from the videos array
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
// Also remove the video from all collections
setCollections(prevCollections =>
prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(videoId => videoId !== id)
})).filter(collection => collection.videos.length > 0)
);
return { success: true };
} else {
return { success: false, error: response.data.error || 'Failed to delete video' };
}
} catch (error) {
console.error('Error deleting video:', error);
return {
success: false,
error: error.response?.data?.error || 'An error occurred while deleting the video'
};
}
};
const handleDownloadFromSearch = async (videoUrl, title) => {
// Abort any ongoing search request
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
setIsSearchMode(false);
// If title is provided, use it for the downloading message
if (title) {
setDownloadingTitle(title);
}
return await handleVideoSubmit(videoUrl);
};
// For debugging
useEffect(() => {
console.log('Current download status:', {
downloadingTitle,
isDownloading: !!downloadingTitle,
localStorage: localStorage.getItem(DOWNLOAD_STATUS_KEY)
});
}, [downloadingTitle]);
// Cleanup effect to abort any pending search requests when unmounting
useEffect(() => {
return () => {
// Abort any ongoing search request when component unmounts
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
};
}, []);
// Update the resetSearch function to abort any ongoing search
const resetSearch = () => {
// Abort any ongoing search request
if (searchAbortController.current) {
searchAbortController.current.abort();
searchAbortController.current = null;
}
// Reset search-related state
setIsSearchMode(false);
setSearchTerm('');
setSearchResults([]);
setLocalSearchResults([]);
setYoutubeLoading(false);
};
// Collection management functions
const handleCreateCollection = async (name, videoId = null) => {
// If videoId is provided, remove it from any other collections first
if (videoId) {
setCollections(prevCollections =>
prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(id => id !== videoId)
})).filter(collection => collection.videos.length > 0 || collection.id === 'temp')
);
}
const newCollection = {
id: Date.now().toString(),
name,
videos: videoId ? [videoId] : []
};
setCollections(prevCollections => [...prevCollections, newCollection]);
return newCollection;
};
const handleAddToCollection = async (collectionId, videoId) => {
// First, remove the video from any other collections
setCollections(prevCollections => {
// Step 1: Remove the video from all collections
const collectionsWithoutVideo = prevCollections.map(collection => ({
...collection,
videos: collection.id === collectionId
? collection.videos // Keep videos in the target collection
: collection.videos.filter(id => id !== videoId) // Remove from others
}));
// Step 2: Add the video to the selected collection if not already there
return collectionsWithoutVideo.map(collection => {
if (collection.id === collectionId && !collection.videos.includes(videoId)) {
return {
...collection,
videos: [...collection.videos, videoId]
};
}
return collection;
}).filter(collection => collection.videos.length > 0); // Remove empty collections
});
};
const handleRemoveFromCollection = async (videoId) => {
// Remove the video from all collections
setCollections(prevCollections =>
prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(id => id !== videoId)
})).filter(collection => collection.videos.length > 0) // Remove empty collections
);
};
return (
<Router>
<div className="app">
<Header
onSearch={handleSearch}
onSubmit={handleVideoSubmit}
isDownloading={!!downloadingTitle}
downloadingTitle={downloadingTitle}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={resetSearch}
/>
<main className="main-content">
<Routes>
<Route
path="/"
element={
<Home
videos={videos}
loading={loading}
error={error}
onDeleteVideo={handleDeleteVideo}
collections={collections}
/>
}
/>
<Route
path="/video/:id"
element={
<VideoPlayer
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onAddToCollection={handleAddToCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
/>
}
/>
<Route
path="/author/:author"
element={
<AuthorVideos
videos={videos}
onDeleteVideo={handleDeleteVideo}
/>
}
/>
<Route
path="/collection/:id"
element={
<CollectionPage
collections={collections}
videos={videos}
onDeleteVideo={handleDeleteVideo}
/>
}
/>
<Route
path="/search"
element={
<SearchResults
results={searchResults}
localResults={localSearchResults}
loading={youtubeLoading}
searchTerm={searchTerm}
onDownload={handleDownloadFromSearch}
/>
}
/>
</Routes>
</main>
</div>
</Router>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
const AuthorsList = ({ videos }) => {
const [isOpen, setIsOpen] = useState(false);
const [authors, setAuthors] = useState([]);
useEffect(() => {
// Extract unique authors from videos
if (videos && videos.length > 0) {
const uniqueAuthors = [...new Set(videos.map(video => video.author))]
.filter(author => author) // Filter out null/undefined authors
.sort((a, b) => a.localeCompare(b)); // Sort alphabetically
setAuthors(uniqueAuthors);
} else {
setAuthors([]);
}
}, [videos]);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
if (!authors.length) {
return null;
}
return (
<div className="authors-container">
{/* Mobile dropdown toggle */}
<div className="authors-dropdown-toggle" onClick={toggleDropdown}>
<h3>Authors</h3>
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}></span>
</div>
{/* Authors list - visible on desktop or when dropdown is open on mobile */}
<div className={`authors-list ${isOpen ? 'open' : ''}`}>
<h3 className="authors-title">Authors</h3>
<ul>
{authors.map(author => (
<li key={author} className="author-item">
<Link
to={`/author/${encodeURIComponent(author)}`}
className="author-link"
onClick={() => setIsOpen(false)} // Close dropdown when an author is selected
>
{author}
</Link>
</li>
))}
</ul>
</div>
</div>
);
};
export default AuthorsList;

View File

@@ -0,0 +1,44 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
const Collections = ({ collections }) => {
const [isOpen, setIsOpen] = useState(false);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
if (!collections || collections.length === 0) {
return null;
}
return (
<div className="collections-container">
{/* Mobile dropdown toggle */}
<div className="collections-dropdown-toggle" onClick={toggleDropdown}>
<h3>Collections</h3>
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}></span>
</div>
{/* Collections list - visible on desktop or when dropdown is open on mobile */}
<div className={`collections-list ${isOpen ? 'open' : ''}`}>
<h3 className="collections-title">Collections</h3>
<ul>
{collections.map(collection => (
<li key={collection.id} className="collection-item">
<Link
to={`/collection/${collection.id}`}
className="collection-link"
onClick={() => setIsOpen(false)} // Close dropdown when a collection is selected
>
{collection.name} ({collection.videos.length})
</Link>
</li>
))}
</ul>
</div>
</div>
);
};
export default Collections;

View File

@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
const Header = ({ onSubmit, onSearch, downloadingTitle, isDownloading }) => {
const [videoUrl, setVideoUrl] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
// Log props for debugging
useEffect(() => {
console.log('Header props:', { downloadingTitle, isDownloading });
}, [downloadingTitle, isDownloading]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!videoUrl.trim()) {
setError('Please enter a video URL or search term');
return;
}
// Simple validation for YouTube or Bilibili URL
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
// Check if input is a URL
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl);
setError('');
setIsSubmitting(true);
try {
if (isUrl) {
// Handle as URL for download
const result = await onSubmit(videoUrl);
if (result.success) {
setVideoUrl('');
} else if (result.isSearchTerm) {
// If backend determined it's a search term despite our check
const searchResult = await onSearch(videoUrl);
if (searchResult.success) {
setVideoUrl('');
navigate('/'); // Navigate to home which will show search results
} else {
setError(searchResult.error);
}
} else {
setError(result.error);
}
} else {
// Handle as search term
const result = await onSearch(videoUrl);
if (result.success) {
setVideoUrl('');
// Stay on home page which will show search results
navigate('/');
} else {
setError(result.error);
}
}
} catch (err) {
setError('An unexpected error occurred. Please try again.');
console.error(err);
} finally {
setIsSubmitting(false);
}
};
// Determine the input placeholder text based on download status
const getPlaceholderText = () => {
if (isDownloading && downloadingTitle) {
return `Downloading: ${downloadingTitle}...`;
}
return "Enter YouTube/Bilibili URL or search term";
};
return (
<header className="header">
<div className="header-content">
<Link to="/" className="logo">
<span style={{ color: '#ff3e3e' }}>My</span>
<span style={{ color: '#f0f0f0' }}>Tube</span>
</Link>
<form className="url-form" onSubmit={handleSubmit}>
<div className="form-group">
<input
type="text"
className={`url-input ${isDownloading ? 'downloading' : ''}`}
placeholder={getPlaceholderText()}
value={isDownloading ? '' : videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
disabled={isSubmitting || isDownloading}
aria-label="Video URL or search term"
/>
<button
type="submit"
className="submit-btn"
disabled={isSubmitting || isDownloading}
>
{isSubmitting ? 'Processing...' : isDownloading ? 'Downloading...' : 'Submit'}
</button>
</div>
{error && (
<div className="form-error">
{error}
</div>
)}
{isDownloading && (
<div className="download-status">
Downloading: {downloadingTitle}...
</div>
)}
</form>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,96 @@
import { useNavigate } from 'react-router-dom';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
const VideoCard = ({ video }) => {
const navigate = useNavigate();
// Format the date (assuming format YYYYMMDD from youtube-dl)
const formatDate = (dateString) => {
if (!dateString || dateString.length !== 8) {
return 'Unknown date';
}
const year = dateString.substring(0, 4);
const month = dateString.substring(4, 6);
const day = dateString.substring(6, 8);
return `${year}-${month}-${day}`;
};
// Use local thumbnail if available, otherwise fall back to the original URL
const thumbnailSrc = video.thumbnailPath
? `${BACKEND_URL}${video.thumbnailPath}`
: video.thumbnailUrl;
// Handle author click
const handleAuthorClick = (e) => {
e.stopPropagation();
navigate(`/author/${encodeURIComponent(video.author)}`);
};
// Handle video navigation
const handleVideoNavigation = () => {
navigate(`/video/${video.id}`);
};
// Get source icon
const getSourceIcon = () => {
if (video.source === 'bilibili') {
return (
<div className="source-icon bilibili-icon" title="Bilibili">
B
</div>
);
}
return (
<div className="source-icon youtube-icon" title="YouTube">
YT
</div>
);
};
return (
<div className="video-card">
<div
className="thumbnail-container clickable"
onClick={handleVideoNavigation}
aria-label={`Play ${video.title}`}
>
<img
src={thumbnailSrc}
alt={`${video.title} thumbnail`}
className="thumbnail"
loading="lazy"
onError={(e) => {
e.target.onerror = null;
e.target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
{getSourceIcon()}
</div>
<div className="video-info">
<h3
className="video-title clickable"
onClick={handleVideoNavigation}
>
{video.title}
</h3>
<div className="video-meta">
<span
className="author-link"
onClick={handleAuthorClick}
role="button"
tabIndex="0"
aria-label={`View all videos by ${video.author}`}
>
{video.author}
</span>
<span className="video-date">{formatDate(video.date)}</span>
</div>
</div>
</div>
);
};
export default VideoCard;

96
frontend/src/index.css Normal file
View File

@@ -0,0 +1,96 @@
/* CSS Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
background-color: #121212;
color: #f0f0f0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
}
/* Remove default styles for common elements */
a {
text-decoration: none;
color: inherit;
}
button, input, select, textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button {
cursor: pointer;
background: none;
border: none;
}
ul, ol {
list-style: none;
}
img, video {
max-width: 100%;
height: auto;
display: block;
}
/* Make the app container full width */
#root {
display: flex;
flex-direction: column;
min-height: 100%;
width: 100%;
}
/* Dark theme overrides */
:root {
color-scheme: dark;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
color: #ff3e3e;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
/* Scrollbar styling for dark theme */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,89 @@
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import VideoCard from '../components/VideoCard';
const API_URL = import.meta.env.VITE_API_URL;
const AuthorVideos = ({ videos: allVideos, onDeleteVideo }) => {
const { author } = useParams();
const navigate = useNavigate();
const [authorVideos, setAuthorVideos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// If videos are passed as props, filter them
if (allVideos && allVideos.length > 0) {
const filteredVideos = allVideos.filter(
video => video.author === author
);
setAuthorVideos(filteredVideos);
setLoading(false);
return;
}
// Otherwise fetch from API
const fetchVideos = async () => {
try {
const response = await axios.get(`${API_URL}/videos`);
// Filter videos by author
const filteredVideos = response.data.filter(
video => video.author === author
);
setAuthorVideos(filteredVideos);
setError(null);
} catch (err) {
console.error('Error fetching videos:', err);
setError('Failed to load videos. Please try again later.');
} finally {
setLoading(false);
}
};
fetchVideos();
}, [author, allVideos]);
const handleBack = () => {
navigate(-1);
};
if (loading) {
return <div className="loading">Loading videos...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
return (
<div className="author-videos-container">
<div className="author-header">
<button className="back-button" onClick={handleBack}>
&larr; Back
</button>
<div className="author-info">
<h2>Author: {decodeURIComponent(author)}</h2>
<span className="video-count">{authorVideos.length} video{authorVideos.length !== 1 ? 's' : ''}</span>
</div>
</div>
{authorVideos.length === 0 ? (
<div className="no-videos">No videos found for this author.</div>
) : (
<div className="videos-grid">
{authorVideos.map(video => (
<VideoCard
key={video.id}
video={video}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
))}
</div>
)}
</div>
);
};
export default AuthorVideos;

View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import VideoCard from '../components/VideoCard';
const CollectionPage = ({ collections, videos, onDeleteVideo }) => {
const { id } = useParams();
const navigate = useNavigate();
const [collection, setCollection] = useState(null);
const [collectionVideos, setCollectionVideos] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (collections && collections.length > 0) {
const foundCollection = collections.find(c => c.id === id);
if (foundCollection) {
setCollection(foundCollection);
// Find all videos that are in this collection
const videosInCollection = videos.filter(video =>
foundCollection.videos.includes(video.id)
);
setCollectionVideos(videosInCollection);
} else {
// Collection not found, redirect to home
navigate('/');
}
}
setLoading(false);
}, [id, collections, videos, navigate]);
const handleBack = () => {
navigate(-1);
};
if (loading) {
return <div className="loading">Loading collection...</div>;
}
if (!collection) {
return <div className="error">Collection not found</div>;
}
return (
<div className="collection-page">
<div className="collection-header">
<button className="back-button" onClick={handleBack}>
&larr; Back
</button>
<div className="collection-info">
<h2 className="collection-title">Collection: {collection.name}</h2>
<span className="video-count">{collectionVideos.length} video{collectionVideos.length !== 1 ? 's' : ''}</span>
</div>
</div>
{collectionVideos.length === 0 ? (
<div className="no-videos">
<p>No videos in this collection.</p>
</div>
) : (
<div className="videos-grid">
{collectionVideos.map(video => (
<VideoCard
key={video.id}
video={video}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
))}
</div>
)}
</div>
);
};
export default CollectionPage;

View File

@@ -0,0 +1,51 @@
import AuthorsList from '../components/AuthorsList';
import Collections from '../components/Collections';
import VideoCard from '../components/VideoCard';
const Home = ({ videos = [], loading, error, onDeleteVideo, collections }) => {
// Add default empty array to ensure videos is always an array
const videoArray = Array.isArray(videos) ? videos : [];
if (loading && videoArray.length === 0) {
return <div className="loading">Loading videos...</div>;
}
if (error && videoArray.length === 0) {
return <div className="error">{error}</div>;
}
return (
<div className="home-container">
{videoArray.length === 0 ? (
<div className="no-videos">
<p>No videos yet. Submit a YouTube URL to download your first video!</p>
</div>
) : (
<div className="home-content">
{/* Sidebar container for Collections and Authors */}
<div className="sidebar-container">
{/* Collections list */}
<Collections collections={collections} />
{/* Authors list */}
<AuthorsList videos={videoArray} />
</div>
{/* Videos grid */}
<div className="videos-grid">
{videoArray.map(video => (
<VideoCard
key={video.id}
video={video}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
))}
</div>
</div>
)}
</div>
);
};
export default Home;

View File

@@ -0,0 +1,250 @@
import React, { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
// Define the API base URL
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
const SearchResults = ({
results,
localResults,
searchTerm,
loading,
youtubeLoading,
onDownload,
onDeleteVideo,
onResetSearch
}) => {
const navigate = useNavigate();
// If search term is empty, reset search and go back to home
useEffect(() => {
if (!searchTerm || searchTerm.trim() === '') {
if (onResetSearch) {
onResetSearch();
}
}
}, [searchTerm, onResetSearch]);
const handleDownload = async (videoUrl, title) => {
try {
await onDownload(videoUrl, title);
} catch (error) {
console.error('Error downloading from search results:', error);
}
};
const handleDelete = async (id) => {
try {
await onDeleteVideo(id);
} catch (error) {
console.error('Error deleting video:', error);
}
};
const handleBackClick = () => {
// Call the onResetSearch function to reset search mode
if (onResetSearch) {
onResetSearch();
} else {
// Fallback to navigate if onResetSearch is not provided
navigate('/');
}
};
// If search term is empty, don't render search results
if (!searchTerm || searchTerm.trim() === '') {
return null;
}
// If the entire page is loading
if (loading) {
return (
<div className="search-results">
<h2>Searching for "{searchTerm}"...</h2>
<div className="loading-spinner"></div>
</div>
);
}
const hasLocalResults = localResults && localResults.length > 0;
const hasYouTubeResults = results && results.length > 0;
const noResults = !hasLocalResults && !hasYouTubeResults && !youtubeLoading;
if (noResults) {
return (
<div className="search-results">
<div className="search-header">
<button className="back-button" onClick={handleBackClick}>
<span></span> Back to Home
</button>
<h2>Search Results for "{searchTerm}"</h2>
</div>
<p className="no-results">No results found. Try a different search term.</p>
</div>
);
}
return (
<div className="search-results">
<div className="search-header">
<button className="back-button" onClick={handleBackClick}>
<span></span> Back to Home
</button>
<h2>Search Results for "{searchTerm}"</h2>
</div>
{/* Local Video Results */}
{hasLocalResults ? (
<div className="search-results-section">
<h3 className="section-title">From Your Library</h3>
<div className="search-results-grid">
{localResults.map((video) => (
<div key={video.id} className="search-result-card local-result">
<Link to={`/video/${video.id}`} className="video-link">
<div className="search-result-thumbnail">
{video.thumbnailPath ? (
<img
src={`${API_BASE_URL}${video.thumbnailPath}`}
alt={video.title}
onError={(e) => {
e.target.onerror = null;
e.target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
) : (
<div className="thumbnail-placeholder">No Thumbnail</div>
)}
</div>
</Link>
<div className="search-result-info">
<Link to={`/video/${video.id}`} className="video-link">
<h3 className="search-result-title">{video.title}</h3>
</Link>
<Link to={`/author/${encodeURIComponent(video.author)}`} className="author-link">
<p className="search-result-author">{video.author}</p>
</Link>
<div className="search-result-meta">
<span className="search-result-date">{formatDate(video.date)}</span>
<span className={`source-badge ${video.source}`}>
{video.source}
</span>
</div>
<div className="search-result-actions">
<Link to={`/video/${video.id}`} className="play-btn">
Play
</Link>
<button
className="delete-btn"
onClick={() => handleDelete(video.id)}
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="search-results-section">
<h3 className="section-title">From Your Library</h3>
<p className="no-results">No matching videos in your library.</p>
</div>
)}
{/* YouTube Search Results */}
<div className="search-results-section">
<h3 className="section-title">From YouTube</h3>
{youtubeLoading ? (
<div className="youtube-loading">
<div className="loading-spinner"></div>
<p>Loading YouTube results...</p>
</div>
) : hasYouTubeResults ? (
<div className="search-results-grid">
{results.map((result) => (
<div key={result.id} className="search-result-card">
<div className="search-result-thumbnail">
{result.thumbnailUrl ? (
<img
src={result.thumbnailUrl}
alt={result.title}
onError={(e) => {
e.target.onerror = null;
e.target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
) : (
<div className="thumbnail-placeholder">No Thumbnail</div>
)}
</div>
<div className="search-result-info">
<h3 className="search-result-title">{result.title}</h3>
<p className="search-result-author">{result.author}</p>
<div className="search-result-meta">
{result.duration && (
<span className="search-result-duration">
{formatDuration(result.duration)}
</span>
)}
{result.viewCount && (
<span className="search-result-views">
{formatViewCount(result.viewCount)} views
</span>
)}
<span className={`source-badge ${result.source}`}>
{result.source}
</span>
</div>
<button
className="download-btn"
onClick={() => handleDownload(result.sourceUrl, result.title)}
>
Download
</button>
</div>
</div>
))}
</div>
) : (
<p className="no-results">No YouTube results found.</p>
)}
</div>
</div>
);
};
// Helper function to format duration in seconds to MM:SS
const formatDuration = (seconds) => {
if (!seconds) return '';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// Helper function to format view count
const formatViewCount = (count) => {
if (!count) return '0';
if (count < 1000) return count.toString();
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
return `${(count / 1000000).toFixed(1)}M`;
};
// Helper function to format date
const formatDate = (dateStr) => {
if (!dateStr) return '';
// Handle YYYYMMDD format
if (dateStr.length === 8) {
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
// Return as is if it's already formatted
return dateStr;
};
export default SearchResults;

View File

@@ -0,0 +1,355 @@
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
const API_URL = import.meta.env.VITE_API_URL;
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
const VideoPlayer = ({ videos, onDeleteVideo, collections, onAddToCollection, onCreateCollection, onRemoveFromCollection }) => {
const { id } = useParams();
const navigate = useNavigate();
const [video, setVideo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState(null);
const [isDeleted, setIsDeleted] = useState(false);
const [showCollectionModal, setShowCollectionModal] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [selectedCollection, setSelectedCollection] = useState('');
const [videoCollections, setVideoCollections] = useState([]);
useEffect(() => {
// Don't try to fetch the video if it's being deleted or has been deleted
if (isDeleting || isDeleted) {
return;
}
const fetchVideo = async () => {
// First check if the video is in the videos prop
const foundVideo = videos.find(v => v.id === id);
if (foundVideo) {
setVideo(foundVideo);
setLoading(false);
return;
}
// If not found in props, try to fetch from API
try {
const response = await axios.get(`${API_URL}/videos/${id}`);
setVideo(response.data);
setError(null);
} catch (err) {
console.error('Error fetching video:', err);
setError('Video not found or could not be loaded.');
// Redirect to home after 3 seconds if video not found
setTimeout(() => {
navigate('/');
}, 3000);
} finally {
setLoading(false);
}
};
fetchVideo();
}, [id, videos, navigate, isDeleting, isDeleted]);
// Find collections that contain this video
useEffect(() => {
if (collections && collections.length > 0 && id) {
const belongsToCollections = collections.filter(collection =>
collection.videos.includes(id)
);
setVideoCollections(belongsToCollections);
} else {
setVideoCollections([]);
}
}, [collections, id]);
// Format the date (assuming format YYYYMMDD from youtube-dl)
const formatDate = (dateString) => {
if (!dateString || dateString.length !== 8) {
return 'Unknown date';
}
const year = dateString.substring(0, 4);
const month = dateString.substring(4, 6);
const day = dateString.substring(6, 8);
return `${year}-${month}-${day}`;
};
// Handle navigation to author videos page
const handleAuthorClick = () => {
navigate(`/author/${encodeURIComponent(video.author)}`);
};
const handleCollectionClick = (collectionId) => {
navigate(`/collection/${collectionId}`);
};
const handleDelete = async () => {
if (!window.confirm('Are you sure you want to delete this video?')) {
return;
}
setIsDeleting(true);
setDeleteError(null);
try {
const result = await onDeleteVideo(id);
if (result.success) {
setIsDeleted(true);
// Navigate immediately to prevent further API calls
navigate('/', { replace: true });
} else {
setDeleteError(result.error);
setIsDeleting(false);
}
} catch (err) {
setDeleteError('An unexpected error occurred while deleting the video.');
console.error(err);
setIsDeleting(false);
}
};
const handleAddToCollection = () => {
setShowCollectionModal(true);
};
const handleCloseModal = () => {
setShowCollectionModal(false);
setNewCollectionName('');
setSelectedCollection('');
};
const handleCreateCollection = async () => {
if (!newCollectionName.trim()) {
return;
}
try {
await onCreateCollection(newCollectionName, id);
handleCloseModal();
} catch (error) {
console.error('Error creating collection:', error);
}
};
const handleAddToExistingCollection = async () => {
if (!selectedCollection) {
return;
}
try {
await onAddToCollection(selectedCollection, id);
handleCloseModal();
} catch (error) {
console.error('Error adding to collection:', error);
}
};
const handleRemoveFromCollection = async () => {
if (!window.confirm('Are you sure you want to remove this video from the collection?')) {
return;
}
try {
await onRemoveFromCollection(id);
handleCloseModal();
} catch (error) {
console.error('Error removing from collection:', error);
}
};
if (isDeleted) {
return <div className="loading">Video deleted successfully. Redirecting...</div>;
}
if (loading) {
return <div className="loading">Loading video...</div>;
}
if (error || !video) {
return <div className="error">{error || 'Video not found'}</div>;
}
// Get source badge
const getSourceBadge = () => {
if (video.source === 'bilibili') {
return <span className="source-badge bilibili">Bilibili</span>;
}
return <span className="source-badge youtube">YouTube</span>;
};
return (
<div className="video-player-container">
<div className="video-wrapper">
<video
className="video-player"
controls
autoPlay
src={`${BACKEND_URL}${video.videoPath || video.url}`}
>
Your browser does not support the video tag.
</video>
</div>
<div className="video-details">
<div className="video-details-header">
<div className="title-container">
<h1>{video.title}</h1>
{getSourceBadge()}
</div>
<div className="video-actions">
<button
className="collection-btn"
onClick={handleAddToCollection}
>
Add to Collection
</button>
<button
className="delete-btn"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Video'}
</button>
</div>
</div>
{deleteError && (
<div className="error" style={{ marginTop: '0.5rem' }}>
{deleteError}
</div>
)}
<div className="video-details-meta">
<div>
<strong>Author:</strong>{' '}
<span
className="author-link"
onClick={handleAuthorClick}
role="button"
tabIndex="0"
aria-label={`View all videos by ${video.author}`}
>
{video.author}
</span>
</div>
<div>
<strong>Upload Date:</strong> {formatDate(video.date)}
</div>
<div>
<strong>Added:</strong> {new Date(video.addedAt).toLocaleString()}
</div>
{video.sourceUrl && (
<div>
<strong>Source:</strong>{' '}
<a
href={video.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="source-link"
>
Original Video
</a>
</div>
)}
{videoCollections.length > 0 && (
<div className="video-collections">
<div className="video-collections-title">Collection:</div>
<div className="video-collections-list">
<span
key={videoCollections[0].id}
className="video-collection-tag"
onClick={() => handleCollectionClick(videoCollections[0].id)}
>
{videoCollections[0].name}
</span>
</div>
</div>
)}
</div>
</div>
{/* Collection Modal */}
{showCollectionModal && (
<div className="modal-overlay">
<div className="modal-content">
<h2>Add to Collection</h2>
{videoCollections.length > 0 && (
<div className="current-collection">
<p className="collection-note">
This video is currently in the collection: <strong>{videoCollections[0].name}</strong>
</p>
<p className="collection-warning">
Adding to a different collection will remove it from the current one.
</p>
<button
className="remove-from-collection"
onClick={handleRemoveFromCollection}
>
Remove from Collection
</button>
</div>
)}
{collections && collections.length > 0 && (
<div className="existing-collections">
<h3>Add to existing collection:</h3>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
>
<option value="">Select a collection</option>
{collections.map(collection => (
<option
key={collection.id}
value={collection.id}
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
>
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? '(Current)' : ''}
</option>
))}
</select>
<button
onClick={handleAddToExistingCollection}
disabled={!selectedCollection}
>
Add to Collection
</button>
</div>
)}
<div className="new-collection">
<h3>Create new collection:</h3>
<input
type="text"
placeholder="Collection name"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
/>
<button
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
Create Collection
</button>
</div>
<button className="close-modal" onClick={handleCloseModal}>
Cancel
</button>
</div>
</div>
)}
</div>
);
};
export default VideoPlayer;

10
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5556,
},
});

353
package-lock.json generated Normal file
View File

@@ -0,0 +1,353 @@
{
"name": "mytube",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mytube",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "mytube",
"version": "1.0.0",
"description": "YouTube video downloader and player application",
"main": "index.js",
"scripts": {
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"start:frontend": "cd frontend && npm run dev",
"start:backend": "cd backend && npm run start",
"dev:frontend": "cd frontend && npm run dev",
"dev:backend": "cd backend && npm run dev",
"install:all": "npm install && cd frontend && npm install && cd ../backend && npm install",
"build": "cd frontend && npm run build"
},
"keywords": [
"youtube",
"video",
"downloader",
"player"
],
"author": "",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"
}
}