diff --git a/dist-electron/main.js b/dist-electron/main.js index d64fe58..826d38b 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -10,6 +10,8 @@ import { spawn } from "child_process"; import { Socket } from "net"; import { EventEmitter } from "events"; import path from "path"; +import http from "http"; +import { Readable, PassThrough } from "stream"; import fs from "fs"; class MpvController extends EventEmitter { constructor(ipcPath) { @@ -41,7 +43,11 @@ class MpvController extends EventEmitter { "--force-window=no", "--no-media-controls", `--input-ipc-server=${this.ipcPath}`, - "--no-terminal" + "--no-terminal", + // Network cache for smooth playback (disk caching is handled by proxy) + "--cache=yes", + "--demuxer-max-bytes=50MiB", + "--demuxer-readahead-secs=30" ]); this.process.on("error", (err) => { console.error("Failed to start MPV:", err); @@ -74,7 +80,7 @@ class MpvController extends EventEmitter { this.socket.on("data", (data) => { this.handleData(data); }); - this.socket.on("error", (err) => { + this.socket.on("error", (_) => { var _a; (_a = this.socket) == null ? void 0 : _a.destroy(); this.tryConnect(retries - 1); @@ -188,6 +194,381 @@ class PluginSystem { } } } +const PORT = 5266; +let CACHE_DIR = ""; +function ensureCacheDir() { + if (!CACHE_DIR) { + CACHE_DIR = path.join(app.getPath("userData"), "music", "cache"); + } + if (!fs.existsSync(CACHE_DIR)) { + try { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + console.log(`[Proxy] Created cache directory: ${CACHE_DIR}`); + } catch (e) { + console.error("[Proxy] Failed to create cache directory:", e); + } + } + return CACHE_DIR; +} +const urlCache = /* @__PURE__ */ new Map(); +const downloadingFiles = /* @__PURE__ */ new Map(); +function getCachePath(source, id, quality) { + const dir = ensureCacheDir(); + const safeId = id.replace(/[^a-z0-9]/gi, "_"); + return path.join(dir, `${source}-${safeId}-${quality}`); +} +function getMetadataPath(cachePath) { + return cachePath + ".meta"; +} +function readMetadata(cachePath) { + const metaPath = getMetadataPath(cachePath); + try { + if (fs.existsSync(metaPath)) { + return JSON.parse(fs.readFileSync(metaPath, "utf-8")); + } + } catch (e) { + console.error("[Proxy] Failed to read metadata:", e); + } + return null; +} +function writeMetadata(cachePath, metadata) { + const metaPath = getMetadataPath(cachePath); + try { + fs.writeFileSync(metaPath, JSON.stringify(metadata)); + } catch (e) { + console.error("[Proxy] Failed to write metadata:", e); + } +} +async function fetchFreshUrl(source, id, quality) { + try { + const plugin = new PluginSystem(source); + const result = await plugin.getUrl(id, quality); + if (result.success && result.url) { + urlCache.set(`${source}:${id}:${quality}`, { + url: result.url, + expiresAt: Date.now() + 3600 * 1e3 + }); + return result.url; + } + console.error(`[Proxy] Failed to fetch URL for ${source}:${id}`, result.error); + return null; + } catch (e) { + console.error(`[Proxy] Error fetching URL:`, e); + return null; + } +} +function isValidCacheFile(filePath) { + const metadata = readMetadata(filePath); + if (!metadata || !metadata.complete) return false; + if (!fs.existsSync(filePath)) return false; + const stat = fs.statSync(filePath); + return stat.size === metadata.totalSize && stat.size > 1024; +} +function parseRangeHeader(range, fileSize) { + if (!range) return null; + const match = range.match(/bytes=(\d*)-(\d*)/); + if (!match) return null; + let start = match[1] ? parseInt(match[1], 10) : 0; + let end = match[2] ? parseInt(match[2], 10) : fileSize - 1; + if (start >= fileSize) return null; + end = Math.min(end, fileSize - 1); + return { start, end }; +} +function serveFromCache(req, res, filePath) { + const metadata = readMetadata(filePath); + if (!metadata) { + console.warn("[Proxy] No metadata for cache file"); + return false; + } + const stat = fs.statSync(filePath); + const fileSize = stat.size; + const range = parseRangeHeader(req.headers.range, fileSize); + if (range) { + const { start, end } = range; + const chunksize = end - start + 1; + res.writeHead(206, { + "Content-Range": `bytes ${start}-${end}/${fileSize}`, + "Accept-Ranges": "bytes", + "Content-Length": chunksize, + "Content-Type": metadata.contentType || "audio/mpeg" + }); + const file = fs.createReadStream(filePath, { start, end }); + file.pipe(res); + file.on("error", (err) => { + console.error("[Proxy] Error reading cache file:", err); + if (!res.headersSent) { + res.writeHead(500); + res.end("Cache read error"); + } + }); + } else { + res.writeHead(200, { + "Content-Length": fileSize, + "Content-Type": metadata.contentType || "audio/mpeg", + "Accept-Ranges": "bytes" + }); + const stream = fs.createReadStream(filePath); + stream.pipe(res); + stream.on("error", (err) => { + console.error("[Proxy] Error reading cache file:", err); + }); + } + return true; +} +async function proxyWithoutCache(req, res, targetUrl) { + const headers = {}; + if (req.headers.range) { + headers["Range"] = req.headers.range; + } + headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + const response = await fetch(targetUrl, { + method: "GET", + headers + }); + if (!response.ok) { + throw { statusCode: response.status, statusText: response.statusText }; + } + const headersToForward = ["content-type", "content-length", "content-range", "accept-ranges"]; + headersToForward.forEach((h) => { + const val = response.headers.get(h); + if (val) res.setHeader(h, val); + }); + res.statusCode = response.status; + if (!response.body) { + res.end(); + return; + } + const nodeStream = Readable.fromWeb(response.body); + nodeStream.pipe(res); + nodeStream.on("error", (err) => { + console.error("[Proxy] Stream error:", err); + if (!res.headersSent) { + res.writeHead(502); + res.end("Proxy stream error"); + } + }); +} +async function proxyAndCache(req, res, targetUrl, cacheFilePath) { + const requestedRange = req.headers.range; + const downloadState = downloadingFiles.get(cacheFilePath); + if (downloadState) { + console.log(`[Proxy] File already downloading, current: ${downloadState.currentSize}/${downloadState.totalSize}`); + if (requestedRange) { + const range = parseRangeHeader(requestedRange, downloadState.totalSize); + if (range && range.end < downloadState.currentSize) { + console.log(`[Proxy] Serving range from in-progress cache`); + ({ + totalSize: downloadState.totalSize + }); + const { start, end } = range; + const chunksize = end - start + 1; + res.writeHead(206, { + "Content-Range": `bytes ${start}-${end}/${downloadState.totalSize}`, + "Accept-Ranges": "bytes", + "Content-Length": chunksize, + "Content-Type": "audio/mpeg" + }); + const file = fs.createReadStream(cacheFilePath, { start, end }); + file.pipe(res); + return; + } + } + await proxyWithoutCache(req, res, targetUrl); + return; + } + if (requestedRange && !requestedRange.startsWith("bytes=0-")) { + console.log(`[Proxy] Range request without cache: ${requestedRange}`); + await proxyWithoutCache(req, res, targetUrl); + return; + } + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }; + const response = await fetch(targetUrl, { + method: "GET", + headers + }); + if (!response.ok) { + throw { statusCode: response.status, statusText: response.statusText }; + } + const contentLength = parseInt(response.headers.get("content-length") || "0", 10); + const contentType = response.headers.get("content-type") || "audio/mpeg"; + if (!contentLength) { + console.warn("[Proxy] No content-length, proxying without cache"); + res.setHeader("Content-Type", contentType); + res.statusCode = 200; + if (response.body) { + const nodeStream2 = Readable.fromWeb(response.body); + nodeStream2.pipe(res); + } else { + res.end(); + } + return; + } + const tempPath = cacheFilePath + ".tmp"; + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + if (fs.existsSync(cacheFilePath)) fs.unlinkSync(cacheFilePath); + } catch (e) { + console.error("[Proxy] Cleanup error:", e); + } + const fileStream = fs.createWriteStream(tempPath); + downloadingFiles.set(cacheFilePath, { + currentSize: 0, + totalSize: contentLength, + writeStream: fileStream + }); + console.log(`[Proxy] Starting download: ${cacheFilePath} (${contentLength} bytes)`); + res.writeHead(200, { + "Content-Length": contentLength, + "Content-Type": contentType, + "Accept-Ranges": "bytes" + }); + if (!response.body) { + res.end(); + downloadingFiles.delete(cacheFilePath); + return; + } + const nodeStream = Readable.fromWeb(response.body); + const passThrough = new PassThrough(); + let downloadedBytes = 0; + passThrough.on("data", (chunk) => { + downloadedBytes += chunk.length; + const state = downloadingFiles.get(cacheFilePath); + if (state) { + state.currentSize = downloadedBytes; + } + }); + passThrough.pipe(res); + nodeStream.pipe(passThrough); + nodeStream.pipe(fileStream); + const cleanup = (success, error) => { + downloadingFiles.delete(cacheFilePath); + if (success && fs.existsSync(tempPath)) { + try { + const stat = fs.statSync(tempPath); + if (stat.size === contentLength) { + fs.renameSync(tempPath, cacheFilePath); + writeMetadata(cacheFilePath, { + totalSize: contentLength, + contentType, + complete: true, + createdAt: Date.now() + }); + console.log(`[Proxy] Cache complete: ${cacheFilePath}`); + } else { + console.warn(`[Proxy] Incomplete download: ${stat.size}/${contentLength}`); + try { + fs.unlinkSync(tempPath); + } catch { + } + } + } catch (e) { + console.error("[Proxy] Failed to finalize cache:", e); + try { + fs.unlinkSync(tempPath); + } catch { + } + } + } else if (!success) { + console.error("[Proxy] Download failed:", error); + try { + fs.unlinkSync(tempPath); + } catch { + } + } + }; + fileStream.on("finish", () => cleanup(true)); + fileStream.on("error", (err) => cleanup(false, err)); + nodeStream.on("error", (err) => cleanup(false, err)); + res.on("close", () => { + if (!res.writableEnded) { + console.log("[Proxy] Client disconnected during download"); + } + }); +} +function startProxyServer() { + const server = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Range"); + if (req.method === "OPTIONS") { + res.statusCode = 204; + res.end(); + return; + } + if (req.method === "HEAD") { + res.setHeader("Accept-Ranges", "bytes"); + res.statusCode = 200; + res.end(); + return; + } + try { + const parsedUrl = new URL(req.url || "", `http://localhost:${PORT}`); + if (parsedUrl.pathname !== "/music") { + res.writeHead(404); + res.end("Not Found"); + return; + } + const id = parsedUrl.searchParams.get("id"); + const source = parsedUrl.searchParams.get("source"); + const quality = parsedUrl.searchParams.get("quality") || "standard"; + if (!id || !source) { + res.writeHead(400); + res.end("Missing id or source"); + return; + } + const cacheKey = `${source}:${id}:${quality}`; + const cacheFilePath = getCachePath(source, id, quality); + if (isValidCacheFile(cacheFilePath)) { + console.log(`[Proxy] Serving from complete cache: ${cacheFilePath}`); + const success = serveFromCache(req, res, cacheFilePath); + if (success) return; + console.warn("[Proxy] Cache file invalid, cleaning up..."); + try { + fs.unlinkSync(cacheFilePath); + fs.unlinkSync(getMetadataPath(cacheFilePath)); + } catch { + } + } + let playUrl = null; + const memCached = urlCache.get(cacheKey); + if (memCached && Date.now() < memCached.expiresAt) { + playUrl = memCached.url; + } + if (!playUrl) { + console.log(`[Proxy] Resolving URL for ${cacheKey}...`); + playUrl = await fetchFreshUrl(source, id, quality); + } + if (!playUrl) { + res.writeHead(500); + res.end("Failed to Obtain URL"); + return; + } + try { + await proxyAndCache(req, res, playUrl, cacheFilePath); + } catch (e) { + console.warn(`[Proxy] Proxy error:`, e); + if (!res.headersSent) { + res.writeHead(e.statusCode || 502); + res.end("Proxy Error"); + } + } + } catch (err) { + console.error("[Proxy] Internal Error:", err); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal Server Error"); + } + } + }); + server.listen(PORT, "127.0.0.1", () => { + ensureCacheDir(); + console.log(`[Proxy] Server running at http://127.0.0.1:${PORT}/music`); + console.log(`[Proxy] Cache dir: ${CACHE_DIR}`); + }); + return server; +} createRequire(import.meta.url); const __dirname$1 = path$1.dirname(fileURLToPath(import.meta.url)); process.env.APP_ROOT = path$1.join(__dirname$1, ".."); @@ -316,6 +697,7 @@ module.exports = { } Menu.setApplicationMenu(null); createWindow(); + startProxyServer(); mpv = new MpvController(); mpv.start(); mpv.on("event", (data) => { diff --git a/electron/main.ts b/electron/main.ts index 5957be2..bae28a2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import fs from 'node:fs' import { MpvController } from './mpvController' +import { startProxyServer } from './proxyServer' import { PluginSystem } from '../src/main/pluginSystem.ts' // @ts-ignore const require = createRequire(import.meta.url) @@ -163,6 +164,9 @@ module.exports = { Menu.setApplicationMenu(null) createWindow() + // Start Proxy Server + startProxyServer() + // Start MPV mpv = new MpvController() mpv.start() diff --git a/electron/mpvController.ts b/electron/mpvController.ts index ef28a1a..e17f9b1 100644 --- a/electron/mpvController.ts +++ b/electron/mpvController.ts @@ -41,7 +41,11 @@ export class MpvController extends EventEmitter { '--force-window=no', '--no-media-controls', `--input-ipc-server=${this.ipcPath}`, - '--no-terminal' + '--no-terminal', + // Network cache for smooth playback (disk caching is handled by proxy) + '--cache=yes', + '--demuxer-max-bytes=50MiB', + '--demuxer-readahead-secs=30' ]); this.process.on('error', (err) => { @@ -82,7 +86,7 @@ export class MpvController extends EventEmitter { this.handleData(data); }); - this.socket.on('error', (err) => { + this.socket.on('error', (_) => { this.socket?.destroy(); this.tryConnect(retries - 1); }); diff --git a/electron/proxyServer.ts b/electron/proxyServer.ts new file mode 100644 index 0000000..16a3378 --- /dev/null +++ b/electron/proxyServer.ts @@ -0,0 +1,548 @@ +import http from 'http'; +import { Readable, PassThrough } from 'stream'; +import fs from 'fs'; +import path from 'path'; +import { app } from 'electron'; +// @ts-ignore +import { PluginSystem } from '../src/main/pluginSystem.ts'; + +const PORT = 5266; +let CACHE_DIR = ''; + +function ensureCacheDir() { + if (!CACHE_DIR) { + CACHE_DIR = path.join(app.getPath('userData'), 'music', 'cache'); + } + if (!fs.existsSync(CACHE_DIR)) { + try { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + console.log(`[Proxy] Created cache directory: ${CACHE_DIR}`); + } catch (e) { + console.error('[Proxy] Failed to create cache directory:', e); + } + } + return CACHE_DIR; +} + +interface CacheEntry { + url: string; + expiresAt: number; +} + +interface CacheMetadata { + totalSize: number; + contentType: string; + complete: boolean; + createdAt: number; +} + +// Memory cache for resolved URLs +const urlCache = new Map(); +// Track in-progress downloads with their current size +const downloadingFiles = new Map(); + +function getCachePath(source: string, id: string, quality: string) { + const dir = ensureCacheDir(); + const safeId = id.replace(/[^a-z0-9]/gi, '_'); + return path.join(dir, `${source}-${safeId}-${quality}`); +} + +function getMetadataPath(cachePath: string): string { + return cachePath + '.meta'; +} + +function readMetadata(cachePath: string): CacheMetadata | null { + const metaPath = getMetadataPath(cachePath); + try { + if (fs.existsSync(metaPath)) { + return JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + } + } catch (e) { + console.error('[Proxy] Failed to read metadata:', e); + } + return null; +} + +function writeMetadata(cachePath: string, metadata: CacheMetadata) { + const metaPath = getMetadataPath(cachePath); + try { + fs.writeFileSync(metaPath, JSON.stringify(metadata)); + } catch (e) { + console.error('[Proxy] Failed to write metadata:', e); + } +} + +async function fetchFreshUrl(source: string, id: string, quality: string): Promise { + try { + const plugin = new PluginSystem(source); + const result = await plugin.getUrl(id, quality); + if (result.success && result.url) { + urlCache.set(`${source}:${id}:${quality}`, { + url: result.url, + expiresAt: Date.now() + 3600 * 1000 + }); + return result.url; + } + console.error(`[Proxy] Failed to fetch URL for ${source}:${id}`, result.error); + return null; + } catch (e) { + console.error(`[Proxy] Error fetching URL:`, e); + return null; + } +} + +function isValidCacheFile(filePath: string): boolean { + const metadata = readMetadata(filePath); + if (!metadata || !metadata.complete) return false; + + if (!fs.existsSync(filePath)) return false; + const stat = fs.statSync(filePath); + + // Verify the file size matches expected total + return stat.size === metadata.totalSize && stat.size > 1024; +} + +function parseRangeHeader(range: string | undefined, fileSize: number): { start: number; end: number } | null { + if (!range) return null; + + const match = range.match(/bytes=(\d*)-(\d*)/); + if (!match) return null; + + let start = match[1] ? parseInt(match[1], 10) : 0; + let end = match[2] ? parseInt(match[2], 10) : fileSize - 1; + + if (start >= fileSize) return null; + end = Math.min(end, fileSize - 1); + + return { start, end }; +} + +function serveFromCache(req: http.IncomingMessage, res: http.ServerResponse, filePath: string): boolean { + const metadata = readMetadata(filePath); + if (!metadata) { + console.warn('[Proxy] No metadata for cache file'); + return false; + } + + const stat = fs.statSync(filePath); + const fileSize = stat.size; + const range = parseRangeHeader(req.headers.range, fileSize); + + if (range) { + const { start, end } = range; + const chunksize = (end - start) + 1; + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': metadata.contentType || 'audio/mpeg', + }); + + const file = fs.createReadStream(filePath, { start, end }); + file.pipe(res); + + file.on('error', (err) => { + console.error('[Proxy] Error reading cache file:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Cache read error'); + } + }); + } else { + res.writeHead(200, { + 'Content-Length': fileSize, + 'Content-Type': metadata.contentType || 'audio/mpeg', + 'Accept-Ranges': 'bytes', + }); + const stream = fs.createReadStream(filePath); + stream.pipe(res); + + stream.on('error', (err) => { + console.error('[Proxy] Error reading cache file:', err); + }); + } + return true; +} + +// Serve partial content from an incomplete cache file + proxy the rest +async function servePartialFromCache( + req: http.IncomingMessage, + res: http.ServerResponse, + filePath: string, + cachedSize: number, + totalSize: number, + contentType: string, + targetUrl: string +): Promise { + const range = parseRangeHeader(req.headers.range, totalSize); + + // If no range or range starts from 0, and we have some cached data + if (!range || range.start === 0) { + // Can serve from beginning of cache + if (cachedSize > 0 && range && range.end < cachedSize) { + // Entire requested range is in cache + const { start, end } = range; + const chunksize = (end - start) + 1; + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${totalSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': contentType, + }); + + const file = fs.createReadStream(filePath, { start, end }); + file.pipe(res); + console.log(`[Proxy] Served range ${start}-${end} from partial cache`); + return; + } + } + + if (range && range.start < cachedSize) { + console.log(`[Proxy] Range ${range.start}-${range.end} partially in cache, proxying all`); + } + + // Fallback: proxy from origin + await proxyWithoutCache(req, res, targetUrl); +} + +async function proxyWithoutCache( + req: http.IncomingMessage, + res: http.ServerResponse, + targetUrl: string +): Promise { + const headers: Record = {}; + + if (req.headers.range) { + headers['Range'] = req.headers.range; + } + headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + + const response = await fetch(targetUrl, { + method: 'GET', + headers: headers, + }); + + if (!response.ok) { + throw { statusCode: response.status, statusText: response.statusText }; + } + + const headersToForward = ['content-type', 'content-length', 'content-range', 'accept-ranges']; + headersToForward.forEach(h => { + const val = response.headers.get(h); + if (val) res.setHeader(h, val); + }); + + res.statusCode = response.status; + + if (!response.body) { + res.end(); + return; + } + + // @ts-ignore + const nodeStream = Readable.fromWeb(response.body); + nodeStream.pipe(res); + + nodeStream.on('error', (err: Error) => { + console.error('[Proxy] Stream error:', err); + if (!res.headersSent) { + res.writeHead(502); + res.end('Proxy stream error'); + } + }); +} + +async function proxyAndCache( + req: http.IncomingMessage, + res: http.ServerResponse, + targetUrl: string, + cacheFilePath: string +): Promise { + const requestedRange = req.headers.range; + + // Check if already downloading + const downloadState = downloadingFiles.get(cacheFilePath); + if (downloadState) { + // Another request is already downloading this file + console.log(`[Proxy] File already downloading, current: ${downloadState.currentSize}/${downloadState.totalSize}`); + + // For range requests, check if we can serve from partial cache + if (requestedRange) { + const range = parseRangeHeader(requestedRange, downloadState.totalSize); + if (range && range.end < downloadState.currentSize) { + // Requested range is fully downloaded, serve from cache + console.log(`[Proxy] Serving range from in-progress cache`); + const metadata: CacheMetadata = { + totalSize: downloadState.totalSize, + contentType: 'audio/mpeg', + complete: false, + createdAt: Date.now() + }; + + const { start, end } = range; + const chunksize = (end - start) + 1; + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${downloadState.totalSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': 'audio/mpeg', + }); + + const file = fs.createReadStream(cacheFilePath, { start, end }); + file.pipe(res); + return; + } + } + + // Can't serve from cache, proxy directly + await proxyWithoutCache(req, res, targetUrl); + return; + } + + // Check if this is a range request that doesn't start from 0 + if (requestedRange && !requestedRange.startsWith('bytes=0-')) { + // This is a seek request - we need to fetch total size first + // then decide whether to start a full download or just proxy + console.log(`[Proxy] Range request without cache: ${requestedRange}`); + await proxyWithoutCache(req, res, targetUrl); + return; + } + + // Start a fresh download from the beginning + const headers: Record = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }; + + const response = await fetch(targetUrl, { + method: 'GET', + headers: headers, + }); + + if (!response.ok) { + throw { statusCode: response.status, statusText: response.statusText }; + } + + const contentLength = parseInt(response.headers.get('content-length') || '0', 10); + const contentType = response.headers.get('content-type') || 'audio/mpeg'; + + if (!contentLength) { + console.warn('[Proxy] No content-length, proxying without cache'); + res.setHeader('Content-Type', contentType); + res.statusCode = 200; + + if (response.body) { + // @ts-ignore + const nodeStream = Readable.fromWeb(response.body); + nodeStream.pipe(res); + } else { + res.end(); + } + return; + } + + // Setup caching + const tempPath = cacheFilePath + '.tmp'; + + // Clean up any existing files + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + if (fs.existsSync(cacheFilePath)) fs.unlinkSync(cacheFilePath); + } catch (e) { + console.error('[Proxy] Cleanup error:', e); + } + + const fileStream = fs.createWriteStream(tempPath); + + downloadingFiles.set(cacheFilePath, { + currentSize: 0, + totalSize: contentLength, + writeStream: fileStream + }); + + console.log(`[Proxy] Starting download: ${cacheFilePath} (${contentLength} bytes)`); + + // Set response headers + res.writeHead(200, { + 'Content-Length': contentLength, + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + }); + + if (!response.body) { + res.end(); + downloadingFiles.delete(cacheFilePath); + return; + } + + // @ts-ignore + const nodeStream = Readable.fromWeb(response.body); + const passThrough = new PassThrough(); + + // Track download progress + let downloadedBytes = 0; + passThrough.on('data', (chunk: Buffer) => { + downloadedBytes += chunk.length; + const state = downloadingFiles.get(cacheFilePath); + if (state) { + state.currentSize = downloadedBytes; + } + }); + + // Pipe to client + passThrough.pipe(res); + + // Pipe to file + nodeStream.pipe(passThrough); + nodeStream.pipe(fileStream); + + // Handle completion + const cleanup = (success: boolean, error?: Error) => { + downloadingFiles.delete(cacheFilePath); + + if (success && fs.existsSync(tempPath)) { + try { + const stat = fs.statSync(tempPath); + if (stat.size === contentLength) { + fs.renameSync(tempPath, cacheFilePath); + writeMetadata(cacheFilePath, { + totalSize: contentLength, + contentType: contentType, + complete: true, + createdAt: Date.now() + }); + console.log(`[Proxy] Cache complete: ${cacheFilePath}`); + } else { + console.warn(`[Proxy] Incomplete download: ${stat.size}/${contentLength}`); + try { fs.unlinkSync(tempPath); } catch { } + } + } catch (e) { + console.error('[Proxy] Failed to finalize cache:', e); + try { fs.unlinkSync(tempPath); } catch { } + } + } else if (!success) { + console.error('[Proxy] Download failed:', error); + try { fs.unlinkSync(tempPath); } catch { } + } + }; + + fileStream.on('finish', () => cleanup(true)); + fileStream.on('error', (err) => cleanup(false, err)); + nodeStream.on('error', (err: Error) => cleanup(false, err)); + + // Handle client disconnect + res.on('close', () => { + if (!res.writableEnded) { + console.log('[Proxy] Client disconnected during download'); + // Don't cleanup - let the download continue in background + } + }); +} + +export function startProxyServer() { + const server = http.createServer(async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Range'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + // Handle HEAD requests for range support detection + if (req.method === 'HEAD') { + res.setHeader('Accept-Ranges', 'bytes'); + res.statusCode = 200; + res.end(); + return; + } + + try { + const parsedUrl = new URL(req.url || '', `http://localhost:${PORT}`); + if (parsedUrl.pathname !== '/music') { + res.writeHead(404); + res.end('Not Found'); + return; + } + + const id = parsedUrl.searchParams.get('id'); + const source = parsedUrl.searchParams.get('source'); + const quality = parsedUrl.searchParams.get('quality') || 'standard'; + + if (!id || !source) { + res.writeHead(400); + res.end('Missing id or source'); + return; + } + + const cacheKey = `${source}:${id}:${quality}`; + const cacheFilePath = getCachePath(source, id, quality); + + // 1. Check complete disk cache + if (isValidCacheFile(cacheFilePath)) { + console.log(`[Proxy] Serving from complete cache: ${cacheFilePath}`); + const success = serveFromCache(req, res, cacheFilePath); + if (success) return; + + // Cache invalid, clean up + console.warn('[Proxy] Cache file invalid, cleaning up...'); + try { + fs.unlinkSync(cacheFilePath); + fs.unlinkSync(getMetadataPath(cacheFilePath)); + } catch { } + } + + // 2. Resolve URL + let playUrl: string | null = null; + const memCached = urlCache.get(cacheKey); + if (memCached && Date.now() < memCached.expiresAt) { + playUrl = memCached.url; + } + + if (!playUrl) { + console.log(`[Proxy] Resolving URL for ${cacheKey}...`); + playUrl = await fetchFreshUrl(source, id, quality); + } + + if (!playUrl) { + res.writeHead(500); + res.end('Failed to Obtain URL'); + return; + } + + // 3. Proxy & Cache + try { + await proxyAndCache(req, res, playUrl, cacheFilePath); + } catch (e: any) { + console.warn(`[Proxy] Proxy error:`, e); + if (!res.headersSent) { + res.writeHead(e.statusCode || 502); + res.end('Proxy Error'); + } + } + + } catch (err) { + console.error('[Proxy] Internal Error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + } + }); + + server.listen(PORT, '127.0.0.1', () => { + ensureCacheDir(); + console.log(`[Proxy] Server running at http://127.0.0.1:${PORT}/music`); + console.log(`[Proxy] Cache dir: ${CACHE_DIR}`); + }); + + return server; +} diff --git a/src/renderer/stores/player.ts b/src/renderer/stores/player.ts index d3befa2..2a58135 100644 --- a/src/renderer/stores/player.ts +++ b/src/renderer/stores/player.ts @@ -36,6 +36,7 @@ export const usePlayerStore = defineStore('player', () => { // Error Handling const playErrorCount = ref(0); const MAX_RETRY_COUNT = 3; + const hasRetriedWithFreshUrl = ref(false); // --- Helpers --- @@ -69,37 +70,7 @@ export const usePlayerStore = defineStore('player', () => { // --- Actions --- - // Cache: key = `${source}:${id}:${quality}` - const urlCache = new Map(); - const fetchUrl = async (song: Song, forceRefresh = false): Promise => { - if (!song.source || !song.id) return null; - - const quality = 'hires'; // TODO: Make configurable - const cacheKey = `${song.source}:${song.id}:${quality}`; - - if (!forceRefresh && urlCache.has(cacheKey)) { - console.log(`[Cache] Hit for ${song.name}`); - return urlCache.get(cacheKey) || null; - } - - console.log(`[Plugin] Fetching URL for [${song.name}] from ${song.source}...`); - try { - const result = await window.electronAPI.plugin.call(song.source, 'getUrl', [song.id, quality]); - if (result.success && result.url) { - console.log('[Plugin] Success:', result.url); - urlCache.set(cacheKey, result.url); - return result.url; - } else { - console.error('[Plugin] Failed:', result.error); - if (!forceRefresh) MessagePlugin.error(`无法获取播放链接: ${result.error || '未知错误'}`); - return null; - } - } catch (e) { - console.error('[Plugin] Error:', e); - return null; - } - }; const setPlaylist = async (list: any[], startIndex = 0) => { playlist.value = list; @@ -124,38 +95,24 @@ export const usePlayerStore = defineStore('player', () => { // 1. Get URL (Cache -> Network) let playUrl = song.url; if (song.type === 'Remote' && song.source) { - const fetched = await fetchUrl(song); - if (fetched) playUrl = fetched; + // Use Local Proxy + const quality = 'hires'; + playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`; + console.log('[Player] Using Proxy:', playUrl); } if (playUrl) { console.log('Playing:', song.name); + // Reset retry flag for new playback attempt + hasRetriedWithFreshUrl.value = false; try { await window.electronAPI.mpv.load(playUrl); await window.electronAPI.mpv.play(); isPlaying.value = true; - // Update song object URL for UI reference if needed song.url = playUrl; } catch (e) { - console.error("Play request failed:", e); - - // Retry Logic: If Remote and failed, force refresh URL and retry - if (song.type === 'Remote' && song.source) { - console.warn("Playback failed. Retrying with fresh URL..."); - const freshUrl = await fetchUrl(song, true); // Force Refresh - if (freshUrl) { - try { - await window.electronAPI.mpv.load(freshUrl); - await window.electronAPI.mpv.play(); - isPlaying.value = true; - song.url = freshUrl; - return; // Success on retry - } catch (retryError) { - console.error("Retry playback failed:", retryError); - } - } - } - + // IPC call failed (rare), handle sync error + console.error("IPC Play request failed:", e); handlePlayError(); } } else { @@ -212,8 +169,14 @@ export const usePlayerStore = defineStore('player', () => { await playSong(playlist.value[prevIndex]); }; - const handlePlayError = () => { + const handlePlayError = async () => { + // Proxy handles refreshing internally, so we rely on MPV error/retry for now. + // Or we could implement a mechanism to tell proxy to invalidate cache if this fails repeatedly (future work). + + // Normal error handling playErrorCount.value++; + hasRetriedWithFreshUrl.value = false; // Reset for next song + if (playlist.value.length === 0) { isPlaying.value = false; playErrorCount.value = 0; @@ -228,7 +191,7 @@ export const usePlayerStore = defineStore('player', () => { syncDummyAudioState(false); } else { MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`); - setTimeout(() => next(false), 1000); + setTimeout(() => next(false), 500); } };