From e8a35f2ba3704a6e9c01dbcfbd18f4d874892a33 Mon Sep 17 00:00:00 2001 From: Abhinav A <71514966+abhixdd@users.noreply.github.com> Date: Sun, 26 Oct 2025 00:45:33 +0530 Subject: [PATCH] Add download history feature with search and export (#336) * Add download history feature * fix: Add History to hamburger menu on all pages * feat: Improve download history security and reliability * fix: enhance file path security and simplify theme handling for history page * fix: history list spacing fixed and close button color changed * fix: code indentation fix * fix: minor codes changes for more efficiency * fix: add error catch for show-file * fix: Remove duplicate app.whenReady() for download history initialization * feat: added translation for history page --- html/compressor.html | 1 + html/history.html | 728 +++++++++++++++++++++++++++++++++++++++ html/index.html | 1 + html/playlist.html | 1 + html/playlist_new.html | 1 + main.js | 99 +++++- src/compressor.js | 7 + src/history.js | 206 +++++++++++ src/playlist.js | 6 + src/playlist_new.js | 6 + src/renderer.js | 19 + src/translate_history.js | 18 + translations/en.json | 24 +- 13 files changed, 1113 insertions(+), 4 deletions(-) create mode 100644 html/history.html create mode 100644 src/history.js create mode 100644 src/translate_history.js diff --git a/html/compressor.html b/html/compressor.html index 6ee545f..d91a351 100644 --- a/html/compressor.html +++ b/html/compressor.html @@ -20,6 +20,7 @@ Homepage Download Playlist Preferences + History About Theme: diff --git a/html/history.html b/html/history.html new file mode 100644 index 0000000..7fa6c15 --- /dev/null +++ b/html/history.html @@ -0,0 +1,728 @@ + + + + + + + + Download History - YtDownloader + + + + + + + + Download History + Close + + + + + + All Formats + MP4 + MP3 + M4A + WEBM + Opus + WAV + FLAC + + + + + + + Export as JSON + Export as CSV + Clear All History + + + + + + + + + + + diff --git a/html/index.html b/html/index.html index 2490506..7d96d41 100644 --- a/html/index.html +++ b/html/index.html @@ -65,6 +65,7 @@ Preferences Compressor + History About Theme: diff --git a/html/playlist.html b/html/playlist.html index 4ec4748..56106a1 100644 --- a/html/playlist.html +++ b/html/playlist.html @@ -49,6 +49,7 @@ Homepage Compressor Preferences + History About Theme: diff --git a/html/playlist_new.html b/html/playlist_new.html index dfe6806..1abb051 100644 --- a/html/playlist_new.html +++ b/html/playlist_new.html @@ -33,6 +33,7 @@ Homepage Preferences + History About Theme: diff --git a/main.js b/main.js index 43e0995..ff6305e 100644 --- a/main.js +++ b/main.js @@ -12,6 +12,7 @@ const {autoUpdater} = require("electron-updater"); process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true"; const fs = require("fs"); const path = require("path"); +const DownloadHistory = require("./src/history"); autoUpdater.autoDownload = false; /**@type {BrowserWindow} */ let win = null; @@ -21,6 +22,7 @@ let isQuiting = false; let indexIsOpen = true; let trayEnabled = false; const configFile = path.join(app.getPath("userData"), "config.json"); +let downloadHistory = null; function createWindow() { const bounds = JSON.parse((getItem("bounds", configFile) || "{}")); @@ -217,9 +219,14 @@ ipcMain.on("get-version", () => { secondaryWindow.webContents.send("version", version); }); -ipcMain.on("show-file", (_event, fullPath) => { - if (fullPath && fs.existsSync(fullPath)) { - shell.showItemInFolder(fullPath); +ipcMain.on("show-file", async (_event, fullPath) => { + try { + const fileExists = await fs.promises.stat(fullPath); + if (fullPath && fileExists) { + shell.showItemInFolder(fullPath); + } + } catch (error) { + console.error("File not found or error opening file:", error.message); } }); @@ -460,3 +467,89 @@ function getItem(item, configPath) { return ""; } } + +ipcMain.handle("get-download-history", async () => { + try { + if (!downloadHistory) { + downloadHistory = new DownloadHistory(); + } + return downloadHistory.getHistory(); + } catch (error) { + console.error("Error getting download history:", error); + throw new Error("Failed to retrieve download history"); + } +}); + +ipcMain.handle("add-to-history", async (event, downloadInfo) => { + try { + if (!downloadHistory) { + downloadHistory = new DownloadHistory(); + } + return downloadHistory.addDownload(downloadInfo); + } catch (error) { + console.error("Error adding to history:", error); + throw new Error("Failed to add download to history"); + } +}); + +ipcMain.handle("get-download-stats", async () => { + try { + if (!downloadHistory) { + downloadHistory = new DownloadHistory(); + } + return downloadHistory.getStats(); + } catch (error) { + console.error("Error getting download stats:", error); + throw new Error("Failed to retrieve download statistics"); + } +}); + +ipcMain.handle("delete-history-item", async (event, id) => { + try { + if (!downloadHistory) { + downloadHistory = new DownloadHistory(); + } + return downloadHistory.removeHistoryItem(id); + } catch (error) { + console.error("Error deleting history item:", error); + throw new Error("Failed to delete history item"); + } +}); + +ipcMain.handle("clear-all-history", async () => { + try { + if (!downloadHistory) { + downloadHistory = new DownloadHistory(); + } + downloadHistory.clearHistory(); + return true; + } catch (error) { + console.error("Error clearing history:", error); + throw new Error("Failed to clear download history"); + } +}); + +ipcMain.handle("export-history-json", async () => { + try { + if (!downloadHistory) { + downloadHistory = new DownloadHistory(); + } + return downloadHistory.exportAsJSON(); + } catch (error) { + console.error("Error exporting history as JSON:", error); + throw new Error("Failed to export history as JSON"); + } +}); + +ipcMain.handle("export-history-csv", async () => { + try { + if (!downloadHistory) { + downloadHistory = new DownloadHistory(); + } + return downloadHistory.exportAsCSV(); + } catch (error) { + console.error("Error exporting history as CSV:", error); + throw new Error("Failed to export history as CSV"); + } +}); + diff --git a/src/compressor.js b/src/compressor.js index 94ba12b..3ba5c61 100644 --- a/src/compressor.js +++ b/src/compressor.js @@ -633,6 +633,13 @@ getId("aboutWin").addEventListener("click", () => { menuIsOpen = false; ipcRenderer.send("load-page", __dirname + "/about.html"); }); + +getId("historyWin").addEventListener("click", () => { + closeMenu(); + menuIsOpen = false; + ipcRenderer.send("load-page", __dirname + "/history.html"); +}); + getId("homeWin").addEventListener("click", () => { closeMenu(); menuIsOpen = false; diff --git a/src/history.js b/src/history.js new file mode 100644 index 0000000..f4ac4b9 --- /dev/null +++ b/src/history.js @@ -0,0 +1,206 @@ +/** + * Download History Manager + */ + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { app } = require("electron"); + +class DownloadHistory { + constructor() { + this.historyFile = path.join(app.getPath("userData"), "download_history.json"); + this.maxHistoryItems = 800; + this.history = []; + this.initialized = this._loadHistory().then(history => { + this.history = history; + }); + } + _generateUniqueId() { + return crypto.randomUUID(); + } + + async _loadHistory() { + try { + if (fs.existsSync(this.historyFile)) { + const data = await fs.promises.readFile(this.historyFile, "utf8"); + return JSON.parse(data) || []; + } + } catch (error) { + console.error("Error loading history:", error); + } + return []; + } + + async _saveHistory() { + try { + await fs.promises.writeFile(this.historyFile, JSON.stringify(this.history, null, 2)); + } catch (error) { + console.error("Error saving history:", error); + } + } + + async addDownload(downloadInfo) { + await this.initialized; + + const historyItem = { + id: this._generateUniqueId(), + title: downloadInfo.title || "Unknown", + url: downloadInfo.url || "", + filename: downloadInfo.filename || "", + filePath: downloadInfo.filePath || "", + fileSize: downloadInfo.fileSize || 0, + format: downloadInfo.format || "unknown", + thumbnail: downloadInfo.thumbnail || "", + duration: downloadInfo.duration || 0, + downloadDate: new Date().toISOString(), + timestamp: Date.now(), + }; + + // Add to beginning for most recent first + this.history.unshift(historyItem); + + // Keep only recent items + if (this.history.length > this.maxHistoryItems) { + this.history = this.history.slice(0, this.maxHistoryItems); + } + + await this._saveHistory(); + return historyItem; + } + + async getHistory() { + await this.initialized; + return this.history; + } + + async getFilteredHistory(options = {}) { + await this.initialized; + + let filtered = [...this.history]; + + if (options.format) { + filtered = filtered.filter( + (item) => item.format.toLowerCase() === options.format.toLowerCase() + ); + } + + if (options.searchTerm) { + const term = options.searchTerm.toLowerCase(); + filtered = filtered.filter( + (item) => + item.title.toLowerCase().includes(term) || + item.url.toLowerCase().includes(term) + ); + } + + if (options.limit) { + filtered = filtered.slice(0, options.limit); + } + + return filtered; + } + + async getHistoryItem(id) { + await this.initialized; + return this.history.find((item) => item.id === id) || null; + } + + async removeHistoryItem(id) { + await this.initialized; + + const index = this.history.findIndex((item) => item.id === id); + if (index !== -1) { + this.history.splice(index, 1); + await this._saveHistory(); + return true; + } + return false; + } + + async clearHistory() { + await this.initialized; + + this.history = []; + await this._saveHistory(); + } + + async getStats() { + await this.initialized; + + const stats = { + totalDownloads: this.history.length, + totalSize: 0, + byFormat: {}, + oldestDownload: null, + newestDownload: null, + }; + + this.history.forEach((item) => { + stats.totalSize += item.fileSize || 0; + + const fmt = item.format.toLowerCase(); + stats.byFormat[fmt] = (stats.byFormat[fmt] || 0) + 1; + }); + + if (this.history.length > 0) { + stats.newestDownload = this.history[0]; + stats.oldestDownload = this.history[this.history.length - 1]; + } + + return stats; + } + + async exportAsJSON() { + await this.initialized; + return JSON.stringify(this.history, null, 2); + } + + _sanitizeCSVField(value) { + if (value == null) { + value = ""; + } + + const stringValue = String(value); + + let sanitized = stringValue.replace(/"/g, '""'); + + const dangerousChars = ['=', '+', '-', '@']; + if (sanitized.length > 0 && dangerousChars.includes(sanitized[0])) { + sanitized = "'" + sanitized; + } + + return `"${sanitized}"`; + } + + async exportAsCSV() { + await this.initialized; + + if (this.history.length === 0) return "No history to export\n"; + + const headers = [ + "Title", + "URL", + "Filename", + "Format", + "File Size (bytes)", + "Download Date", + ]; + const rows = this.history.map((item) => [ + this._sanitizeCSVField(item.title), + this._sanitizeCSVField(item.url), + this._sanitizeCSVField(item.filename), + this._sanitizeCSVField(item.format), + this._sanitizeCSVField(item.fileSize), + this._sanitizeCSVField(item.downloadDate), + ]); + + return ( + headers.join(",") + + "\n" + + rows.map((row) => row.join(",")).join("\n") + ); + } +} + +module.exports = DownloadHistory; diff --git a/src/playlist.js b/src/playlist.js index 11bdf59..d78a421 100644 --- a/src/playlist.js +++ b/src/playlist.js @@ -717,6 +717,12 @@ getId("aboutWin").addEventListener("click", () => { closeMenu(); ipcRenderer.send("load-page", __dirname + "/about.html"); }); + +getId("historyWin").addEventListener("click", () => { + closeMenu(); + ipcRenderer.send("load-page", __dirname + "/history.html"); +}); + getId("homeWin").addEventListener("click", () => { closeMenu(); ipcRenderer.send("load-win", __dirname + "/index.html"); diff --git a/src/playlist_new.js b/src/playlist_new.js index 75e5b03..96a7454 100644 --- a/src/playlist_new.js +++ b/src/playlist_new.js @@ -180,6 +180,12 @@ getId("aboutWin").addEventListener("click", () => { closeMenu(); ipcRenderer.send("load-page", __dirname + "/about.html"); }); + +getId("historyWin").addEventListener("click", () => { + closeMenu(); + ipcRenderer.send("load-page", __dirname + "/history.html"); +}); + getId("homeWin").addEventListener("click", () => { closeMenu(); ipcRenderer.send("load-win", __dirname + "/index.html"); diff --git a/src/renderer.js b/src/renderer.js index 20e8118..013acde 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -57,6 +57,7 @@ const CONSTANTS = { PREFERENCE_WIN: "preferenceWin", ABOUT_WIN: "aboutWin", PLAYLIST_WIN: "playlistWin", + HISTORY_WIN: "historyWin", COMPRESSOR_WIN: "compressorWin", }, LOCAL_STORAGE_KEYS: { @@ -448,6 +449,7 @@ class YtDownloaderApp { const menuMapping = { [CONSTANTS.DOM_IDS.PREFERENCE_WIN]: "/preferences.html", [CONSTANTS.DOM_IDS.ABOUT_WIN]: "/about.html", + [CONSTANTS.DOM_IDS.HISTORY_WIN]: "/history.html", }; const windowMapping = { [CONSTANTS.DOM_IDS.PLAYLIST_WIN]: "/playlist.html", @@ -1154,6 +1156,23 @@ class YtDownloaderApp { }).onclick = () => { shell.showItemInFolder(fullPath); }; + + // Add to download history + fs.promises.stat(fullPath) + .then(stat => { + const fileSize = stat.size || 0; + ipcRenderer.invoke("add-to-history", { + title: this.state.videoInfo.title, + url: this.state.videoInfo.url, + filename: filename, + filePath: fullPath, + fileSize: fileSize, + format: ext, + thumbnail: thumbnail, + duration: this.state.videoInfo.duration, + }).catch(err => console.error("Error adding to history:", err)); + }) + .catch(error => console.error("Error saving to history:", error)); } /** diff --git a/src/translate_history.js b/src/translate_history.js new file mode 100644 index 0000000..9d0315a --- /dev/null +++ b/src/translate_history.js @@ -0,0 +1,18 @@ +function getId(id) { + return document.getElementById(id); +} + +const i18n = new (require("../translations/i18n"))(); + +getId("historyTitle").textContent = i18n.__("Download History"); +getId("closeBtn").textContent = i18n.__("Close"); +getId("searchBox").placeholder = i18n.__("Search by title or URL..."); + +const formatOptions = document.querySelectorAll("#formatFilter option"); +if (formatOptions[0]) { + formatOptions[0].textContent = i18n.__("All Formats"); +} + +getId("exportJsonBtn").textContent = i18n.__("Export as JSON"); +getId("exportCsvBtn").textContent = i18n.__("Export as CSV"); +getId("clearAllBtn").textContent = i18n.__("Clear All History"); diff --git a/translations/en.json b/translations/en.json index ffcb334..cee8098 100644 --- a/translations/en.json +++ b/translations/en.json @@ -129,6 +129,28 @@ "Cancel": "Cancel", "Error! Click for details": "Error! Click for details", "You need to download yt-dlp from homebrew first": "You need to download yt-dlp from homebrew first", - "Open Homebrew":"Open Homebrew" + "Open Homebrew":"Open Homebrew", + "Download History": "Download History", + "Close": "Close", + "Search by title or URL...": "Search by title or URL...", + "All Formats": "All Formats", + "Export as JSON": "Export as JSON", + "Export as CSV": "Export as CSV", + "Clear All History": "Clear All History", + "No Downloads Yet": "No Downloads Yet", + "Your download history will appear here": "Your download history will appear here", + "Format": "Format", + "Size": "Size", + "Date": "Date", + "Duration": "Duration", + "Copy URL": "Copy URL", + "Open": "Open", + "Delete": "Delete", + "Total Downloads": "Total Downloads", + "Total Size": "Total Size", + "Most Common Format": "Most Common Format", + "URL copied to clipboard!": "URL copied to clipboard!", + "Are you sure you want to delete this item from history?": "Are you sure you want to delete this item from history?", + "Are you sure you want to clear all download history? This cannot be undone!": "Are you sure you want to clear all download history? This cannot be undone!" }