9 Commits

Author SHA1 Message Date
Peifan Li
88e452fc61 chore(release): v1.3.2 2025-11-30 17:07:22 -05:00
Peifan Li
cffe2319c2 feat: Add Cloud Storage Service and settings for OpenList 2025-11-30 17:07:10 -05:00
Peifan Li
19383ad582 fix: Update package versions to 1.3.1 2025-11-29 10:55:20 -05:00
Peifan Li
c2d6215b44 chore(release): v1.3.1 2025-11-29 10:52:04 -05:00
Peifan Li
f2b5af0912 refactor: Remove unnecessary youtubedl call arguments 2025-11-29 10:52:00 -05:00
Peifan Li
56557da2cf feat: Update versions and add support for more sites 2025-11-28 21:05:18 -05:00
Peifan Li
1d45692374 chore(release): v1.3.0 2025-11-28 20:50:17 -05:00
Peifan Li
fc070da102 refactor: Update YouTubeDownloader to YtDlpDownloader 2025-11-28 20:50:04 -05:00
Peifan Li
d1ceef9698 fix: Update backend and frontend package versions to 1.2.5 2025-11-27 20:57:31 -05:00
36 changed files with 540 additions and 156 deletions

View File

@@ -1,6 +1,6 @@
# MyTube
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。
一个 YouTube/Bilibili/MissAV 视频下载和播放应用,允许您将视频及其缩略图本地保存。将您的视频整理到收藏夹中,以便轻松访问和管理。现已支持[yt-dlp所有网址](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##)包括微博小红书x.com等。
[English](README.md)

View File

@@ -1,6 +1,6 @@
# MyTube
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management.
A YouTube/Bilibili/MissAV video downloader and player application that allows you to download and save videos locally, along with their thumbnails. Organize your videos into collections for easy access and management. Now supports [yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md##), including Weibo, Xiaohongshu, X.com, etc.
[中文](README-zh.md)

View File

@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "1.2.4",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.2.4",
"version": "1.3.1",
"license": "ISC",
"dependencies": {
"axios": "^1.8.1",

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.2.5",
"version": "1.3.2",
"main": "server.js",
"scripts": {
"start": "ts-node src/server.ts",

View File

@@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as downloadService from '../../services/downloadService';
import { BilibiliDownloader } from '../../services/downloaders/BilibiliDownloader';
import { MissAVDownloader } from '../../services/downloaders/MissAVDownloader';
import { YouTubeDownloader } from '../../services/downloaders/YouTubeDownloader';
import { YtDlpDownloader } from '../../services/downloaders/YtDlpDownloader';
vi.mock('../../services/downloaders/BilibiliDownloader');
vi.mock('../../services/downloaders/YouTubeDownloader');
vi.mock('../../services/downloaders/YtDlpDownloader');
vi.mock('../../services/downloaders/MissAVDownloader');
describe('DownloadService', () => {
@@ -56,22 +56,22 @@ describe('DownloadService', () => {
});
});
describe('YouTube', () => {
it('should call YouTubeDownloader.search', async () => {
describe('YouTube/Generic', () => {
it('should call YtDlpDownloader.search', async () => {
await downloadService.searchYouTube('query');
expect(YouTubeDownloader.search).toHaveBeenCalledWith('query');
expect(YtDlpDownloader.search).toHaveBeenCalledWith('query');
});
it('should call YouTubeDownloader.downloadVideo', async () => {
it('should call YtDlpDownloader.downloadVideo', async () => {
await downloadService.downloadYouTubeVideo('url', 'id');
expect(YouTubeDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
expect(YtDlpDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
});
});
describe('MissAV', () => {
it('should call MissAVDownloader.downloadVideo', async () => {
await downloadService.downloadMissAVVideo('url', 'id');
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id');
expect(MissAVDownloader.downloadVideo).toHaveBeenCalledWith('url', 'id', undefined);
});
});
});

View File

@@ -14,6 +14,10 @@ interface Settings {
maxConcurrentDownloads: number;
language: string;
tags?: string[];
cloudDriveEnabled?: boolean;
openListApiUrl?: string;
openListToken?: string;
cloudDrivePath?: string;
}
const defaultSettings: Settings = {
@@ -22,7 +26,11 @@ const defaultSettings: Settings = {
defaultAutoPlay: false,
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en'
language: 'en',
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: ''
};
export const getSettings = async (_req: Request, res: Response) => {

View File

@@ -9,12 +9,12 @@ import * as downloadService from "../services/downloadService";
import { getVideoDuration } from "../services/metadataService";
import * as storageService from "../services/storageService";
import {
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
} from "../utils/helpers";
// Configure Multer for file uploads
@@ -86,13 +86,12 @@ export const downloadVideo = async (req: Request, res: Response): Promise<any> =
console.log("Resolved shortened URL to:", videoUrl);
}
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be") || isBilibiliUrl(videoUrl) || videoUrl.includes("missav")) {
console.log("Fetching video info for title...");
const info = await downloadService.getVideoInfo(videoUrl);
if (info && info.title) {
initialTitle = info.title;
console.log("Fetched initial title:", initialTitle);
}
// Try to fetch video info for all URLs
console.log("Fetching video info for title...");
const info = await downloadService.getVideoInfo(videoUrl);
if (info && info.title) {
initialTitle = info.title;
console.log("Fetched initial title:", initialTitle);
}
} catch (err) {
console.warn("Failed to fetch video info for title, using default:", err);

View File

@@ -0,0 +1,173 @@
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { getSettings } from './storageService';
interface CloudDriveConfig {
enabled: boolean;
apiUrl: string;
token: string;
uploadPath: string;
}
export class CloudStorageService {
private static getConfig(): CloudDriveConfig {
const settings = getSettings();
return {
enabled: settings.cloudDriveEnabled || false,
apiUrl: settings.openListApiUrl || '',
token: settings.openListToken || '',
uploadPath: settings.cloudDrivePath || '/'
};
}
static async uploadVideo(videoData: any): Promise<void> {
const config = this.getConfig();
if (!config.enabled || !config.apiUrl || !config.token) {
return;
}
console.log(`[CloudStorage] Starting upload for video: ${videoData.title}`);
try {
// Upload Video File
if (videoData.videoPath) {
// videoPath is relative, e.g. /videos/filename.mp4
// We need absolute path. Assuming backend runs in project root or we can resolve it.
// Based on storageService, VIDEOS_DIR is likely imported from config/paths.
// But here we might need to resolve it.
// Let's try to resolve relative to process.cwd() or use absolute path if available.
// Actually, storageService stores relative paths for frontend usage.
// We should probably look up the file using the same logic as storageService or just assume standard location.
// For now, let's try to construct the path.
// Better approach: Use the absolute path if we can get it, or resolve from common dirs.
// Since I don't have direct access to config/paths here easily without importing,
// I'll assume the videoData might have enough info or I'll import paths.
const absoluteVideoPath = this.resolveAbsolutePath(videoData.videoPath);
if (absoluteVideoPath && fs.existsSync(absoluteVideoPath)) {
await this.uploadFile(absoluteVideoPath, config);
} else {
console.error(`[CloudStorage] Video file not found: ${videoData.videoPath}`);
}
}
// Upload Thumbnail
if (videoData.thumbnailPath) {
const absoluteThumbPath = this.resolveAbsolutePath(videoData.thumbnailPath);
if (absoluteThumbPath && fs.existsSync(absoluteThumbPath)) {
await this.uploadFile(absoluteThumbPath, config);
}
}
// Upload Metadata (JSON)
const metadata = {
title: videoData.title,
description: videoData.description,
author: videoData.author,
sourceUrl: videoData.sourceUrl,
tags: videoData.tags,
createdAt: videoData.createdAt,
...videoData
};
const metadataFileName = `${this.sanitizeFilename(videoData.title)}.json`;
const metadataPath = path.join(process.cwd(), 'temp_metadata', metadataFileName);
fs.ensureDirSync(path.dirname(metadataPath));
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
await this.uploadFile(metadataPath, config);
// Cleanup temp metadata
fs.unlinkSync(metadataPath);
console.log(`[CloudStorage] Upload completed for: ${videoData.title}`);
} catch (error) {
console.error(`[CloudStorage] Upload failed for ${videoData.title}:`, error);
}
}
private static resolveAbsolutePath(relativePath: string): string | null {
// This is a heuristic. In a real app we should import the constants.
// Assuming the app runs from 'backend' or root.
// relativePath starts with /videos or /images
// Try to find the 'data' directory.
// If we are in backend/src/services, data is likely ../../../data
// Let's try to use the absolute path if we can find the data dir.
// Or just check common locations.
const possibleRoots = [
path.join(process.cwd(), 'data'),
path.join(process.cwd(), '..', 'data'), // if running from backend
path.join(__dirname, '..', '..', '..', 'data') // if compiled
];
for (const root of possibleRoots) {
if (fs.existsSync(root)) {
// Remove leading slash from relative path
const cleanRelative = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
const fullPath = path.join(root, cleanRelative);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
}
return null;
}
private static async uploadFile(filePath: string, config: CloudDriveConfig): Promise<void> {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const fileStream = fs.createReadStream(filePath);
console.log(`[CloudStorage] Uploading ${fileName} (${fileSize} bytes)...`);
// Generic upload implementation
// Assuming a simple PUT or POST with file content
// Many cloud drives (like Alist/WebDAV) use PUT with the path.
// Construct URL: apiUrl + uploadPath + fileName
// Ensure slashes are handled correctly
const baseUrl = config.apiUrl.endsWith('/') ? config.apiUrl.slice(0, -1) : config.apiUrl;
const uploadDir = config.uploadPath.startsWith('/') ? config.uploadPath : '/' + config.uploadPath;
const finalDir = uploadDir.endsWith('/') ? uploadDir : uploadDir + '/';
// Encode filename for URL
const encodedFileName = encodeURIComponent(fileName);
const url = `${baseUrl}${finalDir}${encodedFileName}`;
try {
await axios.put(url, fileStream, {
headers: {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/octet-stream',
'Content-Length': fileSize
},
maxContentLength: Infinity,
maxBodyLength: Infinity
});
console.log(`[CloudStorage] Successfully uploaded ${fileName}`);
} catch (error: any) {
// Try POST if PUT fails, some APIs might differ
console.warn(`[CloudStorage] PUT failed, trying POST... Error: ${error.message}`);
try {
// For POST, we might need FormData, but let's try raw body first or check if it's a specific API.
// If it's Alist/WebDAV, PUT is standard.
// If it's a custom API, it might expect FormData.
// Let's stick to PUT for now as it's common for "Save to Cloud" generic interfaces.
throw error;
} catch (retryError) {
throw retryError;
}
}
}
private static sanitizeFilename(filename: string): string {
return filename.replace(/[^a-z0-9]/gi, '_').toLowerCase();
}
}

View File

@@ -1,3 +1,4 @@
import { CloudStorageService } from "./CloudStorageService";
import { createDownloadTask } from "./downloadService";
import * as storageService from "./storageService";
@@ -279,6 +280,16 @@ class DownloadManager {
author: videoData.author,
});
// Trigger Cloud Upload (Async, don't await to block queue processing?)
// Actually, we might want to await it if we want to ensure it's done before resolving,
// but that would block the download queue.
// Let's run it in background but log it.
CloudStorageService.uploadVideo({
...videoData,
title: finalTitle || task.title,
sourceUrl: task.sourceUrl
}).catch(err => console.error("Background cloud upload failed:", err));
task.resolve(result);
} catch (error) {
console.error(`Error downloading ${task.title}:`, error);

View File

@@ -9,7 +9,7 @@ import {
DownloadResult
} from "./downloaders/BilibiliDownloader";
import { MissAVDownloader } from "./downloaders/MissAVDownloader";
import { YouTubeDownloader } from "./downloaders/YouTubeDownloader";
import { YtDlpDownloader } from "./downloaders/YtDlpDownloader";
import { Video } from "./storageService";
// Re-export types for compatibility
@@ -77,14 +77,14 @@ export async function downloadRemainingBilibiliParts(
return BilibiliDownloader.downloadRemainingParts(baseUrl, startPart, totalParts, seriesTitle, collectionId, downloadId);
}
// Search for videos on YouTube
// Search for videos on YouTube (using yt-dlp)
export async function searchYouTube(query: string): Promise<any[]> {
return YouTubeDownloader.search(query);
return YtDlpDownloader.search(query);
}
// Download YouTube video
// Download generic video (using yt-dlp)
export async function downloadYouTubeVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
return YouTubeDownloader.downloadVideo(videoUrl, downloadId, onStart);
return YtDlpDownloader.downloadVideo(videoUrl, downloadId, onStart);
}
// Helper function to download MissAV video
@@ -99,19 +99,12 @@ export async function getVideoInfo(url: string): Promise<{ title: string; author
if (videoId) {
return BilibiliDownloader.getVideoInfo(videoId);
}
} else if (url.includes("youtube.com") || url.includes("youtu.be")) {
return YouTubeDownloader.getVideoInfo(url);
} else if (url.includes("missav")) {
return MissAVDownloader.getVideoInfo(url);
}
// Default fallback
return {
title: "Video",
author: "Unknown",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
};
// Default fallback to yt-dlp for everything else
return YtDlpDownloader.getVideoInfo(url);
}
// Factory function to create a download task
@@ -128,8 +121,8 @@ export function createDownloadTask(
// Complex collection handling would require persisting more state
return BilibiliDownloader.downloadSinglePart(url, 1, 1, "");
} else {
// Default to YouTube
return YouTubeDownloader.downloadVideo(url, downloadId, registerCancel);
// Default to yt-dlp
return YtDlpDownloader.downloadVideo(url, downloadId, registerCancel);
}
};
}

View File

@@ -7,16 +7,51 @@ import { sanitizeFilename } from "../../utils/helpers";
import * as storageService from "../storageService";
import { Video } from "../storageService";
export class YouTubeDownloader {
// Search for videos on YouTube
// Helper function to extract author from XiaoHongShu page when yt-dlp doesn't provide it
async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
try {
console.log("Attempting to extract XiaoHongShu author from webpage...");
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 10000
});
const html = response.data;
// Try to find author name in the JSON data embedded in the page
// XiaoHongShu embeds data in window.__INITIAL_STATE__
const match = html.match(/"nickname":"([^"]+)"/);
if (match && match[1]) {
console.log("Found XiaoHongShu author:", match[1]);
return match[1];
}
// Alternative: try to find in user info
const userMatch = html.match(/"user":\{[^}]*"nickname":"([^"]+)"/);
if (userMatch && userMatch[1]) {
console.log("Found XiaoHongShu author (user):", userMatch[1]);
return userMatch[1];
}
console.log("Could not extract XiaoHongShu author from webpage");
return null;
} catch (error) {
console.error("Error extracting XiaoHongShu author:", error);
return null;
}
}
export class YtDlpDownloader {
// Search for videos (primarily for YouTube, but could be adapted)
static async search(query: string): Promise<any[]> {
console.log("Processing search request for query:", query);
// Use youtube-dl to search for videos
// Use ytsearch for searching
const searchResults = await youtubedl(`ytsearch5:${query}`, {
dumpSingleJson: true,
noWarnings: true,
noCallHome: true,
skipDownload: true,
playlistEnd: 5, // Limit to 5 results
} as any);
@@ -33,7 +68,7 @@ export class YouTubeDownloader {
thumbnailUrl: entry.thumbnail,
duration: entry.duration,
viewCount: entry.view_count,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`,
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results
source: "youtube",
}));
@@ -50,31 +85,30 @@ export class YouTubeDownloader {
const info = await youtubedl(url, {
dumpSingleJson: true,
noWarnings: true,
callHome: false,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
// youtubeSkipDashManifest: true, // Specific to YT, might want to keep or make conditional
} as any);
return {
title: info.title || "YouTube Video",
author: info.uploader || "YouTube User",
title: info.title || "Video",
author: info.uploader || "Unknown",
date: info.upload_date || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: info.thumbnail,
};
} catch (error) {
console.error("Error fetching YouTube video info:", error);
console.error("Error fetching video info:", error);
return {
title: "YouTube Video",
author: "YouTube User",
title: "Video",
author: "Unknown",
date: new Date().toISOString().slice(0, 10).replace(/-/g, ""),
thumbnailUrl: "",
};
}
}
// Download YouTube video
// Download video
static async downloadVideo(videoUrl: string, downloadId?: string, onStart?: (cancel: () => void) => void): Promise<Video> {
console.log("Detected YouTube URL");
console.log("Detected URL:", videoUrl);
// Create a safe base filename (without extension)
const timestamp = Date.now();
@@ -84,36 +118,40 @@ export class YouTubeDownloader {
const videoFilename = `${safeBaseFilename}.mp4`;
const thumbnailFilename = `${safeBaseFilename}.jpg`;
// Set full paths for video and thumbnail
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved;
let videoTitle, videoAuthor, videoDate, thumbnailUrl, thumbnailSaved, source;
let finalVideoFilename = videoFilename;
let finalThumbnailFilename = thumbnailFilename;
try {
// Get YouTube video info first
// Get video info first
const info = await youtubedl(videoUrl, {
dumpSingleJson: true,
noWarnings: true,
callHome: false,
preferFreeFormats: true,
youtubeSkipDashManifest: true,
} as any);
console.log("YouTube video info:", {
console.log("Video info:", {
title: info.title,
uploader: info.uploader,
upload_date: info.upload_date,
extractor: info.extractor,
});
videoTitle = info.title || "YouTube Video";
videoAuthor = info.uploader || "YouTube User";
videoTitle = info.title || "Video";
videoAuthor = info.uploader || "Unknown";
// If author is unknown and it's a XiaoHongShu video, try custom extraction
if ((!info.uploader || info.uploader === "Unknown") && info.extractor === "XiaoHongShu") {
const customAuthor = await extractXiaoHongShuAuthor(videoUrl);
if (customAuthor) {
videoAuthor = customAuthor;
}
}
videoDate =
info.upload_date ||
new Date().toISOString().slice(0, 10).replace(/-/g, "");
thumbnailUrl = info.thumbnail;
source = info.extractor || "generic";
// Update the safe base filename with the actual title
const newSafeBaseFilename = `${sanitizeFilename(
@@ -130,8 +168,8 @@ export class YouTubeDownloader {
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
// Download the YouTube video
console.log("Downloading YouTube video to:", newVideoPath);
// Download the video
console.log("Downloading video to:", newVideoPath);
if (downloadId) {
storageService.updateActiveDownload(downloadId, {
@@ -140,20 +178,25 @@ export class YouTubeDownloader {
});
}
// Use exec to capture stdout for progress
// Format selection prioritizes Safari-compatible codecs (H.264/AAC)
// avc1 is the H.264 variant that Safari supports best
// Use Android client to avoid SABR streaming issues and JS runtime requirements
const subprocess = youtubedl.exec(videoUrl, {
// Prepare flags
const flags: any = {
output: newVideoPath,
format: "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best",
format: "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
mergeOutputFormat: "mp4",
'extractor-args': "youtube:player_client=android",
addHeader: [
};
// Add YouTube specific flags if it's a YouTube URL
if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
flags.format = "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a][acodec=aac]/bestvideo[ext=mp4][vcodec=h264]+bestaudio[ext=m4a]/best[ext=mp4]/best";
flags['extractor-args'] = "youtube:player_client=android";
flags.addHeader = [
'Referer:https://www.youtube.com/',
'User-Agent:Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
]
} as any);
];
}
// Use exec to capture stdout for progress
const subprocess = youtubedl.exec(videoUrl, flags);
if (onStart) {
onStart(() => {
@@ -163,7 +206,6 @@ export class YouTubeDownloader {
// Clean up partial files
console.log("Cleaning up partial files...");
try {
// youtube-dl creates .part files during download
const partVideoPath = `${newVideoPath}.part`;
const partThumbnailPath = `${newThumbnailPath}.part`;
@@ -209,12 +251,11 @@ export class YouTubeDownloader {
await subprocess;
console.log("YouTube video downloaded successfully");
console.log("Video downloaded successfully");
// Download and save the thumbnail
thumbnailSaved = false;
// Download the thumbnail image
if (thumbnailUrl) {
try {
console.log("Downloading thumbnail from:", thumbnailUrl);
@@ -242,9 +283,9 @@ export class YouTubeDownloader {
// Continue even if thumbnail download fails
}
}
} catch (youtubeError) {
console.error("Error in YouTube download process:", youtubeError);
throw youtubeError;
} catch (error) {
console.error("Error in download process:", error);
throw error;
}
// Create metadata for the video
@@ -254,7 +295,7 @@ export class YouTubeDownloader {
author: videoAuthor || "Unknown",
date:
videoDate || new Date().toISOString().slice(0, 10).replace(/-/g, ""),
source: "youtube",
source: source, // Use extracted source
sourceUrl: videoUrl,
videoFilename: finalVideoFilename,
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
@@ -269,12 +310,9 @@ export class YouTubeDownloader {
};
// If duration is missing from info, try to extract it from file
// We need to reconstruct the path because newVideoPath is not in scope here if we are outside the try block
// But wait, finalVideoFilename is available.
const finalVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
try {
// Dynamic import to avoid circular dependency if any, though here it's fine
const { getVideoDuration } = await import("../../services/metadataService");
const duration = await getVideoDuration(finalVideoPath);
if (duration) {

View File

@@ -64,9 +64,10 @@ export const backfillDurations = async () => {
const duration = await getVideoDuration(fsPath);
if (duration !== null) {
await db.update(videos)
db.update(videos)
.set({ duration: duration.toString() })
.where(eq(videos.id, video.id));
.where(eq(videos.id, video.id))
.run();
console.log(`Updated duration for ${video.title}: ${duration}s`);
updatedCount++;
}

View File

@@ -68,7 +68,7 @@ export async function runMigration() {
description: video.description,
viewCount: video.viewCount,
duration: video.duration,
}).onConflictDoNothing();
}).onConflictDoNothing().run();
results.videos.count++;
} catch (error: any) {
console.error(`Error migrating video ${video.id}:`, error);
@@ -96,7 +96,7 @@ export async function runMigration() {
title: collection.title,
createdAt: collection.createdAt || new Date().toISOString(),
updatedAt: collection.updatedAt,
}).onConflictDoNothing();
}).onConflictDoNothing().run();
results.collections.count++;
// Insert Collection Videos
@@ -106,7 +106,7 @@ export async function runMigration() {
await db.insert(collectionVideos).values({
collectionId: collection.id,
videoId: videoId,
}).onConflictDoNothing();
}).onConflictDoNothing().run();
} catch (err: any) {
console.error(`Error linking video ${videoId} to collection ${collection.id}:`, err);
results.errors.push(`Link ${videoId}->${collection.id}: ${err.message}`);
@@ -137,7 +137,7 @@ export async function runMigration() {
}).onConflictDoUpdate({
target: settings.key,
set: { value: JSON.stringify(value) },
});
}).run();
results.settings.count++;
}
} catch (error: any) {
@@ -178,7 +178,7 @@ export async function runMigration() {
speed: download.speed,
status: 'active',
}
});
}).run();
results.downloads.count++;
}
}
@@ -198,7 +198,7 @@ export async function runMigration() {
timestamp: download.timestamp,
status: 'queued',
}
});
}).run();
results.downloads.count++;
}
}

