Add download history feature with UI and localization

Introduces a download history dialog with thumbnail previews, redownload, and remove options. Updates all language files to support history-related UI strings. Adds HistoryManager utility and integrates history dialog into the main GUI. Enhances downloader to better track final downloaded files and merged outputs.
This commit is contained in:
oop7
2025-11-03 18:27:22 +02:00
parent e862c811c2
commit df4a7ab49d
20 changed files with 1457 additions and 13 deletions
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "اختر الافتراضي", "select_defaults": "اختر الافتراضي",
"select_all": "تحديد الكل", "select_all": "تحديد الكل",
"deselect_all": "إلغاء تحديد الكل", "deselect_all": "إلغاء تحديد الكل",
"open_folder": "فتح موقع المجلد" "open_folder": "فتح موقع المجلد",
"history": "السجل"
}, },
"dialogs": { "dialogs": {
"custom_options": "خيارات مخصصة", "custom_options": "خيارات مخصصة",
@@ -428,5 +429,32 @@
"copyright_blocked": "هذا الفيديو محظور بسبب مطالبات حقوق النشر.\nصاحب المحتوى قيد الوصول إلى هذا الفيديو.", "copyright_blocked": "هذا الفيديو محظور بسبب مطالبات حقوق النشر.\nصاحب المحتوى قيد الوصول إلى هذا الفيديو.",
"extraction_failed": "فشل استخراج معلومات الفيديو. قد تكون هذه مشكلة مؤقتة.\nيرجى المحاولة مرة أخرى بعد بضع دقائق، أو تحقق من صحة رابط الفيديو.", "extraction_failed": "فشل استخراج معلومات الفيديو. قد تكون هذه مشكلة مؤقتة.\nيرجى المحاولة مرة أخرى بعد بضع دقائق، أو تحقق من صحة رابط الفيديو.",
"generic_error": "تعذر استخراج معلومات الفيديو. يرجى التحقق من الرابط الخاص بك.\nالتفاصيل التقنية: {error}" "generic_error": "تعذر استخراج معلومات الفيديو. يرجى التحقق من الرابط الخاص بك.\nالتفاصيل التقنية: {error}"
},
"history": {
"title": "سجل التنزيلات",
"clear_all": "مسح الكل",
"clear_confirm_title": "مسح السجل؟",
"clear_confirm_message": "هل أنت متأكد؟ لا يمكن التراجع عن هذا.",
"no_history": "لا يوجد سجل بعد",
"no_history_description": "ستظهر تنزيلاتك هنا",
"search_placeholder": "بحث...",
"open_location": "فتح الموقع",
"redownload": "إعادة التنزيل",
"remove": "إزالة",
"file_not_found": "الملف غير موجود",
"file_not_found_message": "تم نقل الملف أو حذفه:\n{path}",
"downloaded_on": "تم التنزيل: {date}",
"file_size": "الحجم: {size}",
"audio_download": "صوت",
"video_download": "فيديو",
"remove_confirm_title": "إزالة؟",
"remove_confirm_message": "إزالة من السجل؟\n\n{title}",
"item_removed": "تمت الإزالة",
"history_cleared": "تم مسح السجل",
"entries_count": "{count} تنزيلات",
"one_entry": "تنزيل واحد",
"redownload_confirm_title": "إعادة التنزيل؟",
"redownload_confirm_message": "إعادة التنزيل؟\n\n{title}",
"redownload_started": "بدأ التنزيل"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Standard auswählen", "select_defaults": "Standard auswählen",
"select_all": "Alle auswählen", "select_all": "Alle auswählen",
"deselect_all": "Alle abwählen", "deselect_all": "Alle abwählen",
"open_folder": "Ordnerspeicherort öffnen" "open_folder": "Ordnerspeicherort öffnen",
"history": "Verlauf"
}, },
"dialogs": { "dialogs": {
"custom_options": "Benutzerdefinierte Optionen", "custom_options": "Benutzerdefinierte Optionen",
@@ -428,5 +429,32 @@
"copyright_blocked": "Dieses Video ist aufgrund von Urheberrechtsansprüchen gesperrt.\nDer Inhaltsinhaber hat den Zugriff auf dieses Video eingeschränkt.", "copyright_blocked": "Dieses Video ist aufgrund von Urheberrechtsansprüchen gesperrt.\nDer Inhaltsinhaber hat den Zugriff auf dieses Video eingeschränkt.",
"extraction_failed": "Fehler beim Extrahieren von Videoinformationen. Dies könnte ein vorübergehendes Problem sein.\nBitte versuchen Sie es in ein paar Minuten erneut oder überprüfen Sie, ob der Videolink korrekt ist.", "extraction_failed": "Fehler beim Extrahieren von Videoinformationen. Dies könnte ein vorübergehendes Problem sein.\nBitte versuchen Sie es in ein paar Minuten erneut oder überprüfen Sie, ob der Videolink korrekt ist.",
"generic_error": "Videoinformationen konnten nicht extrahiert werden. Bitte überprüfen Sie Ihren Link.\nTechnische Details: {error}" "generic_error": "Videoinformationen konnten nicht extrahiert werden. Bitte überprüfen Sie Ihren Link.\nTechnische Details: {error}"
},
"history": {
"title": "Download-Verlauf",
"clear_all": "Alles Löschen",
"clear_confirm_title": "Verlauf Löschen?",
"clear_confirm_message": "Sind Sie sicher? Dies kann nicht rückgängig gemacht werden.",
"no_history": "Noch kein Verlauf",
"no_history_description": "Ihre Downloads werden hier angezeigt",
"search_placeholder": "Suchen...",
"open_location": "Speicherort Öffnen",
"redownload": "Erneut Laden",
"remove": "Entfernen",
"file_not_found": "Datei nicht gefunden",
"file_not_found_message": "Die Datei wurde verschoben oder gelöscht:\n{path}",
"downloaded_on": "Geladen: {date}",
"file_size": "Größe: {size}",
"audio_download": "Audio",
"video_download": "Video",
"remove_confirm_title": "Entfernen?",
"remove_confirm_message": "Aus Verlauf entfernen?\n\n{title}",
"item_removed": "Entfernt",
"history_cleared": "Verlauf gelöscht",
"entries_count": "{count} Downloads",
"one_entry": "1 Download",
"redownload_confirm_title": "Erneut Laden?",
"redownload_confirm_message": "Erneut herunterladen?\n\n{title}",
"redownload_started": "Download gestartet"
} }
} }
+28
View File
@@ -69,6 +69,7 @@
"run_command": "Run Command", "run_command": "Run Command",
"custom_command_help": "Help", "custom_command_help": "Help",
"about": "About", "about": "About",
"history": "History",
"analyze": "Analyze", "analyze": "Analyze",
"paste_url": "Paste URL", "paste_url": "Paste URL",
"select_videos": "Select Videos...", "select_videos": "Select Videos...",
@@ -428,5 +429,32 @@
"copyright_blocked": "This video is blocked due to copyright claims.\nThe content owner has restricted access to this video.", "copyright_blocked": "This video is blocked due to copyright claims.\nThe content owner has restricted access to this video.",
"extraction_failed": "Failed to extract video information. This might be a temporary issue.\nPlease try again in a few minutes, or check if the video link is correct.", "extraction_failed": "Failed to extract video information. This might be a temporary issue.\nPlease try again in a few minutes, or check if the video link is correct.",
"generic_error": "Could not extract video information. Please check your link.\nTechnical details: {error}" "generic_error": "Could not extract video information. Please check your link.\nTechnical details: {error}"
},
"history": {
"title": "Download History",
"clear_all": "Clear All History",
"clear_confirm_title": "Clear History?",
"clear_confirm_message": "Are you sure you want to clear all download history? This cannot be undone.",
"no_history": "No download history yet",
"no_history_description": "Your downloaded videos and audio will appear here",
"search_placeholder": "Search history...",
"open_location": "Open File Location",
"redownload": "Redownload",
"remove": "Remove from History",
"file_not_found": "File not found at original location",
"file_not_found_message": "The file was moved or deleted:\n{path}",
"downloaded_on": "Downloaded on {date}",
"file_size": "Size: {size}",
"audio_download": "Audio",
"video_download": "Video",
"remove_confirm_title": "Remove from History?",
"remove_confirm_message": "Remove this item from history?\n\n{title}",
"item_removed": "Item removed from history",
"history_cleared": "History cleared",
"entries_count": "{count} downloads",
"one_entry": "1 download",
"redownload_confirm_title": "Redownload Video?",
"redownload_confirm_message": "Download this video again using the same settings?\n\n{title}",
"redownload_started": "Redownload started"
} }
} }
+29 -1
View File
@@ -48,7 +48,8 @@
"select_defaults": "Seleccionar Predeterminados", "select_defaults": "Seleccionar Predeterminados",
"select_all": "Seleccionar Todos", "select_all": "Seleccionar Todos",
"deselect_all": "Deseleccionar Todos", "deselect_all": "Deseleccionar Todos",
"open_folder": "Abrir ubicación de carpeta" "open_folder": "Abrir ubicación de carpeta",
"history": "Historial"
}, },
"dialogs": { "dialogs": {
"custom_options": "Opciones Personalizadas", "custom_options": "Opciones Personalizadas",
@@ -411,5 +412,32 @@
"copyright_blocked": "Este video está bloqueado debido a reclamaciones de derechos de autor.\nEl propietario del contenido ha restringido el acceso a este video.", "copyright_blocked": "Este video está bloqueado debido a reclamaciones de derechos de autor.\nEl propietario del contenido ha restringido el acceso a este video.",
"extraction_failed": "Error al extraer información del video. Esto podría ser un problema temporal.\nIntente nuevamente en unos minutos o verifique si el enlace del video es correcto.", "extraction_failed": "Error al extraer información del video. Esto podría ser un problema temporal.\nIntente nuevamente en unos minutos o verifique si el enlace del video es correcto.",
"generic_error": "No se pudo extraer la información del video. Verifique su enlace.\nDetalles técnicos: {error}" "generic_error": "No se pudo extraer la información del video. Verifique su enlace.\nDetalles técnicos: {error}"
},
"history": {
"title": "Historial de Descargas",
"clear_all": "Borrar Todo",
"clear_confirm_title": "¿Borrar Historial?",
"clear_confirm_message": "¿Estás seguro de que quieres borrar todo el historial? Esto no se puede deshacer.",
"no_history": "Aún no hay historial",
"no_history_description": "Tus descargas aparecerán aquí",
"search_placeholder": "Buscar...",
"open_location": "Abrir Ubicación",
"redownload": "Descargar de Nuevo",
"remove": "Eliminar",
"file_not_found": "Archivo no encontrado",
"file_not_found_message": "El archivo fue movido o eliminado:\n{path}",
"downloaded_on": "Descargado: {date}",
"file_size": "Tamaño: {size}",
"audio_download": "Audio",
"video_download": "Video",
"remove_confirm_title": "¿Eliminar?",
"remove_confirm_message": "¿Eliminar del historial?\n\n{title}",
"item_removed": "Eliminado",
"history_cleared": "Historial borrado",
"entries_count": "{count} descargas",
"one_entry": "1 descarga",
"redownload_confirm_title": "¿Descargar de Nuevo?",
"redownload_confirm_message": "¿Descargar nuevamente?\n\n{title}",
"redownload_started": "Descarga iniciada"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Sélectionner par défaut", "select_defaults": "Sélectionner par défaut",
"select_all": "Tout sélectionner", "select_all": "Tout sélectionner",
"deselect_all": "Tout désélectionner", "deselect_all": "Tout désélectionner",
"open_folder": "Ouvrir l'emplacement du dossier" "open_folder": "Ouvrir l'emplacement du dossier",
"history": "Historique"
}, },
"dialogs": { "dialogs": {
"custom_options": "Options personnalisées", "custom_options": "Options personnalisées",
@@ -428,5 +429,32 @@
"copyright_blocked": "Cette vidéo est bloquée en raison de réclamations pour droits d'auteur.\nLe propriétaire du contenu a restreint l'accès à cette vidéo.", "copyright_blocked": "Cette vidéo est bloquée en raison de réclamations pour droits d'auteur.\nLe propriétaire du contenu a restreint l'accès à cette vidéo.",
"extraction_failed": "Échec de l'extraction des informations vidéo. Cela pourrait être un problème temporaire.\nVeuillez réessayer dans quelques minutes ou vérifier si le lien vidéo est correct.", "extraction_failed": "Échec de l'extraction des informations vidéo. Cela pourrait être un problème temporaire.\nVeuillez réessayer dans quelques minutes ou vérifier si le lien vidéo est correct.",
"generic_error": "Impossible d'extraire les informations vidéo. Veuillez vérifier votre lien.\nDétails techniques : {error}" "generic_error": "Impossible d'extraire les informations vidéo. Veuillez vérifier votre lien.\nDétails techniques : {error}"
},
"history": {
"title": "Historique des Téléchargements",
"clear_all": "Tout Effacer",
"clear_confirm_title": "Effacer l'Historique?",
"clear_confirm_message": "Êtes-vous sûr? Cela ne peut pas être annulé.",
"no_history": "Pas encore d'historique",
"no_history_description": "Vos téléchargements apparaîtront ici",
"search_placeholder": "Rechercher...",
"open_location": "Ouvrir l'Emplacement",
"redownload": "Télécharger à Nouveau",
"remove": "Supprimer",
"file_not_found": "Fichier non trouvé",
"file_not_found_message": "Le fichier a été déplacé ou supprimé:\n{path}",
"downloaded_on": "Téléchargé: {date}",
"file_size": "Taille: {size}",
"audio_download": "Audio",
"video_download": "Vidéo",
"remove_confirm_title": "Supprimer?",
"remove_confirm_message": "Supprimer de l'historique?\n\n{title}",
"item_removed": "Supprimé",
"history_cleared": "Historique effacé",
"entries_count": "{count} téléchargements",
"one_entry": "1 téléchargement",
"redownload_confirm_title": "Télécharger à Nouveau?",
"redownload_confirm_message": "Télécharger à nouveau?\n\n{title}",
"redownload_started": "Téléchargement démarré"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "डिफ़ॉल्ट चुनें", "select_defaults": "डिफ़ॉल्ट चुनें",
"select_all": "सभी चुनें", "select_all": "सभी चुनें",
"deselect_all": "सभी को अचयनित करें", "deselect_all": "सभी को अचयनित करें",
"open_folder": "फ़ोल्डर स्थान खोलें" "open_folder": "फ़ोल्डर स्थान खोलें",
"history": "इतिहास"
}, },
"dialogs": { "dialogs": {
"custom_options": "कस्टम विकल्प", "custom_options": "कस्टम विकल्प",
@@ -428,5 +429,32 @@
"copyright_blocked": "कॉपीराइट दावों के कारण यह वीडियो अवरुद्ध है।\nसामग्री स्वामी ने इस वीडियो तक पहुंच प्रतिबंधित कर दी है।", "copyright_blocked": "कॉपीराइट दावों के कारण यह वीडियो अवरुद्ध है।\nसामग्री स्वामी ने इस वीडियो तक पहुंच प्रतिबंधित कर दी है।",
"extraction_failed": "वीडियो जानकारी निकालने में विफल। यह एक अस्थायी समस्या हो सकती है।\nकृपया कुछ मिनट में पुनः प्रयास करें, या जांचें कि वीडियो लिंक सही है या नहीं।", "extraction_failed": "वीडियो जानकारी निकालने में विफल। यह एक अस्थायी समस्या हो सकती है।\nकृपया कुछ मिनट में पुनः प्रयास करें, या जांचें कि वीडियो लिंक सही है या नहीं।",
"generic_error": "वीडियो जानकारी निकालने में विफल। कृपया अपना लिंक जांचें।\nतकनीकी विवरण: {error}" "generic_error": "वीडियो जानकारी निकालने में विफल। कृपया अपना लिंक जांचें।\nतकनीकी विवरण: {error}"
},
"history": {
"title": "डाउनलोड इतिहास",
"clear_all": "सभी साफ़ करें",
"clear_confirm_title": "इतिहास साफ़ करें?",
"clear_confirm_message": "क्या आप निश्चित हैं? इसे पूर्ववत नहीं किया जा सकता।",
"no_history": "अभी तक कोई इतिहास नहीं",
"no_history_description": "आपके डाउनलोड यहां दिखाई देंगे",
"search_placeholder": "खोजें...",
"open_location": "स्थान खोलें",
"redownload": "फिर से डाउनलोड करें",
"remove": "हटाएं",
"file_not_found": "फ़ाइल नहीं मिली",
"file_not_found_message": "फ़ाइल स्थानांतरित या हटाई गई:\n{path}",
"downloaded_on": "डाउनलोड किया: {date}",
"file_size": "आकार: {size}",
"audio_download": "ऑडियो",
"video_download": "वीडियो",
"remove_confirm_title": "हटाएं?",
"remove_confirm_message": "इतिहास से हटाएं?\n\n{title}",
"item_removed": "हटाया गया",
"history_cleared": "इतिहास साफ़ किया गया",
"entries_count": "{count} डाउनलोड",
"one_entry": "1 डाउनलोड",
"redownload_confirm_title": "फिर से डाउनलोड करें?",
"redownload_confirm_message": "फिर से डाउनलोड करें?\n\n{title}",
"redownload_started": "डाउनलोड शुरू हुआ"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Pilih default", "select_defaults": "Pilih default",
"select_all": "Pilih semua", "select_all": "Pilih semua",
"deselect_all": "Batalkan pilihan semua", "deselect_all": "Batalkan pilihan semua",
"open_folder": "Buka lokasi folder" "open_folder": "Buka lokasi folder",
"history": "Riwayat"
}, },
"dialogs": { "dialogs": {
"custom_options": "Opsi khusus", "custom_options": "Opsi khusus",
@@ -428,5 +429,32 @@
"copyright_blocked": "Video ini diblokir karena klaim hak cipta.\nPemilik konten telah membatasi akses ke video ini.", "copyright_blocked": "Video ini diblokir karena klaim hak cipta.\nPemilik konten telah membatasi akses ke video ini.",
"extraction_failed": "Gagal mengekstrak informasi video. Ini mungkin masalah sementara.\nSilakan coba lagi dalam beberapa menit, atau periksa apakah tautan video benar.", "extraction_failed": "Gagal mengekstrak informasi video. Ini mungkin masalah sementara.\nSilakan coba lagi dalam beberapa menit, atau periksa apakah tautan video benar.",
"generic_error": "Tidak dapat mengekstrak informasi video. Silakan periksa tautan Anda.\nDetail teknis: {error}" "generic_error": "Tidak dapat mengekstrak informasi video. Silakan periksa tautan Anda.\nDetail teknis: {error}"
},
"history": {
"title": "Riwayat Unduhan",
"clear_all": "Hapus Semua",
"clear_confirm_title": "Hapus Riwayat?",
"clear_confirm_message": "Apakah Anda yakin? Ini tidak dapat dibatalkan.",
"no_history": "Belum ada riwayat",
"no_history_description": "Unduhan Anda akan muncul di sini",
"search_placeholder": "Cari...",
"open_location": "Buka Lokasi",
"redownload": "Unduh Lagi",
"remove": "Hapus",
"file_not_found": "File tidak ditemukan",
"file_not_found_message": "File dipindahkan atau dihapus:\n{path}",
"downloaded_on": "Diunduh: {date}",
"file_size": "Ukuran: {size}",
"audio_download": "Audio",
"video_download": "Video",
"remove_confirm_title": "Hapus?",
"remove_confirm_message": "Hapus dari riwayat?\n\n{title}",
"item_removed": "Dihapus",
"history_cleared": "Riwayat dihapus",
"entries_count": "{count} unduhan",
"one_entry": "1 unduhan",
"redownload_confirm_title": "Unduh Lagi?",
"redownload_confirm_message": "Unduh lagi?\n\n{title}",
"redownload_started": "Unduhan dimulai"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Seleziona predefiniti", "select_defaults": "Seleziona predefiniti",
"select_all": "Seleziona tutto", "select_all": "Seleziona tutto",
"deselect_all": "Deseleziona tutto", "deselect_all": "Deseleziona tutto",
"open_folder": "Apri posizione cartella" "open_folder": "Apri posizione cartella",
"history": "Cronologia"
}, },
"dialogs": { "dialogs": {
"custom_options": "Opzioni personalizzate", "custom_options": "Opzioni personalizzate",
@@ -428,5 +429,32 @@
"copyright_blocked": "Questo video è bloccato a causa di rivendicazioni di copyright.\nIl proprietario del contenuto ha limitato l'accesso a questo video.", "copyright_blocked": "Questo video è bloccato a causa di rivendicazioni di copyright.\nIl proprietario del contenuto ha limitato l'accesso a questo video.",
"extraction_failed": "Impossibile estrarre le informazioni del video. Questo potrebbe essere un problema temporaneo.\nRiprova tra qualche minuto o verifica se il link del video è corretto.", "extraction_failed": "Impossibile estrarre le informazioni del video. Questo potrebbe essere un problema temporaneo.\nRiprova tra qualche minuto o verifica se il link del video è corretto.",
"generic_error": "Impossibile estrarre le informazioni del video. Controlla il tuo link.\nDettagli tecnici: {error}" "generic_error": "Impossibile estrarre le informazioni del video. Controlla il tuo link.\nDettagli tecnici: {error}"
},
"history": {
"title": "Cronologia Download",
"clear_all": "Cancella Tutto",
"clear_confirm_title": "Cancellare Cronologia?",
"clear_confirm_message": "Sei sicuro? Questa azione non può essere annullata.",
"no_history": "Nessuna cronologia ancora",
"no_history_description": "I tuoi download appariranno qui",
"search_placeholder": "Cerca...",
"open_location": "Apri Posizione",
"redownload": "Scarica di Nuovo",
"remove": "Rimuovi",
"file_not_found": "File non trovato",
"file_not_found_message": "Il file è stato spostato o eliminato:\n{path}",
"downloaded_on": "Scaricato: {date}",
"file_size": "Dimensione: {size}",
"audio_download": "Audio",
"video_download": "Video",
"remove_confirm_title": "Rimuovere?",
"remove_confirm_message": "Rimuovere dalla cronologia?\n\n{title}",
"item_removed": "Rimosso",
"history_cleared": "Cronologia cancellata",
"entries_count": "{count} download",
"one_entry": "1 download",
"redownload_confirm_title": "Scaricare di Nuovo?",
"redownload_confirm_message": "Scaricare di nuovo?\n\n{title}",
"redownload_started": "Download avviato"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "デフォルトを選択", "select_defaults": "デフォルトを選択",
"select_all": "すべて選択", "select_all": "すべて選択",
"deselect_all": "すべて解除", "deselect_all": "すべて解除",
"open_folder": "フォルダの場所を開く" "open_folder": "フォルダの場所を開く",
"history": "履歴"
}, },
"dialogs": { "dialogs": {
"custom_options": "カスタムオプション", "custom_options": "カスタムオプション",
@@ -428,5 +429,32 @@
"copyright_blocked": "著作権の申し立てにより、この動画はブロックされています。\nコンテンツ所有者がこの動画へのアクセスを制限しています。", "copyright_blocked": "著作権の申し立てにより、この動画はブロックされています。\nコンテンツ所有者がこの動画へのアクセスを制限しています。",
"extraction_failed": "動画情報の抽出に失敗しました。これは一時的な問題である可能性があります。\n数分後にもう一度お試しいただくか、動画リンクが正しいか確認してください。", "extraction_failed": "動画情報の抽出に失敗しました。これは一時的な問題である可能性があります。\n数分後にもう一度お試しいただくか、動画リンクが正しいか確認してください。",
"generic_error": "動画情報を抽出できませんでした。リンクを確認してください。\n技術的詳細:{error}" "generic_error": "動画情報を抽出できませんでした。リンクを確認してください。\n技術的詳細:{error}"
},
"history": {
"title": "ダウンロード履歴",
"clear_all": "すべてクリア",
"clear_confirm_title": "履歴をクリア?",
"clear_confirm_message": "本当によろしいですか?この操作は取り消せません。",
"no_history": "まだ履歴がありません",
"no_history_description": "ダウンロードがここに表示されます",
"search_placeholder": "検索...",
"open_location": "場所を開く",
"redownload": "再ダウンロード",
"remove": "削除",
"file_not_found": "ファイルが見つかりません",
"file_not_found_message": "ファイルが移動または削除されました:\n{path}",
"downloaded_on": "ダウンロード日:{date}",
"file_size": "サイズ:{size}",
"audio_download": "オーディオ",
"video_download": "ビデオ",
"remove_confirm_title": "削除?",
"remove_confirm_message": "履歴から削除しますか?\n\n{title}",
"item_removed": "削除しました",
"history_cleared": "履歴をクリアしました",
"entries_count": "{count} 件",
"one_entry": "1 件",
"redownload_confirm_title": "再ダウンロード?",
"redownload_confirm_message": "再ダウンロードしますか?\n\n{title}",
"redownload_started": "ダウンロード開始"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Wybierz domyślne", "select_defaults": "Wybierz domyślne",
"select_all": "Zaznacz wszystko", "select_all": "Zaznacz wszystko",
"deselect_all": "Odznacz wszystko", "deselect_all": "Odznacz wszystko",
"open_folder": "Otwórz lokalizację folderu" "open_folder": "Otwórz lokalizację folderu",
"history": "Historia"
}, },
"dialogs": { "dialogs": {
"custom_options": "Opcje niestandardowe", "custom_options": "Opcje niestandardowe",
@@ -428,5 +429,32 @@
"copyright_blocked": "Ten film jest zablokowany z powodu roszczeń dotyczących praw autorskich.\nWłaściciel treści ograniczył dostęp do tego filmu.", "copyright_blocked": "Ten film jest zablokowany z powodu roszczeń dotyczących praw autorskich.\nWłaściciel treści ograniczył dostęp do tego filmu.",
"extraction_failed": "Nie udało się wyodrębnić informacji o filmie. To może być tymczasowy problem.\nSpróbuj ponownie za kilka minut lub sprawdź, czy link do filmu jest poprawny.", "extraction_failed": "Nie udało się wyodrębnić informacji o filmie. To może być tymczasowy problem.\nSpróbuj ponownie za kilka minut lub sprawdź, czy link do filmu jest poprawny.",
"generic_error": "Nie udało się wyodrębnić informacji o filmie. Sprawdź swój link.\nSzczegóły techniczne: {error}" "generic_error": "Nie udało się wyodrębnić informacji o filmie. Sprawdź swój link.\nSzczegóły techniczne: {error}"
},
"history": {
"title": "Historia Pobierania",
"clear_all": "Wyczyść Wszystko",
"clear_confirm_title": "Wyczyścić Historię?",
"clear_confirm_message": "Czy jesteś pewien? Nie można tego cofnąć.",
"no_history": "Brak historii",
"no_history_description": "Twoje pobrane pliki pojawią się tutaj",
"search_placeholder": "Szukaj...",
"open_location": "Otwórz Lokalizację",
"redownload": "Pobierz Ponownie",
"remove": "Usuń",
"file_not_found": "Nie znaleziono pliku",
"file_not_found_message": "Plik został przeniesiony lub usunięty:\n{path}",
"downloaded_on": "Pobrano: {date}",
"file_size": "Rozmiar: {size}",
"audio_download": "Audio",
"video_download": "Wideo",
"remove_confirm_title": "Usunąć?",
"remove_confirm_message": "Usunąć z historii?\n\n{title}",
"item_removed": "Usunięto",
"history_cleared": "Historia wyczyszczona",
"entries_count": "{count} pobrań",
"one_entry": "1 pobranie",
"redownload_confirm_title": "Pobrać Ponownie?",
"redownload_confirm_message": "Pobrać ponownie?\n\n{title}",
"redownload_started": "Rozpoczęto pobieranie"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Selecionar Padrões", "select_defaults": "Selecionar Padrões",
"select_all": "Selecionar Tudo", "select_all": "Selecionar Tudo",
"deselect_all": "Desmarcar Tudo", "deselect_all": "Desmarcar Tudo",
"open_folder": "Abrir local da pasta" "open_folder": "Abrir local da pasta",
"history": "Histórico"
}, },
"dialogs": { "dialogs": {
"custom_options": "Opções Personalizadas", "custom_options": "Opções Personalizadas",
@@ -428,5 +429,32 @@
"copyright_blocked": "Este vídeo está bloqueado devido a reivindicações de direitos autorais.\nO proprietário do conteúdo restringiu o acesso a este vídeo.", "copyright_blocked": "Este vídeo está bloqueado devido a reivindicações de direitos autorais.\nO proprietário do conteúdo restringiu o acesso a este vídeo.",
"extraction_failed": "Falha ao extrair informações do vídeo. Isso pode ser um problema temporário.\nTente novamente em alguns minutos ou verifique se o link do vídeo está correto.", "extraction_failed": "Falha ao extrair informações do vídeo. Isso pode ser um problema temporário.\nTente novamente em alguns minutos ou verifique se o link do vídeo está correto.",
"generic_error": "Não foi possível extrair informações do vídeo. Verifique seu link.\nDetalhes técnicos: {error}" "generic_error": "Não foi possível extrair informações do vídeo. Verifique seu link.\nDetalhes técnicos: {error}"
},
"history": {
"title": "Histórico de Downloads",
"clear_all": "Limpar Tudo",
"clear_confirm_title": "Limpar Histórico?",
"clear_confirm_message": "Tem certeza? Isto não pode ser desfeito.",
"no_history": "Nenhum histórico ainda",
"no_history_description": "Seus downloads aparecerão aqui",
"search_placeholder": "Pesquisar...",
"open_location": "Abrir Local",
"redownload": "Baixar Novamente",
"remove": "Remover",
"file_not_found": "Arquivo não encontrado",
"file_not_found_message": "O arquivo foi movido ou excluído:\n{path}",
"downloaded_on": "Baixado: {date}",
"file_size": "Tamanho: {size}",
"audio_download": "Áudio",
"video_download": "Vídeo",
"remove_confirm_title": "Remover?",
"remove_confirm_message": "Remover do histórico?\n\n{title}",
"item_removed": "Removido",
"history_cleared": "Histórico limpo",
"entries_count": "{count} downloads",
"one_entry": "1 download",
"redownload_confirm_title": "Baixar Novamente?",
"redownload_confirm_message": "Baixar novamente?\n\n{title}",
"redownload_started": "Download iniciado"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Выбрать по умолчанию", "select_defaults": "Выбрать по умолчанию",
"select_all": "Выбрать всё", "select_all": "Выбрать всё",
"deselect_all": "Снять выделение", "deselect_all": "Снять выделение",
"open_folder": "Открыть расположение папки" "open_folder": "Открыть расположение папки",
"history": "История"
}, },
"dialogs": { "dialogs": {
"custom_options": "Пользовательские опции", "custom_options": "Пользовательские опции",
@@ -428,5 +429,32 @@
"copyright_blocked": "Это видео заблокировано из-за претензий по авторским правам.\nВладелец контента ограничил доступ к этому видео.", "copyright_blocked": "Это видео заблокировано из-за претензий по авторским правам.\nВладелец контента ограничил доступ к этому видео.",
"extraction_failed": "Не удалось извлечь информацию о видео. Это может быть временной проблемой.\nПовторите попытку через несколько минут или проверьте правильность ссылки на видео.", "extraction_failed": "Не удалось извлечь информацию о видео. Это может быть временной проблемой.\nПовторите попытку через несколько минут или проверьте правильность ссылки на видео.",
"generic_error": "Не удалось извлечь информацию о видео. Проверьте вашу ссылку.\nТехнические детали: {error}" "generic_error": "Не удалось извлечь информацию о видео. Проверьте вашу ссылку.\nТехнические детали: {error}"
},
"history": {
"title": "История Загрузок",
"clear_all": "Очистить Всё",
"clear_confirm_title": "Очистить Историю?",
"clear_confirm_message": "Вы уверены? Это нельзя отменить.",
"no_history": "История пока пуста",
"no_history_description": "Загрузки появятся здесь",
"search_placeholder": "Поиск...",
"open_location": "Открыть Папку",
"redownload": "Загрузить Снова",
"remove": "Удалить",
"file_not_found": "Файл не найден",
"file_not_found_message": "Файл был перемещен или удален:\n{path}",
"downloaded_on": "Загружено: {date}",
"file_size": "Размер: {size}",
"audio_download": "Аудио",
"video_download": "Видео",
"remove_confirm_title": "Удалить?",
"remove_confirm_message": "Удалить из истории?\n\n{title}",
"item_removed": "Удалено",
"history_cleared": "История очищена",
"entries_count": "{count} загрузок",
"one_entry": "1 загрузка",
"redownload_confirm_title": "Загрузить Снова?",
"redownload_confirm_message": "Загрузить снова?\n\n{title}",
"redownload_started": "Загрузка начата"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "Varsayılanları seç", "select_defaults": "Varsayılanları seç",
"select_all": "Tümünü seç", "select_all": "Tümünü seç",
"deselect_all": "Tüm seçimi kaldır", "deselect_all": "Tüm seçimi kaldır",
"open_folder": "Klasör konumunu aç" "open_folder": "Klasör konumunu aç",
"history": "Geçmiş"
}, },
"dialogs": { "dialogs": {
"custom_options": "Özel seçenekler", "custom_options": "Özel seçenekler",
@@ -428,5 +429,32 @@
"copyright_blocked": "Bu video telif hakkı iddiaları nedeniyle engellenmiş.\nİçerik sahibi bu videoya erişimi kısıtlamış.", "copyright_blocked": "Bu video telif hakkı iddiaları nedeniyle engellenmiş.\nİçerik sahibi bu videoya erişimi kısıtlamış.",
"extraction_failed": "Video bilgileri çıkarılamadı. Bu geçici bir sorun olabilir.\nLütfen birkaç dakika içinde tekrar deneyin veya video bağlantısının doğru olup olmadığını kontrol edin.", "extraction_failed": "Video bilgileri çıkarılamadı. Bu geçici bir sorun olabilir.\nLütfen birkaç dakika içinde tekrar deneyin veya video bağlantısının doğru olup olmadığını kontrol edin.",
"generic_error": "Video bilgileri çıkarılamadı. Lütfen bağlantınızı kontrol edin.\nTeknik ayrıntılar: {error}" "generic_error": "Video bilgileri çıkarılamadı. Lütfen bağlantınızı kontrol edin.\nTeknik ayrıntılar: {error}"
},
"history": {
"title": "İndirme Geçmişi",
"clear_all": "Tümünü Temizle",
"clear_confirm_title": "Geçmişi Temizle?",
"clear_confirm_message": "Emin misiniz? Bu geri alınamaz.",
"no_history": "Henüz geçmiş yok",
"no_history_description": "İndirmeleriniz burada görünecek",
"search_placeholder": "Ara...",
"open_location": "Konumu Aç",
"redownload": "Tekrar İndir",
"remove": "Kaldır",
"file_not_found": "Dosya bulunamadı",
"file_not_found_message": "Dosya taşındı veya silindi:\n{path}",
"downloaded_on": "İndirildi: {date}",
"file_size": "Boyut: {size}",
"audio_download": "Ses",
"video_download": "Video",
"remove_confirm_title": "Kaldır?",
"remove_confirm_message": "Geçmişten kaldır?\n\n{title}",
"item_removed": "Kaldırıldı",
"history_cleared": "Geçmiş temizlendi",
"entries_count": "{count} indirme",
"one_entry": "1 indirme",
"redownload_confirm_title": "Tekrar İndir?",
"redownload_confirm_message": "Tekrar indir?\n\n{title}",
"redownload_started": "İndirme başladı"
} }
} }
+29 -1
View File
@@ -81,7 +81,8 @@
"select_defaults": "选择默认值", "select_defaults": "选择默认值",
"select_all": "全选", "select_all": "全选",
"deselect_all": "取消全选", "deselect_all": "取消全选",
"open_folder": "打开文件夹位置" "open_folder": "打开文件夹位置",
"history": "历史"
}, },
"dialogs": { "dialogs": {
"custom_options": "自定义选项", "custom_options": "自定义选项",
@@ -428,5 +429,32 @@
"copyright_blocked": "由于版权声明,此视频已被封锁。\n内容所有者已限制对此视频的访问。", "copyright_blocked": "由于版权声明,此视频已被封锁。\n内容所有者已限制对此视频的访问。",
"extraction_failed": "提取视频信息失败。这可能是一个临时问题。\n请在几分钟后重试,或检查视频链接是否正确。", "extraction_failed": "提取视频信息失败。这可能是一个临时问题。\n请在几分钟后重试,或检查视频链接是否正确。",
"generic_error": "无法提取视频信息。请检查您的链接。\n技术详情:{error}" "generic_error": "无法提取视频信息。请检查您的链接。\n技术详情:{error}"
},
"history": {
"title": "下载历史",
"clear_all": "清除全部",
"clear_confirm_title": "清除历史?",
"clear_confirm_message": "确定吗?此操作无法撤消。",
"no_history": "暂无历史",
"no_history_description": "下载将显示在这里",
"search_placeholder": "搜索...",
"open_location": "打开位置",
"redownload": "重新下载",
"remove": "删除",
"file_not_found": "文件未找到",
"file_not_found_message": "文件已移动或删除:\n{path}",
"downloaded_on": "下载于:{date}",
"file_size": "大小:{size}",
"audio_download": "音频",
"video_download": "视频",
"remove_confirm_title": "删除?",
"remove_confirm_message": "从历史中删除?\n\n{title}",
"item_removed": "已删除",
"history_cleared": "历史已清除",
"entries_count": "{count} 个下载",
"one_entry": "1 个下载",
"redownload_confirm_title": "重新下载?",
"redownload_confirm_message": "重新下载?\n\n{title}",
"redownload_started": "已开始下载"
} }
} }
+37
View File
@@ -377,6 +377,36 @@ class DownloadThread(QThread):
self.progress_signal.emit(100) self.progress_signal.emit(100)
self.status_signal.emit(_("download.completed")) self.status_signal.emit(_("download.completed"))
# Try to find the actual final file if last_file_path contains format codes or doesn't exist
if self.last_file_path:
try:
last_path = Path(self.last_file_path)
# Check if the file exists as-is
if not last_path.exists():
# The file path might have format codes or incorrect characters
# Try to find the most recently modified video/audio file in the download directory
video_audio_extensions = {'.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv',
'.m4a', '.mp3', '.opus', '.flac', '.aac', '.wav', '.ogg'}
# Get all video/audio files in the download directory
potential_files = []
for ext in video_audio_extensions:
potential_files.extend(self.path.glob(f'*{ext}'))
# Sort by modification time and get the most recent one
if potential_files:
most_recent = max(potential_files, key=lambda p: p.stat().st_mtime)
# Verify it was modified within the last 10 seconds (just downloaded)
import time
if time.time() - most_recent.stat().st_mtime < 10:
self.last_file_path = str(most_recent)
self.current_filename = most_recent.name
logger.info(f"Found downloaded file: {self.last_file_path}")
except Exception as e:
logger.error(f"Error finding final file: {e}", exc_info=True)
# Clean up subtitle files if they were merged, with a small delay # Clean up subtitle files if they were merged, with a small delay
# to ensure the embedding process has completed # to ensure the embedding process has completed
if self.merge_subs: if self.merge_subs:
@@ -562,6 +592,13 @@ class DownloadThread(QThread):
if "[Merger]" in line or "Merging formats" in line: if "[Merger]" in line or "Merging formats" in line:
self.status_signal.emit(_("download.merging_formats")) self.status_signal.emit(_("download.merging_formats"))
self.progress_signal.emit(95) self.progress_signal.emit(95)
# Extract the merged output filename
merger_match = re.search(r"Merging formats into \"(.+?)\"", line)
if merger_match:
merged_filepath = merger_match.group(1).strip()
self.current_filename = Path(merged_filepath).name
self.last_file_path = merged_filepath
logger.debug(f"Updated to merged filename: {self.current_filename}")
elif "SponsorBlock" in line: elif "SponsorBlock" in line:
self.status_signal.emit(_("download.removing_sponsor_segments")) self.status_signal.emit(_("download.removing_sponsor_segments"))
self.progress_signal.emit(97) self.progress_signal.emit(97)
+4
View File
@@ -15,6 +15,7 @@ This package contains all dialog classes organized by functionality:
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import CustomOptionsDialog, TimeRangeDialog from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import CustomOptionsDialog, TimeRangeDialog
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_history import HistoryDialog
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import ( from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import (
PlaylistSelectionDialog, PlaylistSelectionDialog,
SponsorBlockCategoryDialog, SponsorBlockCategoryDialog,
@@ -50,4 +51,7 @@ __all__ = [
# Custom functionality dialogs # Custom functionality dialogs
"CustomOptionsDialog", "CustomOptionsDialog",
"TimeRangeDialog", "TimeRangeDialog",
# History dialog
"HistoryDialog",
] ]
@@ -0,0 +1,581 @@
"""
History Dialog for YTSage application.
Displays download history with thumbnails and provides options to redownload or remove entries.
"""
import os
import subprocess
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional
import requests
from PIL import Image
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QWidget,
QFrame,
QMenu,
QMessageBox,
QSizePolicy,
)
from src.utils.ytsage_history_manager import HistoryManager
from src.utils.ytsage_constants import APP_THUMBNAILS_DIR, SUBPROCESS_CREATIONFLAGS
from src.utils.ytsage_localization import _
from src.utils.ytsage_logger import logger
if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp
class HistoryEntryWidget(QFrame):
"""Widget representing a single history entry."""
remove_requested = Signal(str) # Emit entry ID when remove is requested
redownload_requested = Signal(dict) # Emit entry data when redownload is requested
def __init__(self, entry: dict, parent=None):
super().__init__(parent)
self.entry = entry
self.entry_id = entry.get("id", "")
self.setup_ui()
def setup_ui(self):
"""Setup the UI for this history entry."""
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
self.setStyleSheet("""
QFrame {
background-color: #1d1e22;
border: 1px solid #2a2d36;
border-radius: 8px;
padding: 10px;
margin: 5px;
}
QFrame:hover {
background-color: #252830;
border-color: #3a3d46;
}
""")
main_layout = QHBoxLayout(self)
main_layout.setSpacing(15)
main_layout.setContentsMargins(10, 10, 10, 10)
# Thumbnail - Balanced size for better visibility without taking too much space
self.thumbnail_label = QLabel()
self.thumbnail_label.setFixedSize(240, 135) # 16:9 ratio, balanced size
self.thumbnail_label.setStyleSheet("""
QLabel {
border: 2px solid #3d3d3d;
border-radius: 6px;
background-color: #15181b;
}
""")
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.thumbnail_label.setScaledContents(True)
# Load thumbnail
self.load_thumbnail()
main_layout.addWidget(self.thumbnail_label, alignment=Qt.AlignmentFlag.AlignTop)
# Info section
info_layout = QVBoxLayout()
info_layout.setSpacing(5)
# Title
title = self.entry.get("title", _("video_info.unknown_title"))
self.title_label = QLabel(title)
self.title_label.setWordWrap(True)
self.title_label.setStyleSheet("""
QLabel {
font-size: 14px;
font-weight: bold;
color: #ffffff;
}
""")
info_layout.addWidget(self.title_label)
# Channel (if available)
channel = self.entry.get("channel")
if channel:
channel_label = QLabel(f"{_('video_info.channel')}: {channel}")
channel_label.setStyleSheet("color: #cccccc; font-size: 12px;")
info_layout.addWidget(channel_label)
# Download date
download_date = self.entry.get("download_date", "")
if download_date:
try:
dt = datetime.fromisoformat(download_date)
date_str = dt.strftime("%Y-%m-%d %H:%M")
date_label = QLabel(_("history.downloaded_on", date=date_str))
date_label.setStyleSheet("color: #aaaaaa; font-size: 11px;")
info_layout.addWidget(date_label)
except Exception as e:
logger.debug(f"Error parsing date: {e}")
# File size and type
file_size = self.entry.get("file_size", 0)
is_audio = self.entry.get("is_audio_only", False)
size_type_layout = QHBoxLayout()
# File type badge
type_badge = QLabel(_("history.audio_download") if is_audio else _("history.video_download"))
type_badge.setStyleSheet(f"""
QLabel {{
background-color: {'#c90000' if not is_audio else '#0066cc'};
color: white;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
}}
""")
size_type_layout.addWidget(type_badge)
# File size
if file_size > 0:
size_str = self.format_file_size(file_size)
size_label = QLabel(_("history.file_size", size=size_str))
size_label.setStyleSheet("color: #aaaaaa; font-size: 11px;")
size_type_layout.addWidget(size_label)
size_type_layout.addStretch()
info_layout.addLayout(size_type_layout)
info_layout.addStretch()
main_layout.addLayout(info_layout, 1)
# Three-dot menu button
self.menu_button = QPushButton("")
self.menu_button.setFixedSize(40, 40)
self.menu_button.setStyleSheet("""
QPushButton {
background-color: #2a2d36;
border: none;
border-radius: 20px;
color: white;
font-size: 24px;
font-weight: bold;
}
QPushButton:hover {
background-color: #3a3d46;
}
QPushButton:pressed {
background-color: #c90000;
}
""")
self.menu_button.clicked.connect(self.show_menu)
main_layout.addWidget(self.menu_button, alignment=Qt.AlignmentFlag.AlignTop)
def load_thumbnail(self):
"""Load and display the thumbnail."""
thumbnail_url = self.entry.get("thumbnail_url")
if not thumbnail_url:
self.set_placeholder_thumbnail()
return
# Check if thumbnail is cached
thumbnail_filename = f"{self.entry_id}.jpg"
thumbnail_path = APP_THUMBNAILS_DIR / thumbnail_filename
if thumbnail_path.exists():
try:
pixmap = QPixmap(str(thumbnail_path))
if not pixmap.isNull():
# Don't scale here, let setScaledContents handle it
self.thumbnail_label.setPixmap(pixmap)
return
except Exception as e:
logger.debug(f"Error loading cached thumbnail: {e}")
# Download thumbnail
try:
response = requests.get(thumbnail_url, timeout=5)
response.raise_for_status()
image = Image.open(BytesIO(response.content))
# Don't resize, keep original quality and just save at higher quality
# The QPixmap scaling will handle the display size with high quality
# Save to cache with higher quality
try:
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
image.save(thumbnail_path, "JPEG", quality=95, optimize=True)
except Exception as e:
logger.debug(f"Error caching thumbnail: {e}")
# Convert to QPixmap
image_bytes = BytesIO()
image.save(image_bytes, format="JPEG", quality=95)
image_bytes.seek(0)
pixmap = QPixmap()
pixmap.loadFromData(image_bytes.read())
if not pixmap.isNull():
# Don't scale here, let setScaledContents handle it
self.thumbnail_label.setPixmap(pixmap)
else:
self.set_placeholder_thumbnail()
except Exception as e:
logger.debug(f"Error downloading thumbnail: {e}")
self.set_placeholder_thumbnail()
def set_placeholder_thumbnail(self):
"""Set a placeholder when thumbnail is not available."""
self.thumbnail_label.setText("📹" if not self.entry.get("is_audio_only") else "🎵")
self.thumbnail_label.setStyleSheet("""
QLabel {
border: 1px solid #3d3d3d;
border-radius: 4px;
background-color: #15181b;
color: #666666;
font-size: 48px;
}
""")
def format_file_size(self, size_bytes: int) -> str:
"""Format file size in human-readable format."""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
def show_menu(self):
"""Show the context menu with options."""
menu = QMenu(self)
menu.setStyleSheet("""
QMenu {
background-color: #2a2d36;
border: 1px solid #3a3d46;
color: white;
padding: 5px;
}
QMenu::item {
padding: 8px 20px;
border-radius: 4px;
}
QMenu::item:selected {
background-color: #c90000;
}
""")
# Open file location
open_action = menu.addAction("📁 " + _("history.open_location"))
open_action.triggered.connect(self.open_file_location)
# Redownload
redownload_action = menu.addAction("⬇️ " + _("history.redownload"))
redownload_action.triggered.connect(self.redownload)
menu.addSeparator()
# Remove from history
remove_action = menu.addAction("🗑️ " + _("history.remove"))
remove_action.triggered.connect(self.remove_from_history)
# Show menu at button position
menu.exec(self.menu_button.mapToGlobal(self.menu_button.rect().bottomLeft()))
def open_file_location(self):
"""Open the file location in the system file explorer."""
file_path = Path(self.entry.get("file_path", ""))
if not file_path.exists():
QMessageBox.warning(
self,
_("history.file_not_found"),
_("history.file_not_found_message", path=str(file_path))
)
return
try:
# On Windows, use explorer with /select to highlight the file
if os.name == "nt":
subprocess.run(['explorer', '/select,', str(file_path)], creationflags=SUBPROCESS_CREATIONFLAGS)
# On macOS, use open with -R to reveal in Finder
elif subprocess.sys.platform == "darwin":
subprocess.run(['open', '-R', str(file_path)])
# On Linux, try to open the folder
else:
folder_path = file_path.parent
subprocess.run(['xdg-open', str(folder_path)])
logger.info(f"Opened file location: {file_path}")
except Exception as e:
logger.exception(f"Error opening file location: {e}")
QMessageBox.warning(self, "Error", f"Could not open file location: {str(e)}")
def redownload(self):
"""Request redownload of this entry."""
reply = QMessageBox.question(
self,
_("history.redownload_confirm_title"),
_("history.redownload_confirm_message", title=self.entry.get("title", "")),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.redownload_requested.emit(self.entry)
def remove_from_history(self):
"""Request removal of this entry from history."""
reply = QMessageBox.question(
self,
_("history.remove_confirm_title"),
_("history.remove_confirm_message", title=self.entry.get("title", "")),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.remove_requested.emit(self.entry_id)
class HistoryDialog(QDialog):
"""Dialog to display and manage download history."""
redownload_requested = Signal(dict) # Signal to request redownload in main window
def __init__(self, parent: Optional["YTSageApp"] = None):
super().__init__(parent)
self.parent_app = parent
self.entry_widgets = []
self.setup_ui()
self.load_history()
def setup_ui(self):
"""Setup the dialog UI."""
self.setWindowTitle(_("history.title"))
self.setMinimumSize(700, 500)
self.resize(850, 600)
# Set window flags
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
layout = QVBoxLayout(self)
layout.setSpacing(10)
layout.setContentsMargins(20, 20, 20, 20)
# Header with title and buttons
header_layout = QHBoxLayout()
title_label = QLabel(_("history.title"))
title_label.setStyleSheet("""
QLabel {
font-size: 18px;
font-weight: bold;
color: white;
}
""")
header_layout.addWidget(title_label)
header_layout.addStretch()
# Clear all button
self.clear_all_btn = QPushButton(_("history.clear_all"))
self.clear_all_btn.setStyleSheet("""
QPushButton {
background-color: #c90000;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #a50000;
}
QPushButton:pressed {
background-color: #800000;
}
""")
self.clear_all_btn.clicked.connect(self.clear_all_history)
header_layout.addWidget(self.clear_all_btn)
layout.addLayout(header_layout)
# Search bar
self.search_input = QLineEdit()
self.search_input.setPlaceholderText(_("history.search_placeholder"))
self.search_input.setStyleSheet("""
QLineEdit {
padding: 10px;
border: 2px solid #1b2021;
border-radius: 4px;
background-color: #1b2021;
color: #ffffff;
font-size: 13px;
}
""")
self.search_input.textChanged.connect(self.filter_history)
layout.addWidget(self.search_input)
# Scroll area for history entries
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setStyleSheet("""
QScrollArea {
border: none;
background-color: transparent;
}
""")
# Container for history entries
self.history_container = QWidget()
self.history_layout = QVBoxLayout(self.history_container)
self.history_layout.setSpacing(10)
self.history_layout.setContentsMargins(0, 0, 0, 0)
self.history_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll_area.setWidget(self.history_container)
layout.addWidget(scroll_area)
# Status label
self.status_label = QLabel()
self.status_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
layout.addWidget(self.status_label)
# Apply dark theme
self.setStyleSheet("""
QDialog {
background-color: #15181b;
}
QLabel {
color: #ffffff;
}
""")
def load_history(self):
"""Load and display history entries."""
# Clear existing widgets
for widget in self.entry_widgets:
widget.deleteLater()
self.entry_widgets.clear()
# Get history entries
entries = HistoryManager.get_all_entries()
if not entries:
self.show_empty_state()
return
# Create widgets for each entry
for entry in entries:
widget = HistoryEntryWidget(entry, self.history_container)
widget.remove_requested.connect(self.remove_entry)
widget.redownload_requested.connect(self.handle_redownload)
self.history_layout.addWidget(widget)
self.entry_widgets.append(widget)
# Update status
count = len(entries)
if count == 1:
status_text = _("history.one_entry")
else:
status_text = _("history.entries_count", count=count)
self.status_label.setText(status_text)
def show_empty_state(self):
"""Show empty state when there's no history."""
empty_widget = QWidget()
empty_layout = QVBoxLayout(empty_widget)
empty_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon_label = QLabel("📂")
icon_label.setStyleSheet("font-size: 64px; color: #555555;")
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
empty_layout.addWidget(icon_label)
title_label = QLabel(_("history.no_history"))
title_label.setStyleSheet("font-size: 16px; color: #888888; font-weight: bold;")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
empty_layout.addWidget(title_label)
desc_label = QLabel(_("history.no_history_description"))
desc_label.setStyleSheet("font-size: 13px; color: #666666;")
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
empty_layout.addWidget(desc_label)
self.history_layout.addWidget(empty_widget)
self.entry_widgets.append(empty_widget)
self.status_label.setText("")
self.clear_all_btn.setEnabled(False)
def filter_history(self, query: str):
"""Filter history entries based on search query."""
if not query:
# Show all entries
for widget in self.entry_widgets:
widget.show()
return
# Hide/show based on query
query_lower = query.lower()
visible_count = 0
for widget in self.entry_widgets:
if isinstance(widget, HistoryEntryWidget):
title = (widget.entry.get("title") or "").lower()
channel = (widget.entry.get("channel") or "").lower()
if query_lower in title or query_lower in channel:
widget.show()
visible_count += 1
else:
widget.hide()
def remove_entry(self, entry_id: str):
"""Remove an entry from history."""
success = HistoryManager.remove_entry(entry_id)
if success:
# Reload history
self.load_history()
logger.info(f"Removed entry from history: {entry_id}")
else:
QMessageBox.warning(self, "Error", "Failed to remove entry from history")
def clear_all_history(self):
"""Clear all history entries."""
reply = QMessageBox.question(
self,
_("history.clear_confirm_title"),
_("history.clear_confirm_message"),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
count = HistoryManager.clear_history()
self.load_history()
logger.info(f"Cleared all history: {count} entries")
def handle_redownload(self, entry: dict):
"""Handle redownload request."""
# Emit signal to parent window
self.redownload_requested.emit(entry)
# Close dialog
self.accept()
+75
View File
@@ -38,6 +38,7 @@ from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__
CustomOptionsDialog, CustomOptionsDialog,
DownloadSettingsDialog, DownloadSettingsDialog,
FFmpegCheckDialog, FFmpegCheckDialog,
HistoryDialog,
PlaylistSelectionDialog, PlaylistSelectionDialog,
TimeRangeDialog, TimeRangeDialog,
YTDLPUpdateDialog, YTDLPUpdateDialog,
@@ -48,6 +49,7 @@ from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIO
from src.utils.ytsage_logger import logger from src.utils.ytsage_logger import logger
from src.utils.ytsage_config_manager import ConfigManager from src.utils.ytsage_config_manager import ConfigManager
from src.utils.ytsage_localization import LocalizationManager, _ from src.utils.ytsage_localization import LocalizationManager, _
from src.utils.ytsage_history_manager import HistoryManager
# Note: yt-dlp Python package removed - using binary-only approach # Note: yt-dlp Python package removed - using binary-only approach
# DownloadError and ExtractorError definitions kept for compatibility # DownloadError and ExtractorError definitions kept for compatibility
@@ -572,6 +574,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.about_btn = QPushButton(_("buttons.about")) self.about_btn = QPushButton(_("buttons.about"))
self.about_btn.clicked.connect(self.show_about_dialog) self.about_btn.clicked.connect(self.show_about_dialog)
self.history_btn = QPushButton(_("buttons.history"))
self.history_btn.clicked.connect(self.show_history_dialog)
# Add new Time Range button # Add new Time Range button
self.time_range_btn = QPushButton(_("buttons.trim_video")) self.time_range_btn = QPushButton(_("buttons.trim_video"))
self.time_range_btn.clicked.connect(self.show_time_range_dialog) self.time_range_btn.clicked.connect(self.show_time_range_dialog)
@@ -600,6 +605,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Add all buttons to layout in the correct order # Add all buttons to layout in the correct order
download_layout.addWidget(self.custom_options_btn) download_layout.addWidget(self.custom_options_btn)
download_layout.addWidget(self.about_btn) download_layout.addWidget(self.about_btn)
download_layout.addWidget(self.history_btn)
download_layout.addWidget(self.time_range_btn) # New button position download_layout.addWidget(self.time_range_btn) # New button position
download_layout.addWidget(self.update_ytdlp_btn) download_layout.addWidget(self.update_ytdlp_btn)
download_layout.addWidget(self.settings_button) download_layout.addWidget(self.settings_button)
@@ -932,6 +938,44 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Show the open folder button # Show the open folder button
self.open_folder_btn.setVisible(True) self.open_folder_btn.setVisible(True)
# Save to history
try:
if self.download_thread.last_file_path and self.video_info:
# Get video information
title = self.video_info.get("title", _("video_info.unknown_title"))
channel = self.video_info.get("channel", None) or self.video_info.get("uploader", None)
duration = self.video_info.get("duration_string", None)
# Prepare download options
download_options = {
"format_id": self.download_thread.format_id,
"subtitle_langs": self.download_thread.subtitle_langs,
"merge_subs": self.download_thread.merge_subs,
"enable_sponsorblock": self.download_thread.enable_sponsorblock,
"sponsorblock_categories": self.download_thread.sponsorblock_categories,
"save_description": self.download_thread.save_description,
"embed_chapters": self.download_thread.embed_chapters,
"download_section": self.download_thread.download_section,
"force_keyframes": self.download_thread.force_keyframes,
}
# Add to history
HistoryManager.add_entry(
title=title,
url=self.video_url,
thumbnail_url=self.thumbnail_url,
file_path=str(self.download_thread.last_file_path),
format_id=self.download_thread.format_id,
is_audio_only=self.download_thread.is_audio_only,
resolution=self.download_thread.resolution,
channel=channel,
duration=duration,
download_options=download_options,
)
logger.info(f"Added download to history: {title}")
except Exception as e:
logger.error(f"Error saving to history: {e}", exc_info=True)
# Play notification sound when download completes # Play notification sound when download completes
self.play_notification_sound() self.play_notification_sound()
@@ -1326,6 +1370,37 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
dialog = AboutDialog(self) dialog = AboutDialog(self)
dialog.exec() dialog.exec()
def show_history_dialog(self) -> None:
"""Show the download history dialog."""
dialog = HistoryDialog(self)
dialog.redownload_requested.connect(self.handle_redownload_from_history)
dialog.exec()
def handle_redownload_from_history(self, entry: dict) -> None:
"""Handle redownload request from history dialog."""
try:
# Set URL
url = entry.get("url", "")
if url:
self.url_input.setText(url)
self.video_url = url
# Analyze the URL to get format info
logger.info(f"Redownloading from history: {entry.get('title', 'Unknown')}")
QMessageBox.information(
self,
_("history.redownload_started"),
_("history.redownload_started") + f"\n\n{entry.get('title', '')}"
)
# Trigger analysis
self.analyze_url()
else:
QMessageBox.warning(self, "Error", "No URL found in history entry")
except Exception as e:
logger.error(f"Error handling redownload from history: {e}", exc_info=True)
QMessageBox.warning(self, "Error", f"Failed to start redownload: {str(e)}")
def file_already_exists(self, filename) -> None: def file_already_exists(self, filename) -> None:
"""Handle case when file already exists - simplified version""" """Handle case when file already exists - simplified version"""
self.toggle_download_controls(True) self.toggle_download_controls(True)
+7
View File
@@ -96,6 +96,8 @@ if OS_NAME == "Windows":
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" APP_LOG_DIR: Path = APP_DIR / "logs"
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json" APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe" YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe"
@@ -113,6 +115,8 @@ elif OS_NAME == "Darwin": # macOS
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" APP_LOG_DIR: Path = APP_DIR / "logs"
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json" APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp" YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
@@ -130,6 +134,8 @@ else: # Linux and other UNIX-like
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" APP_LOG_DIR: Path = APP_DIR / "logs"
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json" APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp" YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
@@ -167,3 +173,4 @@ else:
APP_BIN_DIR.mkdir(parents=True, exist_ok=True) APP_BIN_DIR.mkdir(parents=True, exist_ok=True)
APP_DATA_DIR.mkdir(parents=True, exist_ok=True) APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
APP_LOG_DIR.mkdir(parents=True, exist_ok=True) APP_LOG_DIR.mkdir(parents=True, exist_ok=True)
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
+348
View File
@@ -0,0 +1,348 @@
"""
History Manager Module
======================
This module provides **thread-safe** centralized management for download
history in YTSage. It handles reading, writing, and managing download history
stored in a JSON file.
Thread safety is ensured using a reentrant lock (`RLock`), so multiple threads
can safely access or modify history concurrently.
Features
--------
- Thread-safe operations for getting, adding, and removing history entries.
- Loads history from a JSON file (`APP_HISTORY_FILE`).
- Creates the history file if missing or corrupt.
- Manages download history with metadata including thumbnails, file paths, and download options.
- Provides safe error handling with logging instead of raising exceptions.
- Persists updates back to disk automatically.
Usage
-----
from src.utils.ytsage_history_manager import HistoryManager
# Add a download to history
HistoryManager.add_entry(
title="Video Title",
url="https://youtube.com/watch?v=...",
thumbnail_url="https://...",
file_path="/path/to/file.mp4",
format_id="137+140",
is_audio_only=False,
resolution="1080p",
download_options={...}
)
# Get all history entries
history = HistoryManager.get_all_entries()
# Remove an entry
HistoryManager.remove_entry(entry_id)
# Clear all history
HistoryManager.clear_history()
Design Notes
------------
- History entries are stored in `HistoryManager._history` (a list of dicts).
- Each entry has a unique ID based on timestamp.
- All modifications trigger a save (`_save`) to keep JSON in sync.
- Logs actions and errors using the app's central logger.
- Uses `RLock` to allow safe concurrent access from multiple threads.
Exceptions
----------
- Any issues during file I/O (permissions, disk errors, JSON corruption)
are caught and logged. The application continues running with an empty
history when possible.
"""
import json
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from src.utils.ytsage_constants import APP_HISTORY_FILE
from src.utils.ytsage_logger import logger
class HistoryManager:
"""
Thread-safe history manager for YTSage.
Provides methods to load, save, get, add, and remove download history entries.
Automatically persists changes to disk.
"""
_lock = threading.RLock()
_history_file = APP_HISTORY_FILE
_history: List[Dict[str, Any]] = []
_loaded = False
@classmethod
def _load(cls) -> None:
"""
Loads download history from a JSON file if it exists and is valid.
If the file is missing or corrupt, initializes with an empty history.
Logs actions and errors during the process.
"""
with cls._lock:
if cls._history_file.exists():
try:
with open(cls._history_file, "r", encoding="utf-8") as f:
data = json.load(f)
# Ensure it's a list
if isinstance(data, list):
cls._history = data
else:
cls._history = []
logger.warning("History file format invalid, initialized empty history.")
logger.info(f"History loaded from file: {len(cls._history)} entries.")
except json.JSONDecodeError:
cls._history = []
logger.warning("History file corrupt, initialized empty history.")
except Exception as e:
cls._history = []
logger.error(f"Error loading history file: {e}")
else:
cls._history = []
cls._save()
logger.info("History file not found, created empty history.")
cls._loaded = True
@classmethod
def _save(cls) -> None:
"""
Save current history to JSON file.
Note:
May raise exceptions if the file cannot be written due to permission issues,
disk errors, or other I/O problems.
"""
with cls._lock:
try:
# Ensure parent directory exists
cls._history_file.parent.mkdir(parents=True, exist_ok=True)
with open(cls._history_file, "w", encoding="utf-8") as f:
json.dump(cls._history, f, indent=2, ensure_ascii=False)
logger.debug(f"History saved to file: {len(cls._history)} entries.")
except (OSError, PermissionError) as e:
logger.exception(f"Failed to save history: {e}")
except Exception as e:
logger.exception(f"Unexpected error while saving history: {e}")
@classmethod
def _ensure_loaded(cls) -> None:
"""Ensure history is loaded before any operation."""
with cls._lock:
if not cls._loaded:
cls._load()
@classmethod
def add_entry(
cls,
title: str,
url: str,
thumbnail_url: Optional[str],
file_path: str,
format_id: str,
is_audio_only: bool,
resolution: str,
file_size: Optional[int] = None,
channel: Optional[str] = None,
duration: Optional[str] = None,
download_options: Optional[Dict[str, Any]] = None,
) -> str:
"""
Add a new download entry to history.
Args:
title: Video/audio title
url: Original URL
thumbnail_url: Thumbnail URL (can be None)
file_path: Path to downloaded file
format_id: Format ID used for download
is_audio_only: Whether it's audio-only download
resolution: Resolution string (e.g., "1080p", "best audio")
file_size: File size in bytes (optional)
channel: Channel name (optional)
duration: Duration string (optional)
download_options: Dictionary of all download options used (optional)
Returns:
str: The unique ID of the created entry
"""
cls._ensure_loaded()
with cls._lock:
# Generate unique ID based on timestamp
entry_id = f"{int(time.time() * 1000)}"
# Get file size if not provided
if file_size is None:
try:
file_path_obj = Path(file_path)
if file_path_obj.exists():
file_size = file_path_obj.stat().st_size
except Exception as e:
logger.debug(f"Could not get file size: {e}")
file_size = 0
entry = {
"id": entry_id,
"title": title,
"url": url,
"thumbnail_url": thumbnail_url,
"file_path": file_path,
"download_date": datetime.now().isoformat(),
"format_id": format_id,
"is_audio_only": is_audio_only,
"resolution": resolution,
"file_size": file_size or 0,
"channel": channel,
"duration": duration,
"download_options": download_options or {},
}
# Add to beginning of list (most recent first)
cls._history.insert(0, entry)
cls._save()
logger.info(f"Added entry to history: {title}")
return entry_id
@classmethod
def get_all_entries(cls, limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""
Retrieve all history entries.
Args:
limit: Optional limit on number of entries to return (most recent first)
Returns:
List of history entry dictionaries
"""
cls._ensure_loaded()
with cls._lock:
if limit is not None:
return cls._history[:limit]
return cls._history.copy()
@classmethod
def get_entry(cls, entry_id: str) -> Optional[Dict[str, Any]]:
"""
Get a specific history entry by ID.
Args:
entry_id: The unique entry ID
Returns:
Entry dictionary or None if not found
"""
cls._ensure_loaded()
with cls._lock:
for entry in cls._history:
if entry.get("id") == entry_id:
return entry.copy()
return None
@classmethod
def remove_entry(cls, entry_id: str) -> bool:
"""
Remove a specific entry from history.
Args:
entry_id: The unique entry ID to remove
Returns:
bool: True if entry was found and removed, False otherwise
"""
cls._ensure_loaded()
with cls._lock:
for i, entry in enumerate(cls._history):
if entry.get("id") == entry_id:
removed = cls._history.pop(i)
cls._save()
logger.info(f"Removed entry from history: {removed.get('title', 'Unknown')}")
return True
logger.debug(f"Entry ID '{entry_id}' not found in history.")
return False
@classmethod
def clear_history(cls) -> int:
"""
Clear all history entries.
Returns:
int: Number of entries that were cleared
"""
cls._ensure_loaded()
with cls._lock:
count = len(cls._history)
cls._history = []
cls._save()
logger.info(f"Cleared all history: {count} entries removed.")
return count
@classmethod
def search_entries(cls, query: str) -> List[Dict[str, Any]]:
"""
Search history entries by title, channel, or URL.
Args:
query: Search query string
Returns:
List of matching history entries
"""
cls._ensure_loaded()
if not query:
return cls.get_all_entries()
query_lower = query.lower()
with cls._lock:
results = []
for entry in cls._history:
# Search in title, channel, and URL
title = (entry.get("title") or "").lower()
channel = (entry.get("channel") or "").lower()
url = (entry.get("url") or "").lower()
if query_lower in title or query_lower in channel or query_lower in url:
results.append(entry.copy())
return results
@classmethod
def get_statistics(cls) -> Dict[str, Any]:
"""
Get statistics about download history.
Returns:
Dictionary with statistics (total_downloads, total_size, etc.)
"""
cls._ensure_loaded()
with cls._lock:
total_downloads = len(cls._history)
total_size = sum(entry.get("file_size", 0) for entry in cls._history)
video_count = sum(1 for entry in cls._history if not entry.get("is_audio_only", False))
audio_count = sum(1 for entry in cls._history if entry.get("is_audio_only", False))
return {
"total_downloads": total_downloads,
"total_size": total_size,
"video_count": video_count,
"audio_count": audio_count,
}