From 8eab16cbf5c254ec9eafa0d71fbd04ffde56bf02 Mon Sep 17 00:00:00 2001 From: lqtmcstudio Date: Wed, 4 Feb 2026 14:14:40 +0800 Subject: [PATCH] feat: Implement features and continuously optimize the proxy service - Settings page UI - Persist settings data - Proxy server fixes & optimizations: - Fixed download latency caused by Range Seek - Improved cache stability - Implemented a dual-threading model to ensure smooth playback - Optimized styles for the homepage TopBar and SideBar - Added theme switching functionality - Added theme color customization feature --- dist-electron/main.js | 581 +++++++++++++++++----- dist-electron/preload.mjs | 14 + electron/main.ts | 52 +- electron/preload.ts | 16 + electron/proxyServer.ts | 654 +++++++++++++++++++------ electron/settingsStore.ts | 65 +++ src/renderer/App.vue | 17 + src/renderer/components/PlayerBar.vue | 37 +- src/renderer/components/Settings.vue | 680 ++++++++++++++++++++++++++ src/renderer/components/Sidebar.vue | 26 +- src/renderer/components/TopBar.vue | 9 +- src/renderer/styles/variables.css | 93 +++- src/renderer/types/electron.d.ts | 14 + 13 files changed, 1935 insertions(+), 323 deletions(-) create mode 100644 electron/settingsStore.ts create mode 100644 src/renderer/components/Settings.vue diff --git a/dist-electron/main.js b/dist-electron/main.js index 2792ed2..1d39b24 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -150,7 +150,7 @@ class MpvController extends EventEmitter { } } } -const require$1 = createRequire(import.meta.url); +const require$2 = createRequire(import.meta.url); class PluginSystem { constructor(pluginId) { __publicField(this, "pluginId"); @@ -169,8 +169,8 @@ class PluginSystem { if (!fs.existsSync(pluginPath)) { throw new Error(`Plugin ${this.pluginId} not found`); } - delete require$1.cache[require$1.resolve(pluginPath)]; - this.plugin = require$1(pluginPath); + delete require$2.cache[require$2.resolve(pluginPath)]; + this.plugin = require$2(pluginPath); } catch (e) { console.error(`[PluginSystem] load failed:`, e); this.plugin = null; @@ -211,7 +211,7 @@ function ensureCacheDir() { return CACHE_DIR; } const urlCache = /* @__PURE__ */ new Map(); -const downloadingFiles = /* @__PURE__ */ new Map(); +const downloadTasks = /* @__PURE__ */ new Map(); function getCachePath(source, id, quality) { const dir = ensureCacheDir(); const safeId = id.replace(/[^a-z0-9]/gi, "_"); @@ -220,6 +220,9 @@ function getCachePath(source, id, quality) { function getMetadataPath(cachePath) { return cachePath + ".meta"; } +function getTempPath(cachePath) { + return cachePath + ".tmp"; +} function readMetadata(cachePath) { const metaPath = getMetadataPath(cachePath); try { @@ -258,11 +261,15 @@ async function fetchFreshUrl(source, id, quality) { } } 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; + try { + 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; + } catch { + return false; + } } function parseRangeHeader(range, fileSize) { if (!range) return null; @@ -275,107 +282,270 @@ function parseRangeHeader(range, fileSize) { return { start, end }; } function serveFromCache(req, res, filePath) { - const metadata = readMetadata(filePath); - if (!metadata) { - console.warn("[Proxy] No metadata for cache file"); + try { + 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; + } catch (e) { + console.error("[Proxy] Error serving from cache:", e); 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 = {}; +async function proxyRangeDirect(req, res, targetUrl, totalSize, contentType) { + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }; 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) { + if (!response.ok && response.status !== 206) { 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; + const responseContentType = response.headers.get("content-type") || contentType || "audio/mpeg"; + const contentLength = response.headers.get("content-length"); + const contentRange = response.headers.get("content-range"); + if (response.status === 206 && contentRange) { + res.writeHead(206, { + "Content-Type": responseContentType, + "Content-Length": contentLength || "", + "Content-Range": contentRange, + "Accept-Ranges": "bytes" + }); + } else { + res.writeHead(200, { + "Content-Type": responseContentType, + "Content-Length": contentLength || "", + "Accept-Ranges": "bytes" + }); + } 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"); - } + return new Promise((resolve, reject) => { + nodeStream.on("end", resolve); + nodeStream.on("error", reject); + res.on("close", () => { + nodeStream.destroy(); + resolve(); + }); }); } -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`); - 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" +function serveFromPartialCache(req, res, filePath, task) { + try { + const tempPath = getTempPath(filePath); + if (!fs.existsSync(tempPath)) return false; + const range = parseRangeHeader(req.headers.range, task.totalSize); + if (!range) return false; + const { start, end } = range; + if (end >= task.currentSize) { + return false; + } + const chunksize = end - start + 1; + res.writeHead(206, { + "Content-Range": `bytes ${start}-${end}/${task.totalSize}`, + "Accept-Ranges": "bytes", + "Content-Length": chunksize, + "Content-Type": task.contentType || "audio/mpeg" + }); + const file = fs.createReadStream(tempPath, { start, end }); + file.pipe(res); + return true; + } catch (e) { + console.error("[Proxy] Error serving from partial cache:", e); + return false; + } +} +function startBackgroundDownload(targetUrl, cacheFilePath, cacheKey) { + const existingTask = downloadTasks.get(cacheFilePath); + if (existingTask) { + return existingTask; + } + const abortController = new AbortController(); + const task = { + currentSize: 0, + totalSize: 0, + contentType: "audio/mpeg", + abortController, + promise: null + }; + downloadTasks.set(cacheFilePath, task); + task.promise = (async () => { + const tempPath = getTempPath(cacheFilePath); + try { + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch { + } + const response = await fetch(targetUrl, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }, + signal: abortController.signal + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const contentLength = parseInt(response.headers.get("content-length") || "0", 10); + const contentType = response.headers.get("content-type") || "audio/mpeg"; + if (!contentLength) { + console.warn("[Proxy] Background download: No content-length, skipping cache"); + downloadTasks.delete(cacheFilePath); + return; + } + task.totalSize = contentLength; + task.contentType = contentType; + console.log(`[Proxy] Background download started: ${cacheFilePath} (${contentLength} bytes)`); + const fileStream = fs.createWriteStream(tempPath); + if (!response.body) { + downloadTasks.delete(cacheFilePath); + return; + } + const nodeStream = Readable.fromWeb(response.body); + await new Promise((resolve, reject) => { + nodeStream.on("data", (chunk) => { + task.currentSize += chunk.length; }); - const file = fs.createReadStream(cacheFilePath, { start, end }); - file.pipe(res); + nodeStream.pipe(fileStream); + fileStream.on("finish", () => { + 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] Background download 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 { + } + } + resolve(); + }); + fileStream.on("error", (err) => { + console.error("[Proxy] Background download write error:", err); + try { + fs.unlinkSync(tempPath); + } catch { + } + reject(err); + }); + nodeStream.on("error", (err) => { + console.error("[Proxy] Background download stream error:", err); + fileStream.destroy(); + try { + fs.unlinkSync(tempPath); + } catch { + } + reject(err); + }); + }); + } catch (e) { + if (e.name !== "AbortError") { + console.error("[Proxy] Background download failed:", e); + } + try { + fs.unlinkSync(tempPath); + } catch { + } + } finally { + downloadTasks.delete(cacheFilePath); + } + })(); + return task; +} +async function proxyAndCache(req, res, targetUrl, cacheFilePath, cacheKey) { + const requestedRange = req.headers.range; + const isSeekRequest = requestedRange && !requestedRange.startsWith("bytes=0-"); + let task = downloadTasks.get(cacheFilePath); + if (task) { + console.log(`[Proxy] Download in progress: ${task.currentSize}/${task.totalSize}`); + if (requestedRange && task.totalSize > 0) { + const range = parseRangeHeader(requestedRange, task.totalSize); + if (range) { + const safeEnd = Math.floor(task.currentSize * 0.95); + if (range.end < safeEnd && range.start < safeEnd) { + console.log(`[Proxy] Serving range ${range.start}-${range.end} from partial cache (downloaded: ${task.currentSize})`); + if (serveFromPartialCache(req, res, cacheFilePath, task)) { + return; + } + } + console.log(`[Proxy] Range ${range.start}-${range.end} not cached yet (downloaded: ${task.currentSize}), proxying directly`); + await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType); return; } } - await proxyWithoutCache(req, res, targetUrl); + await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType); return; } - if (requestedRange && !requestedRange.startsWith("bytes=0-")) { - console.log(`[Proxy] Range request without cache: ${requestedRange}`); - await proxyWithoutCache(req, res, targetUrl); + if (isSeekRequest) { + console.log(`[Proxy] Seek request: ${requestedRange}, starting background download`); + startBackgroundDownload(targetUrl, cacheFilePath); + const headResponse = await fetch(targetUrl, { + method: "HEAD", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + }); + const totalSize = parseInt(headResponse.headers.get("content-length") || "0", 10); + const contentType2 = headResponse.headers.get("content-type") || "audio/mpeg"; + await proxyRangeDirect(req, res, targetUrl, totalSize, contentType2); return; } const headers = { @@ -392,8 +562,10 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath) { 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; + res.writeHead(200, { + "Content-Type": contentType, + "Accept-Ranges": "bytes" + }); if (response.body) { const nodeStream2 = Readable.fromWeb(response.body); nodeStream2.pipe(res); @@ -402,20 +574,22 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath) { } return; } - const tempPath = cacheFilePath + ".tmp"; + const tempPath = getTempPath(cacheFilePath); try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); if (fs.existsSync(cacheFilePath)) fs.unlinkSync(cacheFilePath); - } catch (e) { - console.error("[Proxy] Cleanup error:", e); + } catch { } const fileStream = fs.createWriteStream(tempPath); - downloadingFiles.set(cacheFilePath, { + task = { currentSize: 0, totalSize: contentLength, - writeStream: fileStream - }); - console.log(`[Proxy] Starting download: ${cacheFilePath} (${contentLength} bytes)`); + contentType, + abortController: null, + promise: null + }; + downloadTasks.set(cacheFilePath, task); + console.log(`[Proxy] Starting stream download: ${cacheFilePath} (${contentLength} bytes)`); res.writeHead(200, { "Content-Length": contentLength, "Content-Type": contentType, @@ -423,24 +597,21 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath) { }); if (!response.body) { res.end(); - downloadingFiles.delete(cacheFilePath); + downloadTasks.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; + if (task) { + task.currentSize += chunk.length; } }); passThrough.pipe(res); nodeStream.pipe(passThrough); nodeStream.pipe(fileStream); const cleanup = (success, error) => { - downloadingFiles.delete(cacheFilePath); + downloadTasks.delete(cacheFilePath); if (success && fs.existsSync(tempPath)) { try { const stat = fs.statSync(tempPath); @@ -469,25 +640,83 @@ async function proxyAndCache(req, res, targetUrl, cacheFilePath) { } } 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)); + nodeStream.on("error", (err) => { + cleanup(false, err); + if (!res.headersSent) { + res.writeHead(502); + res.end("Proxy stream error"); + } + }); res.on("close", () => { if (!res.writableEnded) { - console.log("[Proxy] Client disconnected during download"); + console.log("[Proxy] Client disconnected, download continues in background"); } }); } let persistCacheEnabled = true; +function getCacheDir() { + return ensureCacheDir(); +} +function getCacheSize() { + const dir = ensureCacheDir(); + if (!fs.existsSync(dir)) return "0 B"; + let totalSize = 0; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + try { + const stat = fs.statSync(filePath); + if (stat.isFile()) { + totalSize += stat.size; + } + } catch { + } + } + } catch (e) { + console.error("[Proxy] Error calculating cache size:", e); + } + if (totalSize < 1024) return `${totalSize} B`; + if (totalSize < 1024 * 1024) return `${(totalSize / 1024).toFixed(1)} KB`; + if (totalSize < 1024 * 1024 * 1024) return `${(totalSize / (1024 * 1024)).toFixed(1)} MB`; + return `${(totalSize / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} +function setPersistCache(persist) { + persistCacheEnabled = persist; + console.log(`[Proxy] Cache persistence set to: ${persist}`); +} +function clearCacheNow() { + const dir = ensureCacheDir(); + if (fs.existsSync(dir)) { + console.log(`[Proxy] Clearing cache directory: ${dir}`); + for (const [, task] of downloadTasks) { + if (task.abortController) { + task.abortController.abort(); + } + } + downloadTasks.clear(); + try { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + console.log("[Proxy] Cache cleared"); + } catch (e) { + console.error("[Proxy] Failed to clear cache:", e); + } + } +} function cleanupCache() { if (!persistCacheEnabled && CACHE_DIR && fs.existsSync(CACHE_DIR)) { console.log(`[Proxy] Cleaning up cache directory: ${CACHE_DIR}`); + for (const [, task] of downloadTasks) { + if (task.abortController) { + task.abortController.abort(); + } + } + downloadTasks.clear(); try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); console.log("[Proxy] Cache cleanup complete"); @@ -496,9 +725,35 @@ function cleanupCache() { } } } +function cleanupTempFiles() { + const dir = ensureCacheDir(); + try { + const files = fs.readdirSync(dir); + const now = Date.now(); + for (const file of files) { + if (file.endsWith(".tmp")) { + const filePath = path.join(dir, file); + const cacheFilePath = filePath.replace(".tmp", ""); + if (!downloadTasks.has(cacheFilePath)) { + try { + const stat = fs.statSync(filePath); + if (now - stat.mtimeMs > 3600 * 1e3) { + fs.unlinkSync(filePath); + console.log(`[Proxy] Cleaned up stale temp file: ${file}`); + } + } catch { + } + } + } + } + } catch (e) { + console.error("[Proxy] Error cleaning up temp files:", e); + } +} function startProxyServer(persistCache = true) { persistCacheEnabled = persistCache; console.log(`[Proxy] Persist cache: ${persistCache}`); + setInterval(cleanupTempFiles, 30 * 60 * 1e3); const server = http.createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); @@ -556,13 +811,33 @@ function startProxyServer(persistCache = true) { 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"); + const maxAttempts = 3; + let currentAttempt = 0; + let lastError = null; + while (currentAttempt < maxAttempts) { + try { + await proxyAndCache(req, res, playUrl, cacheFilePath, cacheKey); + break; + } catch (e) { + lastError = e; + currentAttempt++; + console.warn(`[Proxy] Proxy error (Attempt ${currentAttempt}/${maxAttempts}):`, e); + if (currentAttempt < maxAttempts) { + console.log("[Proxy] Error occurred, refreshing URL from plugin..."); + urlCache.delete(cacheKey); + const newUrl = await fetchFreshUrl(source, id, quality); + if (newUrl) { + playUrl = newUrl; + continue; + } else { + console.error("[Proxy] Failed to refresh URL"); + } + } + if (!res.headersSent) { + res.writeHead((lastError == null ? void 0 : lastError.statusCode) || 502); + res.end("Proxy Error"); + } + break; } } } catch (err) { @@ -575,12 +850,53 @@ function startProxyServer(persistCache = true) { }); server.listen(PORT, "127.0.0.1", () => { ensureCacheDir(); + cleanupTempFiles(); 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 DEFAULT_SETTINGS = { + persistCache: true, + theme: "dark", + accentColor: "#ec4141" + // Default red +}; +let settingsCache = null; +function getSettingsPath() { + return path.join(app.getPath("userData"), "settings.json"); +} +function loadSettings() { + if (settingsCache) return settingsCache; + const settingsPath = getSettingsPath(); + try { + if (fs.existsSync(settingsPath)) { + const data = fs.readFileSync(settingsPath, "utf-8"); + settingsCache = { ...DEFAULT_SETTINGS, ...JSON.parse(data) }; + console.log("[Settings] Loaded from disk:", settingsCache); + return settingsCache; + } + } catch (e) { + console.error("[Settings] Failed to load settings:", e); + } + settingsCache = { ...DEFAULT_SETTINGS }; + return settingsCache; +} +function saveSettings(settings) { + settingsCache = { ...loadSettings(), ...settings }; + const settingsPath = getSettingsPath(); + try { + fs.writeFileSync(settingsPath, JSON.stringify(settingsCache, null, 2)); + console.log("[Settings] Saved to disk:", settingsCache); + } catch (e) { + console.error("[Settings] Failed to save settings:", e); + } + return settingsCache; +} +function getSetting(key) { + return loadSettings()[key]; +} +const require$1 = createRequire(import.meta.url); const __dirname$1 = path$1.dirname(fileURLToPath(import.meta.url)); process.env.APP_ROOT = path$1.join(__dirname$1, ".."); const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; @@ -646,6 +962,43 @@ ipcMain.handle( return await plugin[method](...args); } ); +ipcMain.handle("cache:getInfo", () => { + const settings = loadSettings(); + return { + path: getCacheDir(), + size: getCacheSize(), + persistCache: settings.persistCache + }; +}); +ipcMain.handle("cache:setPersist", (_, persist) => { + setPersistCache(persist); + saveSettings({ persistCache: persist }); +}); +ipcMain.handle("cache:openFolder", () => { + const dir = getCacheDir(); + require$1("electron").shell.openPath(dir); +}); +ipcMain.handle("cache:clear", () => { + clearCacheNow(); +}); +ipcMain.handle("settings:getAll", () => { + return loadSettings(); +}); +ipcMain.handle("settings:set", (_, settings) => { + return saveSettings(settings); +}); +ipcMain.handle("settings:getTheme", () => { + return getSetting("theme"); +}); +ipcMain.handle("settings:setTheme", (_, theme) => { + saveSettings({ theme }); +}); +ipcMain.handle("settings:getAccentColor", () => { + return getSetting("accentColor"); +}); +ipcMain.handle("settings:setAccentColor", (_, color) => { + saveSettings({ accentColor: color }); +}); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 747815e..4927ff7 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -20,5 +20,19 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { // Plugin System plugin: { call: (pluginId, method, args) => electron.ipcRenderer.invoke("plugin:call", pluginId, method, args) + }, + // Cache Control + getCacheInfo: () => electron.ipcRenderer.invoke("cache:getInfo"), + setCachePersist: (persist) => electron.ipcRenderer.invoke("cache:setPersist", persist), + openCacheFolder: () => electron.ipcRenderer.invoke("cache:openFolder"), + clearCache: () => electron.ipcRenderer.invoke("cache:clear"), + // Settings + settings: { + getAll: () => electron.ipcRenderer.invoke("settings:getAll"), + set: (settings) => electron.ipcRenderer.invoke("settings:set", settings), + getTheme: () => electron.ipcRenderer.invoke("settings:getTheme"), + setTheme: (theme) => electron.ipcRenderer.invoke("settings:setTheme", theme), + getAccentColor: () => electron.ipcRenderer.invoke("settings:getAccentColor"), + setAccentColor: (color) => electron.ipcRenderer.invoke("settings:setAccentColor", color) } }); diff --git a/electron/main.ts b/electron/main.ts index a1dd2ff..c671b2f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,8 +4,9 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import fs from 'node:fs' import { MpvController } from './mpvController' -import { startProxyServer, cleanupCache } from './proxyServer' +import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow } from './proxyServer' import { PluginSystem } from '../src/main/pluginSystem.ts' +import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore' // @ts-ignore const require = createRequire(import.meta.url) const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -91,6 +92,55 @@ ipcMain.handle( } ) +// Cache IPC Handlers +ipcMain.handle('cache:getInfo', () => { + const settings = loadSettings(); + return { + path: getCacheDir(), + size: getCacheSize(), + persistCache: settings.persistCache + } +}) + +ipcMain.handle('cache:setPersist', (_, persist: boolean) => { + setPersistCache(persist) + saveSettings({ persistCache: persist }) +}) + +ipcMain.handle('cache:openFolder', () => { + const dir = getCacheDir() + require('electron').shell.openPath(dir) +}) + +ipcMain.handle('cache:clear', () => { + clearCacheNow() +}) + +// Settings IPC Handlers +ipcMain.handle('settings:getAll', () => { + return loadSettings() +}) + +ipcMain.handle('settings:set', (_, settings: Partial) => { + return saveSettings(settings) +}) + +ipcMain.handle('settings:getTheme', () => { + return getSetting('theme') +}) + +ipcMain.handle('settings:setTheme', (_, theme: 'dark' | 'light') => { + saveSettings({ theme }) +}) + +ipcMain.handle('settings:getAccentColor', () => { + return getSetting('accentColor') +}) + +ipcMain.handle('settings:setAccentColor', (_, color: string) => { + saveSettings({ accentColor: color }) +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() diff --git a/electron/preload.ts b/electron/preload.ts index cdea379..e32b35a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -22,5 +22,21 @@ contextBridge.exposeInMainWorld('electronAPI', { // Plugin System plugin: { call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args) + }, + + // Cache Control + getCacheInfo: () => ipcRenderer.invoke('cache:getInfo'), + setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist), + openCacheFolder: () => ipcRenderer.invoke('cache:openFolder'), + clearCache: () => ipcRenderer.invoke('cache:clear'), + + // Settings + settings: { + getAll: () => ipcRenderer.invoke('settings:getAll'), + set: (settings: any) => ipcRenderer.invoke('settings:set', settings), + getTheme: () => ipcRenderer.invoke('settings:getTheme'), + setTheme: (theme: 'dark' | 'light') => ipcRenderer.invoke('settings:setTheme', theme), + getAccentColor: () => ipcRenderer.invoke('settings:getAccentColor'), + setAccentColor: (color: string) => ipcRenderer.invoke('settings:setAccentColor', color) } }) \ No newline at end of file diff --git a/electron/proxyServer.ts b/electron/proxyServer.ts index 1fd5804..30e8b1f 100644 --- a/electron/proxyServer.ts +++ b/electron/proxyServer.ts @@ -36,14 +36,19 @@ interface CacheMetadata { createdAt: number; } -// Memory cache for resolved URLs -const urlCache = new Map(); -// Track in-progress downloads with their current size -const downloadingFiles = new Map(); + contentType: string; + abortController: AbortController | null; + promise: Promise | null; +} + +// Memory cache for resolved URLs +const urlCache = new Map(); + +// Track background download tasks +const downloadTasks = new Map(); function getCachePath(source: string, id: string, quality: string) { const dir = ensureCacheDir(); @@ -55,6 +60,10 @@ function getMetadataPath(cachePath: string): string { return cachePath + '.meta'; } +function getTempPath(cachePath: string): string { + return cachePath + '.tmp'; +} + function readMetadata(cachePath: string): CacheMetadata | null { const metaPath = getMetadataPath(cachePath); try { @@ -96,14 +105,16 @@ async function fetchFreshUrl(source: string, id: string, quality: string): Promi } function isValidCacheFile(filePath: string): boolean { - const metadata = readMetadata(filePath); - if (!metadata || !metadata.complete) return false; + try { + const metadata = readMetadata(filePath); + if (!metadata || !metadata.complete) return false; + if (!fs.existsSync(filePath)) 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; + const stat = fs.statSync(filePath); + return stat.size === metadata.totalSize && stat.size > 1024; + } catch { + return false; + } } function parseRangeHeader(range: string | undefined, fileSize: number): { start: number; end: number } | null { @@ -122,81 +133,104 @@ function parseRangeHeader(range: string | undefined, fileSize: number): { start: } 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'); + try { + 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; + } catch (e) { + console.error('[Proxy] Error serving from cache:', e); 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( +/** + * 直接代理 Range 请求,不缓存(用于 seek 到未下载的位置) + */ +async function proxyRangeDirect( req: http.IncomingMessage, res: http.ServerResponse, - targetUrl: string + targetUrl: string, + totalSize: number, + contentType: string ): Promise { - const headers: Record = {}; + const headers: Record = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }; 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) { + if (!response.ok && response.status !== 206) { 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); - }); + // Forward response headers + const responseContentType = response.headers.get('content-type') || contentType || 'audio/mpeg'; + const contentLength = response.headers.get('content-length'); + const contentRange = response.headers.get('content-range'); - res.statusCode = response.status; + if (response.status === 206 && contentRange) { + res.writeHead(206, { + 'Content-Type': responseContentType, + 'Content-Length': contentLength || '', + 'Content-Range': contentRange, + 'Accept-Ranges': 'bytes', + }); + } else { + res.writeHead(200, { + 'Content-Type': responseContentType, + 'Content-Length': contentLength || '', + 'Accept-Ranges': 'bytes', + }); + } if (!response.body) { res.end(); @@ -207,67 +241,259 @@ async function proxyWithoutCache( 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'); - } + return new Promise((resolve, reject) => { + nodeStream.on('end', resolve); + nodeStream.on('error', reject); + res.on('close', () => { + nodeStream.destroy(); + resolve(); + }); }); } +/** + * 从部分下载的缓存中读取已下载的部分 + */ +function serveFromPartialCache( + req: http.IncomingMessage, + res: http.ServerResponse, + filePath: string, + task: DownloadTask +): boolean { + try { + const tempPath = getTempPath(filePath); + if (!fs.existsSync(tempPath)) return false; + + const range = parseRangeHeader(req.headers.range, task.totalSize); + if (!range) return false; + + const { start, end } = range; + + // 检查请求的范围是否已下载 + if (end >= task.currentSize) { + return false; // 还没下载到这里 + } + + const chunksize = (end - start) + 1; + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${task.totalSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': task.contentType || 'audio/mpeg', + }); + + const file = fs.createReadStream(tempPath, { start, end }); + file.pipe(res); + + return true; + } catch (e) { + console.error('[Proxy] Error serving from partial cache:', e); + return false; + } +} + +/** + * 启动后台下载任务(不阻塞当前请求) + */ +function startBackgroundDownload( + targetUrl: string, + cacheFilePath: string, + cacheKey: string +): DownloadTask { + const existingTask = downloadTasks.get(cacheFilePath); + if (existingTask) { + return existingTask; + } + + const abortController = new AbortController(); + const task: DownloadTask = { + currentSize: 0, + totalSize: 0, + contentType: 'audio/mpeg', + abortController, + promise: null, + }; + + downloadTasks.set(cacheFilePath, task); + + task.promise = (async () => { + const tempPath = getTempPath(cacheFilePath); + + try { + // 清理旧文件 + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch { } + + const response = await fetch(targetUrl, { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }, + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const contentLength = parseInt(response.headers.get('content-length') || '0', 10); + const contentType = response.headers.get('content-type') || 'audio/mpeg'; + + if (!contentLength) { + console.warn('[Proxy] Background download: No content-length, skipping cache'); + downloadTasks.delete(cacheFilePath); + return; + } + + task.totalSize = contentLength; + task.contentType = contentType; + + console.log(`[Proxy] Background download started: ${cacheFilePath} (${contentLength} bytes)`); + + const fileStream = fs.createWriteStream(tempPath); + + if (!response.body) { + downloadTasks.delete(cacheFilePath); + return; + } + + // @ts-ignore + const nodeStream = Readable.fromWeb(response.body); + + await new Promise((resolve, reject) => { + nodeStream.on('data', (chunk: Buffer) => { + task.currentSize += chunk.length; + }); + + nodeStream.pipe(fileStream); + + fileStream.on('finish', () => { + // 验证并移动文件 + 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] Background download 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 { } + } + resolve(); + }); + + fileStream.on('error', (err) => { + console.error('[Proxy] Background download write error:', err); + try { fs.unlinkSync(tempPath); } catch { } + reject(err); + }); + + nodeStream.on('error', (err: Error) => { + console.error('[Proxy] Background download stream error:', err); + fileStream.destroy(); + try { fs.unlinkSync(tempPath); } catch { } + reject(err); + }); + }); + + } catch (e: any) { + if (e.name !== 'AbortError') { + console.error('[Proxy] Background download failed:', e); + } + try { fs.unlinkSync(tempPath); } catch { } + } finally { + downloadTasks.delete(cacheFilePath); + } + })(); + + return task; +} + +/** + * 主代理函数 - 智能处理缓存和 Range 请求 + */ async function proxyAndCache( req: http.IncomingMessage, res: http.ServerResponse, targetUrl: string, - cacheFilePath: string + cacheFilePath: string, + cacheKey: string ): Promise { const requestedRange = req.headers.range; + const isSeekRequest = requestedRange && !requestedRange.startsWith('bytes=0-'); - // 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}`); + // 检查是否有正在进行的后台下载 + let task = downloadTasks.get(cacheFilePath); - // 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`); + if (task) { + console.log(`[Proxy] Download in progress: ${task.currentSize}/${task.totalSize}`); - const { start, end } = range; - const chunksize = (end - start) + 1; + // 尝试从部分缓存中读取 + if (requestedRange && task.totalSize > 0) { + const range = parseRangeHeader(requestedRange, task.totalSize); - res.writeHead(206, { - 'Content-Range': `bytes ${start}-${end}/${downloadState.totalSize}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': chunksize, - 'Content-Type': 'audio/mpeg', - }); + if (range) { + // 检查请求的数据是否已下载 + // 给一些缓冲区间(已下载部分的 95%) + const safeEnd = Math.floor(task.currentSize * 0.95); - const file = fs.createReadStream(cacheFilePath, { start, end }); - file.pipe(res); + if (range.end < safeEnd && range.start < safeEnd) { + console.log(`[Proxy] Serving range ${range.start}-${range.end} from partial cache (downloaded: ${task.currentSize})`); + if (serveFromPartialCache(req, res, cacheFilePath, task)) { + return; + } + } + + // 请求的数据还没下载到,直接代理这个 Range + console.log(`[Proxy] Range ${range.start}-${range.end} not cached yet (downloaded: ${task.currentSize}), proxying directly`); + await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType); return; } } - // Can't serve from cache, proxy directly - await proxyWithoutCache(req, res, targetUrl); + // 无法确定如何处理,直接代理 + await proxyRangeDirect(req, res, targetUrl, task.totalSize, task.contentType); 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); + // 没有正在进行的下载任务 + + if (isSeekRequest) { + // Seek 请求:启动后台下载任务,同时直接代理当前请求 + console.log(`[Proxy] Seek request: ${requestedRange}, starting background download`); + + // 启动后台下载(不等待) + startBackgroundDownload(targetUrl, cacheFilePath, cacheKey); + + // 获取文件信息用于正确的 Range 响应 + const headResponse = await fetch(targetUrl, { + method: 'HEAD', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + const totalSize = parseInt(headResponse.headers.get('content-length') || '0', 10); + const contentType = headResponse.headers.get('content-type') || 'audio/mpeg'; + + // 直接代理当前的 Range 请求 + await proxyRangeDirect(req, res, targetUrl, totalSize, contentType); 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' }; @@ -286,8 +512,10 @@ async function proxyAndCache( if (!contentLength) { console.warn('[Proxy] No content-length, proxying without cache'); - res.setHeader('Content-Type', contentType); - res.statusCode = 200; + res.writeHead(200, { + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + }); if (response.body) { // @ts-ignore @@ -299,28 +527,29 @@ async function proxyAndCache( return; } - // Setup caching - const tempPath = cacheFilePath + '.tmp'; + // 设置下载任务 + const tempPath = getTempPath(cacheFilePath); - // 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); - } + } catch { } const fileStream = fs.createWriteStream(tempPath); - downloadingFiles.set(cacheFilePath, { + task = { currentSize: 0, totalSize: contentLength, - writeStream: fileStream - }); + contentType: contentType, + abortController: null, + promise: null, + }; + downloadTasks.set(cacheFilePath, task); - console.log(`[Proxy] Starting download: ${cacheFilePath} (${contentLength} bytes)`); + console.log(`[Proxy] Starting stream download: ${cacheFilePath} (${contentLength} bytes)`); - // Set response headers + // 发送响应头 res.writeHead(200, { 'Content-Length': contentLength, 'Content-Type': contentType, @@ -329,7 +558,7 @@ async function proxyAndCache( if (!response.body) { res.end(); - downloadingFiles.delete(cacheFilePath); + downloadTasks.delete(cacheFilePath); return; } @@ -337,26 +566,23 @@ async function proxyAndCache( 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; + if (task) { + task.currentSize += chunk.length; } }); - // 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); + downloadTasks.delete(cacheFilePath); if (success && fs.existsSync(tempPath)) { try { @@ -380,28 +606,104 @@ async function proxyAndCache( } } 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)); + nodeStream.on('error', (err: Error) => { + cleanup(false, err); + if (!res.headersSent) { + res.writeHead(502); + res.end('Proxy stream error'); + } + }); - // 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 + console.log('[Proxy] Client disconnected, download continues in background'); } }); } let persistCacheEnabled = true; +export function getCacheDir() { + return ensureCacheDir(); +} + +export function getCacheSize(): string { + const dir = ensureCacheDir(); + if (!fs.existsSync(dir)) return '0 B'; + + let totalSize = 0; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + try { + const stat = fs.statSync(filePath); + if (stat.isFile()) { + totalSize += stat.size; + } + } catch { } + } + } catch (e) { + console.error('[Proxy] Error calculating cache size:', e); + } + + if (totalSize < 1024) return `${totalSize} B`; + if (totalSize < 1024 * 1024) return `${(totalSize / 1024).toFixed(1)} KB`; + if (totalSize < 1024 * 1024 * 1024) return `${(totalSize / (1024 * 1024)).toFixed(1)} MB`; + return `${(totalSize / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +export function isPersistCacheEnabled() { + return persistCacheEnabled; +} + +export function setPersistCache(persist: boolean) { + persistCacheEnabled = persist; + console.log(`[Proxy] Cache persistence set to: ${persist}`); +} + +export function clearCacheNow() { + const dir = ensureCacheDir(); + if (fs.existsSync(dir)) { + console.log(`[Proxy] Clearing cache directory: ${dir}`); + + // 终止所有下载任务 + for (const [, task] of downloadTasks) { + if (task.abortController) { + task.abortController.abort(); + } + } + downloadTasks.clear(); + + try { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + console.log('[Proxy] Cache cleared'); + } catch (e) { + console.error('[Proxy] Failed to clear cache:', e); + } + } +} + export function cleanupCache() { if (!persistCacheEnabled && CACHE_DIR && fs.existsSync(CACHE_DIR)) { console.log(`[Proxy] Cleaning up cache directory: ${CACHE_DIR}`); + + // 终止所有下载任务 + for (const [, task] of downloadTasks) { + if (task.abortController) { + task.abortController.abort(); + } + } + downloadTasks.clear(); + try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); console.log('[Proxy] Cache cleanup complete'); @@ -411,10 +713,44 @@ export function cleanupCache() { } } +/** + * 清理过期的临时文件 + */ +function cleanupTempFiles() { + const dir = ensureCacheDir(); + try { + const files = fs.readdirSync(dir); + const now = Date.now(); + + for (const file of files) { + if (file.endsWith('.tmp')) { + const filePath = path.join(dir, file); + const cacheFilePath = filePath.replace('.tmp', ''); + + // 如果没有正在下载这个文件,且临时文件超过 1 小时,删除它 + if (!downloadTasks.has(cacheFilePath)) { + try { + const stat = fs.statSync(filePath); + if (now - stat.mtimeMs > 3600 * 1000) { + fs.unlinkSync(filePath); + console.log(`[Proxy] Cleaned up stale temp file: ${file}`); + } + } catch { } + } + } + } + } catch (e) { + console.error('[Proxy] Error cleaning up temp files:', e); + } +} + export function startProxyServer(persistCache: boolean = true) { persistCacheEnabled = persistCache; console.log(`[Proxy] Persist cache: ${persistCache}`); + // 定期清理临时文件 + setInterval(cleanupTempFiles, 30 * 60 * 1000); // 每 30 分钟 + const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); @@ -426,7 +762,6 @@ export function startProxyServer(persistCache: boolean = true) { return; } - // Handle HEAD requests for range support detection if (req.method === 'HEAD') { res.setHeader('Accept-Ranges', 'bytes'); res.statusCode = 200; @@ -455,13 +790,13 @@ export function startProxyServer(persistCache: boolean = true) { const cacheKey = `${source}:${id}:${quality}`; const cacheFilePath = getCachePath(source, id, quality); - // 1. Check complete disk cache + // 1. 检查完整缓存 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); @@ -469,7 +804,7 @@ export function startProxyServer(persistCache: boolean = true) { } catch { } } - // 2. Resolve URL + // 2. 解析 URL let playUrl: string | null = null; const memCached = urlCache.get(cacheKey); if (memCached && Date.now() < memCached.expiresAt) { @@ -487,14 +822,42 @@ export function startProxyServer(persistCache: boolean = true) { 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'); + // 3. 代理和缓存 + const maxAttempts = 3; + let currentAttempt = 0; + let lastError: any = null; + + while (currentAttempt < maxAttempts) { + try { + await proxyAndCache(req, res, playUrl, cacheFilePath, cacheKey); + break; + } catch (e: any) { + lastError = e; + currentAttempt++; + console.warn(`[Proxy] Proxy error (Attempt ${currentAttempt}/${maxAttempts}):`, e); + + if (currentAttempt < maxAttempts) { + console.log('[Proxy] Error occurred, refreshing URL from plugin...'); + + // 清除 URL 缓存 + urlCache.delete(cacheKey); + + // 获取新 URL + const newUrl = await fetchFreshUrl(source, id, quality); + if (newUrl) { + playUrl = newUrl; + continue; + } else { + console.error('[Proxy] Failed to refresh URL'); + } + } + + // 发送错误响应 + if (!res.headersSent) { + res.writeHead(lastError?.statusCode || 502); + res.end('Proxy Error'); + } + break; } } @@ -509,6 +872,7 @@ export function startProxyServer(persistCache: boolean = true) { server.listen(PORT, '127.0.0.1', () => { ensureCacheDir(); + cleanupTempFiles(); // 启动时清理 console.log(`[Proxy] Server running at http://127.0.0.1:${PORT}/music`); console.log(`[Proxy] Cache dir: ${CACHE_DIR}`); }); diff --git a/electron/settingsStore.ts b/electron/settingsStore.ts new file mode 100644 index 0000000..fe8c5cc --- /dev/null +++ b/electron/settingsStore.ts @@ -0,0 +1,65 @@ +import fs from 'fs'; +import path from 'path'; +import { app } from 'electron'; + +export interface AppSettings { + // Cache + persistCache: boolean; + + // Appearance + theme: 'dark' | 'light'; + accentColor: string; +} + +const DEFAULT_SETTINGS: AppSettings = { + persistCache: true, + theme: 'dark', + accentColor: '#ec4141', // Default red +}; + +let settingsCache: AppSettings | null = null; + +function getSettingsPath(): string { + return path.join(app.getPath('userData'), 'settings.json'); +} + +export function loadSettings(): AppSettings { + if (settingsCache) return settingsCache; + + const settingsPath = getSettingsPath(); + try { + if (fs.existsSync(settingsPath)) { + const data = fs.readFileSync(settingsPath, 'utf-8'); + settingsCache = { ...DEFAULT_SETTINGS, ...JSON.parse(data) }; + console.log('[Settings] Loaded from disk:', settingsCache); + return settingsCache!; + } + } catch (e) { + console.error('[Settings] Failed to load settings:', e); + } + + settingsCache = { ...DEFAULT_SETTINGS }; + return settingsCache; +} + +export function saveSettings(settings: Partial): AppSettings { + settingsCache = { ...loadSettings(), ...settings }; + + const settingsPath = getSettingsPath(); + try { + fs.writeFileSync(settingsPath, JSON.stringify(settingsCache, null, 2)); + console.log('[Settings] Saved to disk:', settingsCache); + } catch (e) { + console.error('[Settings] Failed to save settings:', e); + } + + return settingsCache; +} + +export function getSetting(key: K): AppSettings[K] { + return loadSettings()[key]; +} + +export function setSetting(key: K, value: AppSettings[K]): void { + saveSettings({ [key]: value }); +} diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 1929fbf..84bb681 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -1,9 +1,26 @@ diff --git a/src/renderer/components/Sidebar.vue b/src/renderer/components/Sidebar.vue index 9527613..a79d825 100644 --- a/src/renderer/components/Sidebar.vue +++ b/src/renderer/components/Sidebar.vue @@ -104,7 +104,7 @@ const togglePlaylists = () => { flex-shrink: 0; } -/* 滚动条样式 */ +/* 滚动条样式 - 默认隐藏,悬停时显示 */ .sidebar::-webkit-scrollbar { width: 6px; } @@ -114,11 +114,16 @@ const togglePlaylists = () => { } .sidebar::-webkit-scrollbar-thumb { - background: var(--color-border-light); + background: transparent; border-radius: 3px; + transition: background 0.2s ease; } -.sidebar::-webkit-scrollbar-thumb:hover { +.sidebar:hover::-webkit-scrollbar-thumb { + background: var(--color-border-light); +} + +.sidebar:hover::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); } @@ -184,7 +189,6 @@ const togglePlaylists = () => { .nav-item:hover { background-color: var(--color-bg-tertiary); color: var(--color-text-primary); - transform: translateX(2px); } .nav-item.active { @@ -193,17 +197,6 @@ const togglePlaylists = () => { font-weight: 500; } -.nav-item.active::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background-color: var(--color-accent); - border-radius: 0 2px 2px 0; -} .nav-icon { width: 20px; @@ -213,9 +206,6 @@ const togglePlaylists = () => { transition: transform var(--transition-base); } -.nav-item:hover .nav-icon { - transform: scale(1.1); -} .nav-text { font-size: var(--font-size-sm); diff --git a/src/renderer/components/TopBar.vue b/src/renderer/components/TopBar.vue index f1af550..88008c6 100644 --- a/src/renderer/components/TopBar.vue +++ b/src/renderer/components/TopBar.vue @@ -24,7 +24,7 @@
-
@@ -49,7 +49,7 @@