diff --git a/languages/ar.json b/languages/ar.json index 800fff5..bf64349 100644 --- a/languages/ar.json +++ b/languages/ar.json @@ -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": "بدأ التنزيل" } } \ No newline at end of file diff --git a/languages/de.json b/languages/de.json index 786582f..5d741f4 100644 --- a/languages/de.json +++ b/languages/de.json @@ -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" } } \ No newline at end of file diff --git a/languages/en.json b/languages/en.json index 4d57556..d29ae7f 100644 --- a/languages/en.json +++ b/languages/en.json @@ -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" } } \ No newline at end of file diff --git a/languages/es.json b/languages/es.json index 86e1c3c..df0ed9d 100644 --- a/languages/es.json +++ b/languages/es.json @@ -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" } } \ No newline at end of file diff --git a/languages/fr.json b/languages/fr.json index 9305a57..81f2d69 100644 --- a/languages/fr.json +++ b/languages/fr.json @@ -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é" } } \ No newline at end of file diff --git a/languages/hi.json b/languages/hi.json index 5a3c19b..1dd4a9b 100644 --- a/languages/hi.json +++ b/languages/hi.json @@ -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": "डाउनलोड शुरू हुआ" } } \ No newline at end of file diff --git a/languages/id.json b/languages/id.json index 7789260..61b60da 100644 --- a/languages/id.json +++ b/languages/id.json @@ -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" } } \ No newline at end of file diff --git a/languages/it.json b/languages/it.json index 7908db7..a52f1db 100644 --- a/languages/it.json +++ b/languages/it.json @@ -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" } } \ No newline at end of file diff --git a/languages/ja.json b/languages/ja.json index b7e6baa..932084b 100644 --- a/languages/ja.json +++ b/languages/ja.json @@ -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": "ダウンロード開始" } } \ No newline at end of file diff --git a/languages/pl.json b/languages/pl.json index c21b530..96a6812 100644 --- a/languages/pl.json +++ b/languages/pl.json @@ -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" } } \ No newline at end of file diff --git a/languages/pt.json b/languages/pt.json index 20ce1b4..6dad73d 100644 --- a/languages/pt.json +++ b/languages/pt.json @@ -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" } } \ No newline at end of file diff --git a/languages/ru.json b/languages/ru.json index 6767d6a..d80459a 100644 --- a/languages/ru.json +++ b/languages/ru.json @@ -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": "Загрузка начата" } } \ No newline at end of file diff --git a/languages/tr.json b/languages/tr.json index 8d71c94..188ad55 100644 --- a/languages/tr.json +++ b/languages/tr.json @@ -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ı" } } \ No newline at end of file diff --git a/languages/zh.json b/languages/zh.json index 4928f61..ab38078 100644 --- a/languages/zh.json +++ b/languages/zh.json @@ -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": "已开始下载" } } \ No newline at end of file diff --git a/src/core/ytsage_downloader.py b/src/core/ytsage_downloader.py index 7d92c32..18dae05 100644 --- a/src/core/ytsage_downloader.py +++ b/src/core/ytsage_downloader.py @@ -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) diff --git a/src/gui/ytsage_gui_dialogs/__init__.py b/src/gui/ytsage_gui_dialogs/__init__.py index fe233f3..f33c15b 100644 --- a/src/gui/ytsage_gui_dialogs/__init__.py +++ b/src/gui/ytsage_gui_dialogs/__init__.py @@ -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", ] diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_history.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_history.py new file mode 100644 index 0000000..1ef5b81 --- /dev/null +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_history.py @@ -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() diff --git a/src/gui/ytsage_gui_main.py b/src/gui/ytsage_gui_main.py index ae8c091..ea10080 100644 --- a/src/gui/ytsage_gui_main.py +++ b/src/gui/ytsage_gui_main.py @@ -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""" diff --git a/src/utils/ytsage_constants.py b/src/utils/ytsage_constants.py index 72ef44b..14f9c88 100644 --- a/src/utils/ytsage_constants.py +++ b/src/utils/ytsage_constants.py @@ -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) diff --git a/src/utils/ytsage_history_manager.py b/src/utils/ytsage_history_manager.py new file mode 100644 index 0000000..b915f00 --- /dev/null +++ b/src/utils/ytsage_history_manager.py @@ -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, + }