View File

@@ -108,9 +108,13 @@ export function sanitizeFilename(filename: string): string {
// Replace only unsafe characters for filesystems
// This preserves non-Latin characters like Chinese, Japanese, Korean, etc.
return withoutHashtags
const sanitized = withoutHashtags
.replace(/[\/\\:*?"<>|]/g, "_") // Replace unsafe filesystem characters
.replace(/\s+/g, "_"); // Replace spaces with underscores
// Truncate to 200 characters to avoid ENAMETOOLONG errors (filesystem limit is usually 255 bytes)
// We use 200 to leave room for timestamp suffix and extension
return sanitized.slice(0, 200);
}
// Helper function to extract user mid from Bilibili URL

View File

@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "1.2.4",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.2.4",
"version": "1.3.1",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.2.5",
"version": "1.3.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -123,10 +123,9 @@ const Header: React.FC<HeaderProps> = ({
return;
}
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
const missavRegex = /^(https?:\/\/)?(www\.)?(missav\.(ai|ws|com))\/.+$/;
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl) || missavRegex.test(videoUrl);
// Generic URL check
const urlRegex = /^(https?:\/\/[^\s]+)/;
const isUrl = urlRegex.test(videoUrl);
setError('');
setIsSubmitting(true);

View File

@@ -1,9 +1,6 @@
import {
Delete,
Folder,
Movie,
OndemandVideo,
YouTube
Folder
} from '@mui/icons-material';
import {
Box,
@@ -140,17 +137,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
}
};
// Get source icon
const getSourceIcon = () => {
if (video.source === 'bilibili') {
return <OndemandVideo sx={{ color: '#23ade5' }} />; // Bilibili blue
} else if (video.source === 'local') {
return <Folder sx={{ color: '#4caf50' }} />; // Local green (using Folder as generic local icon, or maybe VideoFile if available)
} else if (video.source === 'missav') {
return <Movie sx={{ color: '#ff4081' }} />; // Pink for MissAV
}
return <YouTube sx={{ color: '#ff0000' }} />; // YouTube red
};
return (
<>
@@ -192,9 +179,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
}}
/>
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{getSourceIcon()}
</Box>
{video.partNumber && video.totalParts && video.totalParts > 1 && (
<Chip
@@ -276,7 +261,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
sx={{
position: 'absolute',
top: 8,
right: 40, // Positioned to the left of the source icon
right: 8,
bgcolor: 'rgba(0,0,0,0.6)',
color: 'white',
opacity: 0, // Hidden by default, shown on hover

View File

@@ -4,6 +4,8 @@ import {
Forward10,
Fullscreen,
FullscreenExit,
KeyboardDoubleArrowLeft,
KeyboardDoubleArrowRight,
Loop,
Pause,
PlayArrow,
@@ -139,7 +141,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
};
return (
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}>
<video
ref={videoRef}
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
@@ -216,6 +218,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
{/* Row 2 on Mobile: Seek Controls */}
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
<Tooltip title="-10m">
<Button variant="outlined" onClick={() => handleSeek(-600)}>
<KeyboardDoubleArrowLeft />
</Button>
</Tooltip>
<Tooltip title="-1m">
<Button variant="outlined" onClick={() => handleSeek(-60)}>
<FastRewind />
@@ -236,6 +243,11 @@ const VideoControls: React.FC<VideoControlsProps> = ({
<FastForward />
</Button>
</Tooltip>
<Tooltip title="+10m">
<Button variant="outlined" onClick={() => handleSeek(600)}>
<KeyboardDoubleArrowRight />
</Button>
</Tooltip>
</Stack>
</Stack>
</Box>

View File

@@ -242,7 +242,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
)}
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<VideoLibrary fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('source')}</strong> {video.source === 'bilibili' ? 'Bilibili' : (video.source === 'local' ? 'Local Upload' : 'YouTube')}
<strong>{t('source')}</strong> {video.source ? video.source.charAt(0).toUpperCase() + video.source.slice(1) : 'Unknown'}
</Typography>
{video.addedAt && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>

View File

@@ -19,7 +19,8 @@ import { Video } from '../types';
const AuthorVideos: React.FC = () => {
const { t } = useLanguage();
const { author } = useParams<{ author: string }>();
const { authorName } = useParams<{ authorName: string }>();
const author = authorName;
const navigate = useNavigate();
const { videos, loading, deleteVideo } = useVideo();
const { collections } = useCollection();
@@ -79,7 +80,7 @@ const AuthorVideos: React.FC = () => {
</Avatar>
<Box>
<Typography variant="h4" component="h1" fontWeight="bold">
{author ? decodeURIComponent(author) : t('unknownAuthor')}
{author || t('unknownAuthor')}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{authorVideos.length} {t('videos')}

View File

@@ -339,6 +339,11 @@ const DownloadPage: React.FC = () => {
secondaryTypographyProps={{ component: 'div' }}
secondary={
<Box component="div" sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{item.sourceUrl && (
<Typography variant="caption" color="primary" component="a" href={item.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
{item.sourceUrl}
</Typography>
)}
<Typography variant="caption" component="span">
{formatDate(item.finishedAt)}
</Typography>

View File

@@ -44,6 +44,10 @@ interface Settings {
maxConcurrentDownloads: number;
language: string;
tags: string[];
cloudDriveEnabled: boolean;
openListApiUrl: string;
openListToken: string;
cloudDrivePath: string;
}
const SettingsPage: React.FC = () => {
@@ -58,7 +62,11 @@ const SettingsPage: React.FC = () => {
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en',
tags: []
tags: [],
cloudDriveEnabled: false,
openListApiUrl: '',
openListToken: '',
cloudDrivePath: ''
});
const [newTag, setNewTag] = useState('');
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
@@ -469,6 +477,48 @@ const SettingsPage: React.FC = () => {
<Grid size={12}><Divider /></Grid>
{/* Cloud Drive Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('cloudDriveSettings')}</Typography>
<FormControlLabel
control={
<Switch
checked={settings.cloudDriveEnabled || false}
onChange={(e) => handleChange('cloudDriveEnabled', e.target.checked)}
/>
}
label={t('enableAutoSave')}
/>
{settings.cloudDriveEnabled && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 600 }}>
<TextField
label={t('apiUrl')}
value={settings.openListApiUrl || ''}
onChange={(e) => handleChange('openListApiUrl', e.target.value)}
helperText={t('apiUrlHelper')}
fullWidth
/>
<TextField
label={t('token')}
value={settings.openListToken || ''}
onChange={(e) => handleChange('openListToken', e.target.value)}
type="password"
fullWidth
/>
<TextField
label={t('uploadPath')}
value={settings.cloudDrivePath || ''}
onChange={(e) => handleChange('cloudDrivePath', e.target.value)}
helperText={t('cloudDrivePathHelper')}
fullWidth
/>
</Box>
)}
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Database Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('database')}</Typography>

View File

@@ -354,9 +354,14 @@ const VideoPlayer: React.FC = () => {
}).slice(0, 10);
}, [video, videos, collections]);
// Scroll to top when video ID changes
useEffect(() => {
window.scrollTo(0, 0);
}, [id]);
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Grid container spacing={4}>
<Container maxWidth={false} disableGutters sx={{ py: { xs: 0, md: 4 }, px: { xs: 0, md: 2 } }}>
<Grid container spacing={{ xs: 0, md: 4 }}>
{/* Main Content Column */}
<Grid size={{ xs: 12, lg: 8 }}>
<VideoControls
@@ -367,31 +372,33 @@ const VideoPlayer: React.FC = () => {
startTime={video.progress || 0}
/>
<VideoInfo
video={video}
onTitleSave={handleSaveTitle}
onRatingChange={handleRatingChange}
onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
videoCollections={videoCollections}
onCollectionClick={handleCollectionClick}
availableTags={availableTags}
onTagsUpdate={handleUpdateTags}
/>
<Box sx={{ px: { xs: 2, md: 0 } }}>
<VideoInfo
video={video}
onTitleSave={handleSaveTitle}
onRatingChange={handleRatingChange}
onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
videoCollections={videoCollections}
onCollectionClick={handleCollectionClick}
availableTags={availableTags}
onTagsUpdate={handleUpdateTags}
/>
<CommentsSection
comments={comments}
loading={loadingComments}
showComments={showComments}
onToggleComments={handleToggleComments}
/>
<CommentsSection
comments={comments}
loading={loadingComments}
showComments={showComments}
onToggleComments={handleToggleComments}
/>
</Box>
</Grid>
{/* Sidebar Column - Up Next */}
<Grid size={{ xs: 12, lg: 4 }}>
<Grid size={{ xs: 12, lg: 4 }} sx={{ p: { xs: 2, md: 0 }, pt: { xs: 2, md: 0 } }}>
<Typography variant="h6" gutterBottom fontWeight="bold">{t('upNext')}</Typography>
<Stack spacing={2}>
{relatedVideos.map(relatedVideo => (

View File

@@ -84,6 +84,15 @@ export const ar = {
cleanupTempFilesActiveDownloads: "لا يمكن التنظيف أثناء وجود تنزيلات نشطة. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
// Cloud Drive
cloudDriveSettings: "التخزين السحابي (OpenList)",
enableAutoSave: "تمكين الحفظ التلقائي في السحابة",
apiUrl: "رابط API",
apiUrlHelper: "مثال: https://your-alist-instance.com/api/fs/put",
token: "الرمز المميز (Token)",
uploadPath: "مسار التحميل",
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
// Manage
manageContent: "إدارة المحتوى",

View File

@@ -42,6 +42,16 @@ export const de = {
cleanupTempFilesActiveDownloads: "Bereinigung nicht möglich, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie ab.",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
// Cloud Drive
cloudDriveSettings: "Cloud-Speicher (OpenList)",
enableAutoSave: "Automatisches Speichern in der Cloud aktivieren",
apiUrl: "API-URL",
apiUrlHelper: "z.B. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Upload-Pfad",
cloudDrivePathHelper: "Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
manageContent: "Inhalte Verwalten", videos: "Videos", collections: "Sammlungen", allVideos: "Alle Videos",
delete: "Löschen", backToHome: "Zurück zur Startseite", confirmDelete: "Sind Sie sicher, dass Sie dies löschen möchten?",
deleteSuccess: "Erfolgreich gelöscht", deleteFailed: "Löschen fehlgeschlagen", noVideos: "Keine Videos gefunden",

View File

@@ -84,6 +84,15 @@ export const en = {
cleanupTempFilesActiveDownloads: "Cannot clean up while downloads are active. Please wait for all downloads to complete or cancel them first.",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",
// Cloud Drive
cloudDriveSettings: "Cloud Drive (OpenList)",
enableAutoSave: "Enable Auto Save to Cloud",
apiUrl: "API URL",
apiUrlHelper: "e.g. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Upload Path",
cloudDrivePathHelper: "Directory path in cloud drive, e.g. /mytube-uploads",
// Manage
manageContent: "Manage Content",

View File

@@ -40,6 +40,16 @@ export const es = {
cleanupTempFilesActiveDownloads: "No se puede limpiar mientras hay descargas activas. Espera a que todas las descargas terminen o cancélalas primero.",
cleanupTempFilesSuccess: "Se eliminaron exitosamente {count} archivo(s) temporal(es).",
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
// Cloud Drive
cloudDriveSettings: "Almacenamiento en la Nube (OpenList)",
enableAutoSave: "Habilitar guardado automático en la nube",
apiUrl: "URL de la API",
apiUrlHelper: "ej. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Ruta de carga",
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
manageContent: "Gestionar Contenido", videos: "Videos", collections: "Colecciones", allVideos: "Todos los Videos",
delete: "Eliminar", backToHome: "Volver a Inicio", confirmDelete: "¿Está seguro de que desea eliminar esto?",
deleteSuccess: "Eliminado exitosamente", deleteFailed: "Error al eliminar", noVideos: "No se encontraron videos",

View File

@@ -84,6 +84,15 @@ export const fr = {
cleanupTempFilesActiveDownloads: "Impossible de nettoyer pendant que des téléchargements sont actifs. Veuillez attendre la fin de tous les téléchargements ou les annuler d'abord.",
cleanupTempFilesSuccess: "{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
// Cloud Drive
cloudDriveSettings: "Stockage Cloud (OpenList)",
enableAutoSave: "Activer la sauvegarde automatique sur le Cloud",
apiUrl: "URL de l'API",
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Jeton (Token)",
uploadPath: "Chemin de téléchargement",
cloudDrivePathHelper: "Chemin du répertoire dans le cloud, ex. /mytube-uploads",
// Manage
manageContent: "Gérer le contenu",

View File

@@ -84,6 +84,15 @@ export const ja = {
cleanupTempFilesActiveDownloads: "ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
// Cloud Drive
cloudDriveSettings: "クラウドストレージ (OpenList)",
enableAutoSave: "クラウドへの自動保存を有効にする",
apiUrl: "API URL",
apiUrlHelper: "例: https://your-alist-instance.com/api/fs/put",
token: "トークン",
uploadPath: "アップロードパス",
cloudDrivePathHelper: "クラウドドライブ内のディレクトリパス、例: /mytube-uploads",
// Manage
manageContent: "コンテンツの管理",

View File

@@ -84,6 +84,15 @@ export const ko = {
cleanupTempFilesActiveDownloads: "다운로드가 활성화된 동안에는 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하세요.",
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
cleanupTempFilesFailed: "임시 파일 정리 실패",
// Cloud Drive
cloudDriveSettings: "클라우드 드라이브 (OpenList)",
enableAutoSave: "클라우드 자동 저장 활성화",
apiUrl: "API URL",
apiUrlHelper: "예: https://your-alist-instance.com/api/fs/put",
token: "토큰",
uploadPath: "업로드 경로",
cloudDrivePathHelper: "클라우드 드라이브 내 디렉토리 경로, 예: /mytube-uploads",
// Manage
manageContent: "콘텐츠 관리",

View File

@@ -84,6 +84,15 @@ export const pt = {
cleanupTempFilesActiveDownloads: "Não é possível limpar enquanto houver downloads ativos. Aguarde a conclusão de todos os downloads ou cancele-os primeiro.",
cleanupTempFilesSuccess: "{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",
// Cloud Drive
cloudDriveSettings: "Armazenamento em Nuvem (OpenList)",
enableAutoSave: "Ativar salvamento automático na nuvem",
apiUrl: "URL da API",
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "Caminho de upload",
cloudDrivePathHelper: "Caminho do diretório na nuvem, ex. /mytube-uploads",
// Manage
manageContent: "Gerenciar Conteúdo",
@@ -150,6 +159,12 @@ export const pt = {
titleUpdateFailed: "Falha ao atualizar título",
refreshThumbnail: "Atualizar miniatura",
thumbnailRefreshed: "Miniatura atualizada com sucesso",
thumbnailRefreshFailed: "Falha ao atualizar miniatura",
videoUpdated: "Vídeo atualizado com sucesso",
videoUpdateFailed: "Falha ao atualizar vídeo",
failedToLoadVideos: "Falha ao carregar vídeos. Por favor, tente novamente mais tarde.",
videoRemovedSuccessfully: "Vídeo removido com sucesso",
failedToDeleteVideo: "Falha ao excluir vídeo",
// Snackbar Messages
videoDownloading: "Baixando vídeo",
downloadStartedSuccessfully: "Download iniciado com sucesso",

View File

@@ -84,6 +84,15 @@ export const ru = {
cleanupTempFilesActiveDownloads: "Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
// Cloud Drive
cloudDriveSettings: "Облачное хранилище (OpenList)",
enableAutoSave: "Включить автосохранение в облако",
apiUrl: "URL API",
apiUrlHelper: "напр. https://your-alist-instance.com/api/fs/put",
token: "Токен",
uploadPath: "Путь загрузки",
cloudDrivePathHelper: "Путь к каталогу в облаке, напр. /mytube-uploads",
// Manage
manageContent: "Управление контентом",

View File

@@ -84,6 +84,15 @@ export const zh = {
cleanupTempFilesActiveDownloads: "有活动下载时无法清理。请等待所有下载完成或取消它们。",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",
// Cloud Drive
cloudDriveSettings: "云端存储 (OpenList)",
enableAutoSave: "启用自动保存到云端",
apiUrl: "API 地址",
apiUrlHelper: "例如https://your-alist-instance.com/api/fs/put",
token: "Token",
uploadPath: "上传路径",
cloudDrivePathHelper: "云端存储中的目录路径,例如:/mytube-uploads",
// Manage
manageContent: "内容管理",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "mytube",
"version": "1.2.4",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mytube",
"version": "1.2.4",
"version": "1.3.1",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"

View File

@@ -1,6 +1,6 @@
{
"name": "mytube",
"version": "1.2.5",
"version": "1.3.2",
"description": "YouTube video downloader and player application",
"main": "index.js",
"scripts": {