init
This commit is contained in:
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal 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
107
DEPLOYMENT.md
Normal 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
126
README.md
Normal 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
1
backend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
PORT={backend_port}
|
||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal 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
2923
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/package.json
Normal file
28
backend/package.json
Normal 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
673
backend/server.js
Normal 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
0
backend/uploads/.gitkeep
Normal file
41
build-and-push.sh
Executable file
41
build-and-push.sh
Executable 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
40
docker-compose.yml
Normal 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
2
frontend/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:5551/api
|
||||
VITE_BACKEND_URL=http://localhost:5551
|
||||
30
frontend/.gitignore
vendored
Normal file
30
frontend/.gitignore
vendored
Normal 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
23
frontend/Dockerfile
Normal 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
12
frontend/README.md
Normal 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
33
frontend/eslint.config.js
Normal 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
23
frontend/index.html
Normal 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
16
frontend/nginx.conf
Normal 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
3067
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
3
frontend/public/mytube-icon.svg
Normal file
3
frontend/public/mytube-icon.svg
Normal 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
1
frontend/public/vite.svg
Normal 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
1392
frontend/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
532
frontend/src/App.jsx
Normal file
532
frontend/src/App.jsx
Normal 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;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
58
frontend/src/components/AuthorsList.jsx
Normal file
58
frontend/src/components/AuthorsList.jsx
Normal 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;
|
||||
44
frontend/src/components/Collections.jsx
Normal file
44
frontend/src/components/Collections.jsx
Normal 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;
|
||||
123
frontend/src/components/Header.jsx
Normal file
123
frontend/src/components/Header.jsx
Normal 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;
|
||||
96
frontend/src/components/VideoCard.jsx
Normal file
96
frontend/src/components/VideoCard.jsx
Normal 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
96
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
89
frontend/src/pages/AuthorVideos.jsx
Normal file
89
frontend/src/pages/AuthorVideos.jsx
Normal 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}>
|
||||
← 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;
|
||||
78
frontend/src/pages/CollectionPage.jsx
Normal file
78
frontend/src/pages/CollectionPage.jsx
Normal 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}>
|
||||
← 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;
|
||||
51
frontend/src/pages/Home.jsx
Normal file
51
frontend/src/pages/Home.jsx
Normal 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;
|
||||
250
frontend/src/pages/SearchResults.jsx
Normal file
250
frontend/src/pages/SearchResults.jsx
Normal 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;
|
||||
355
frontend/src/pages/VideoPlayer.jsx
Normal file
355
frontend/src/pages/VideoPlayer.jsx
Normal 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
10
frontend/vite.config.js
Normal 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
353
package-lock.json
generated
Normal 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
27
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user