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:
+29
-1
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "اختر الافتراضي",
|
||||
"select_all": "تحديد الكل",
|
||||
"deselect_all": "إلغاء تحديد الكل",
|
||||
"open_folder": "فتح موقع المجلد"
|
||||
"open_folder": "فتح موقع المجلد",
|
||||
"history": "السجل"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "خيارات مخصصة",
|
||||
@@ -428,5 +429,32 @@
|
||||
"copyright_blocked": "هذا الفيديو محظور بسبب مطالبات حقوق النشر.\nصاحب المحتوى قيد الوصول إلى هذا الفيديو.",
|
||||
"extraction_failed": "فشل استخراج معلومات الفيديو. قد تكون هذه مشكلة مؤقتة.\nيرجى المحاولة مرة أخرى بعد بضع دقائق، أو تحقق من صحة رابط الفيديو.",
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Standard auswählen",
|
||||
"select_all": "Alle auswählen",
|
||||
"deselect_all": "Alle abwählen",
|
||||
"open_folder": "Ordnerspeicherort öffnen"
|
||||
"open_folder": "Ordnerspeicherort öffnen",
|
||||
"history": "Verlauf"
|
||||
},
|
||||
"dialogs": {
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@
|
||||
"run_command": "Run Command",
|
||||
"custom_command_help": "Help",
|
||||
"about": "About",
|
||||
"history": "History",
|
||||
"analyze": "Analyze",
|
||||
"paste_url": "Paste URL",
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -48,7 +48,8 @@
|
||||
"select_defaults": "Seleccionar Predeterminados",
|
||||
"select_all": "Seleccionar Todos",
|
||||
"deselect_all": "Deseleccionar Todos",
|
||||
"open_folder": "Abrir ubicación de carpeta"
|
||||
"open_folder": "Abrir ubicación de carpeta",
|
||||
"history": "Historial"
|
||||
},
|
||||
"dialogs": {
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Sélectionner par défaut",
|
||||
"select_all": "Tout 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": {
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "डिफ़ॉल्ट चुनें",
|
||||
"select_all": "सभी चुनें",
|
||||
"deselect_all": "सभी को अचयनित करें",
|
||||
"open_folder": "फ़ोल्डर स्थान खोलें"
|
||||
"open_folder": "फ़ोल्डर स्थान खोलें",
|
||||
"history": "इतिहास"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "कस्टम विकल्प",
|
||||
@@ -428,5 +429,32 @@
|
||||
"copyright_blocked": "कॉपीराइट दावों के कारण यह वीडियो अवरुद्ध है।\nसामग्री स्वामी ने इस वीडियो तक पहुंच प्रतिबंधित कर दी है।",
|
||||
"extraction_failed": "वीडियो जानकारी निकालने में विफल। यह एक अस्थायी समस्या हो सकती है।\nकृपया कुछ मिनट में पुनः प्रयास करें, या जांचें कि वीडियो लिंक सही है या नहीं।",
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Pilih default",
|
||||
"select_all": "Pilih semua",
|
||||
"deselect_all": "Batalkan pilihan semua",
|
||||
"open_folder": "Buka lokasi folder"
|
||||
"open_folder": "Buka lokasi folder",
|
||||
"history": "Riwayat"
|
||||
},
|
||||
"dialogs": {
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Seleziona predefiniti",
|
||||
"select_all": "Seleziona tutto",
|
||||
"deselect_all": "Deseleziona tutto",
|
||||
"open_folder": "Apri posizione cartella"
|
||||
"open_folder": "Apri posizione cartella",
|
||||
"history": "Cronologia"
|
||||
},
|
||||
"dialogs": {
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "デフォルトを選択",
|
||||
"select_all": "すべて選択",
|
||||
"deselect_all": "すべて解除",
|
||||
"open_folder": "フォルダの場所を開く"
|
||||
"open_folder": "フォルダの場所を開く",
|
||||
"history": "履歴"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "カスタムオプション",
|
||||
@@ -428,5 +429,32 @@
|
||||
"copyright_blocked": "著作権の申し立てにより、この動画はブロックされています。\nコンテンツ所有者がこの動画へのアクセスを制限しています。",
|
||||
"extraction_failed": "動画情報の抽出に失敗しました。これは一時的な問題である可能性があります。\n数分後にもう一度お試しいただくか、動画リンクが正しいか確認してください。",
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Wybierz domyślne",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"deselect_all": "Odznacz wszystko",
|
||||
"open_folder": "Otwórz lokalizację folderu"
|
||||
"open_folder": "Otwórz lokalizację folderu",
|
||||
"history": "Historia"
|
||||
},
|
||||
"dialogs": {
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Selecionar Padrões",
|
||||
"select_all": "Selecionar Tudo",
|
||||
"deselect_all": "Desmarcar Tudo",
|
||||
"open_folder": "Abrir local da pasta"
|
||||
"open_folder": "Abrir local da pasta",
|
||||
"history": "Histórico"
|
||||
},
|
||||
"dialogs": {
|
||||
"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.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Выбрать по умолчанию",
|
||||
"select_all": "Выбрать всё",
|
||||
"deselect_all": "Снять выделение",
|
||||
"open_folder": "Открыть расположение папки"
|
||||
"open_folder": "Открыть расположение папки",
|
||||
"history": "История"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "Пользовательские опции",
|
||||
@@ -428,5 +429,32 @@
|
||||
"copyright_blocked": "Это видео заблокировано из-за претензий по авторским правам.\nВладелец контента ограничил доступ к этому видео.",
|
||||
"extraction_failed": "Не удалось извлечь информацию о видео. Это может быть временной проблемой.\nПовторите попытку через несколько минут или проверьте правильность ссылки на видео.",
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "Varsayılanları seç",
|
||||
"select_all": "Tümünü seç",
|
||||
"deselect_all": "Tüm seçimi kaldır",
|
||||
"open_folder": "Klasör konumunu aç"
|
||||
"open_folder": "Klasör konumunu aç",
|
||||
"history": "Geçmiş"
|
||||
},
|
||||
"dialogs": {
|
||||
"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ış.",
|
||||
"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}"
|
||||
},
|
||||
"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
@@ -81,7 +81,8 @@
|
||||
"select_defaults": "选择默认值",
|
||||
"select_all": "全选",
|
||||
"deselect_all": "取消全选",
|
||||
"open_folder": "打开文件夹位置"
|
||||
"open_folder": "打开文件夹位置",
|
||||
"history": "历史"
|
||||
},
|
||||
"dialogs": {
|
||||
"custom_options": "自定义选项",
|
||||
@@ -428,5 +429,32 @@
|
||||
"copyright_blocked": "由于版权声明,此视频已被封锁。\n内容所有者已限制对此视频的访问。",
|
||||
"extraction_failed": "提取视频信息失败。这可能是一个临时问题。\n请在几分钟后重试,或检查视频链接是否正确。",
|
||||
"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": "已开始下载"
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,36 @@ class DownloadThread(QThread):
|
||||
if return_code == 0:
|
||||
self.progress_signal.emit(100)
|
||||
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
|
||||
# to ensure the embedding process has completed
|
||||
@@ -562,6 +592,13 @@ class DownloadThread(QThread):
|
||||
if "[Merger]" in line or "Merging formats" in line:
|
||||
self.status_signal.emit(_("download.merging_formats"))
|
||||
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:
|
||||
self.status_signal.emit(_("download.removing_sponsor_segments"))
|
||||
self.progress_signal.emit(97)
|
||||
|
||||
@@ -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_custom import CustomOptionsDialog, TimeRangeDialog
|
||||
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 (
|
||||
PlaylistSelectionDialog,
|
||||
SponsorBlockCategoryDialog,
|
||||
@@ -50,4 +51,7 @@ __all__ = [
|
||||
# Custom functionality dialogs
|
||||
"CustomOptionsDialog",
|
||||
"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()
|
||||
@@ -38,6 +38,7 @@ from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__
|
||||
CustomOptionsDialog,
|
||||
DownloadSettingsDialog,
|
||||
FFmpegCheckDialog,
|
||||
HistoryDialog,
|
||||
PlaylistSelectionDialog,
|
||||
TimeRangeDialog,
|
||||
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_config_manager import ConfigManager
|
||||
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
|
||||
# DownloadError and ExtractorError definitions kept for compatibility
|
||||
@@ -571,6 +573,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
|
||||
self.about_btn = QPushButton(_("buttons.about"))
|
||||
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
|
||||
self.time_range_btn = QPushButton(_("buttons.trim_video"))
|
||||
@@ -600,6 +605,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
# Add all buttons to layout in the correct order
|
||||
download_layout.addWidget(self.custom_options_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.update_ytdlp_btn)
|
||||
download_layout.addWidget(self.settings_button)
|
||||
@@ -931,6 +937,44 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
|
||||
# Show the open folder button
|
||||
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
|
||||
self.play_notification_sound()
|
||||
@@ -1325,6 +1369,37 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
def show_about_dialog(self) -> None: # ADDED METHOD HERE
|
||||
dialog = AboutDialog(self)
|
||||
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:
|
||||
"""Handle case when file already exists - simplified version"""
|
||||
|
||||
@@ -96,6 +96,8 @@ if OS_NAME == "Windows":
|
||||
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||
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_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_LOG_DIR: Path = APP_DIR / "logs"
|
||||
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_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_LOG_DIR: Path = APP_DIR / "logs"
|
||||
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_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
|
||||
@@ -167,3 +173,4 @@ else:
|
||||
APP_BIN_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_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user