diff --git a/crates/hydra-gui/assets/locale/ar.json b/crates/hydra-gui/assets/locale/ar.json index 0dd357fc..94c0d894 100644 --- a/crates/hydra-gui/assets/locale/ar.json +++ b/crates/hydra-gui/assets/locale/ar.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "عدد محاولات الإعادة لكل ملف في حال فشل التنزيل :", "OK": "موافق", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "إيقاف (افتراضي): يُحفظ التنزيل في مجلد فئته، مثل Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "إيقاف = لا يتم جلب أي شيء حتى الضغط على \"بدء التنزيل\"، لذا تتم إعادة التسمية أو تغيير المجلد قبل النقل لا أثناءه.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "تفعيل: يتم تجاهل المجلدات أعلاه وتُحفظ التنزيلات الجديدة مباشرة في مجلد فئة عام. تحتفظ التنزيلات الموجودة بالفعل في القائمة بمجلدها.", "Once": "مرة واحدة", "One-time downloading": "تنزيل لمرة واحدة", diff --git a/crates/hydra-gui/assets/locale/cs.json b/crates/hydra-gui/assets/locale/cs.json index 4ba740c1..10eb2602 100644 --- a/crates/hydra-gui/assets/locale/cs.json +++ b/crates/hydra-gui/assets/locale/cs.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Počet opakování pro každý soubor při selhání stahování:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Vypnuto (výchozí): stahování se uloží do složky své kategorie, např. Stahování/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Vypnuto = nic se nestahuje, dokud nestisknete „Spustit stahování“, takže přejmenování nebo změna složky proběhne před přenosem, ne během něj.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Zapnuto: složky uvedené výše se ignorují a nová stahování se ukládají přímo do složky kategorie Obecné. Stahování již v seznamu si svou složku ponechají.", "Once": "Jednou", "One-time downloading": "Jednorázové stahování", diff --git a/crates/hydra-gui/assets/locale/da.json b/crates/hydra-gui/assets/locale/da.json index d4362221..a946816b 100644 --- a/crates/hydra-gui/assets/locale/da.json +++ b/crates/hydra-gui/assets/locale/da.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Antal genforsøg pr. fil, hvis download mislykkedes:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Fra (standard): et download arkiveres i sin kategorimappe, f.eks. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Fra = der hentes intet, før der trykkes på \"Start download\", så omdøbning eller skift af mappe sker før overførslen, ikke under den.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Til: mapperne ovenfor ignoreres, og nye downloads gemmes direkte i kategorimappen Generelt. Downloads, der allerede er på listen, beholder deres mappe.", "Once": "Én gang", "One-time downloading": "Engangsdownload", diff --git a/crates/hydra-gui/assets/locale/de.json b/crates/hydra-gui/assets/locale/de.json index 8bfbf459..d3677c5a 100644 --- a/crates/hydra-gui/assets/locale/de.json +++ b/crates/hydra-gui/assets/locale/de.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Anzahl der Wiederholungsversuche für jede Datei, wenn der Download fehlschlägt :", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Aus (Standard): Ein Download wird im Unterordner der Kategorie abgelegt, z. B. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Aus = es wird nichts geladen, bis „Download starten“ gedrückt wird; Umbenennen oder ein Ordnerwechsel geschieht so vor der Übertragung, nicht währenddessen.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Ein: Die Ordner der einzelnen Kategorien werden ignoriert und neue Downloads werden direkt im Ordner der Kategorie \"Allgemein\" gespeichert. Downloads, die sich bereits in der Liste befinden, behalten ihren Ordner.", "Once": "Einmal", "One-time downloading": "Einmaliger Download", diff --git a/crates/hydra-gui/assets/locale/el.json b/crates/hydra-gui/assets/locale/el.json index 32101be9..3f494cf4 100644 --- a/crates/hydra-gui/assets/locale/el.json +++ b/crates/hydra-gui/assets/locale/el.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Αριθμός επαναλήψεων ανά αρχείο σε περίπτωση αποτυχίας λήψης:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Ανενεργό (προεπιλογή): κάθε λήψη καταχωρίζεται στον φάκελο της κατηγορίας της, π.χ. Λήψεις/Βίντεο.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Ανενεργό = δεν λαμβάνεται τίποτα μέχρι να πατηθεί «Έναρξη λήψης», ώστε η μετονομασία ή η αλλαγή φακέλου να γίνεται πριν από τη μεταφορά και όχι κατά τη διάρκειά της.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Ενεργό: οι παραπάνω φάκελοι αγνοούνται και οι νέες λήψεις αποθηκεύονται απευθείας στον φάκελο της κατηγορίας Γενικά. Οι λήψεις που βρίσκονται ήδη στη λίστα διατηρούν τον φάκελό τους.", "Once": "Μία φορά", "One-time downloading": "Εφάπαξ λήψη", diff --git a/crates/hydra-gui/assets/locale/en.json b/crates/hydra-gui/assets/locale/en.json index 9473d026..7801d605 100644 --- a/crates/hydra-gui/assets/locale/en.json +++ b/crates/hydra-gui/assets/locale/en.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Number of retries for each file if downloading failed :", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Off (default): a download is filed in its category folder, e.g. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.", "Once": "Once", "One-time downloading": "One-time downloading", diff --git a/crates/hydra-gui/assets/locale/es.json b/crates/hydra-gui/assets/locale/es.json index c8e2099e..b4c56c07 100644 --- a/crates/hydra-gui/assets/locale/es.json +++ b/crates/hydra-gui/assets/locale/es.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Número de reintentos por fichero si la descarga falla :", "OK": "Aceptar", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Apagado (por defecto): las descargas se guardan en la carpeta correspondiente a su categoría, p. ej., Descargas/Videos.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Desactivado = no se descarga nada hasta pulsar «Iniciar descarga», por lo que cambiar el nombre o la carpeta ocurre antes de la transferencia, no durante ella.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Activado: las carpetas anteriores se ignoran y las nuevas descargas se guardan directamente en la carpeta de la categoría 'General'. Las descargas que ya figuran en la lista conservan su carpeta.", "Once": "Una vez", "One-time downloading": "Descarga única", diff --git a/crates/hydra-gui/assets/locale/fa.json b/crates/hydra-gui/assets/locale/fa.json index 5db0ebc6..de773c64 100644 --- a/crates/hydra-gui/assets/locale/fa.json +++ b/crates/hydra-gui/assets/locale/fa.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "تعداد تلاش مجدد برای هر فایل در صورت شکست دانلود:", "OK": "تأیید", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "خاموش (پیش‌فرض): هر دانلود در پوشه دسته خودش ذخیره می‌شود، مثلاً Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "خاموش = تا زمانی که «شروع دانلود» زده نشود چیزی دریافت نمی‌شود، بنابراین تغییر نام یا تغییر پوشه پیش از انتقال انجام می‌شود نه در حین آن.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "روشن: پوشه‌های بالا نادیده گرفته می‌شوند و دانلودهای جدید مستقیماً در پوشه دسته General ذخیره می‌شوند. دانلودهایی که از قبل در فهرست هستند پوشه خود را حفظ می‌کنند.", "Once": "یک‌بار", "One-time downloading": "دانلود یک‌باره", diff --git a/crates/hydra-gui/assets/locale/fi.json b/crates/hydra-gui/assets/locale/fi.json index fcfee5f0..9cbff205 100644 --- a/crates/hydra-gui/assets/locale/fi.json +++ b/crates/hydra-gui/assets/locale/fi.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Yritysten määrä tiedostoa kohden, jos lataus epäonnistuu:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Pois päältä (oletus): lataus tallennetaan sen kategoriakansioon, esim. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Pois = mitään ei ladata ennen kuin painetaan ”Aloita lataus”, joten nimen tai kansion vaihto tapahtuu ennen siirtoa, ei sen aikana.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Päällä: yllä olevat kansiot ohitetaan, ja uudet lataukset tallennetaan suoraan Yleiset-kategoriakansioon. Luettelossa jo olevat lataukset säilyttävät kansionsa.", "Once": "Kerran", "One-time downloading": "Kertalataus", diff --git a/crates/hydra-gui/assets/locale/fr.json b/crates/hydra-gui/assets/locale/fr.json index 11470446..a2449502 100644 --- a/crates/hydra-gui/assets/locale/fr.json +++ b/crates/hydra-gui/assets/locale/fr.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Nombre de tentatives pour chaque fichier en cas d'échec :", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Désactivé (par défaut) : un téléchargement est classé dans son dossier de catégorie, par ex. Téléchargements/Vidéo.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Désactivé = rien n’est téléchargé tant que « Démarrer le téléchargement » n’est pas cliqué ; renommer ou changer de dossier se fait donc avant le transfert, pas pendant.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Activé : les dossiers ci-dessus sont ignorés et les nouveaux téléchargements sont enregistrés directement dans le dossier de catégorie Général. Les téléchargements déjà présents dans la liste conservent leur dossier.", "Once": "Une fois", "One-time downloading": "Téléchargement unique", diff --git a/crates/hydra-gui/assets/locale/he.json b/crates/hydra-gui/assets/locale/he.json index 8a0b8af9..dd4ef0f7 100644 --- a/crates/hydra-gui/assets/locale/he.json +++ b/crates/hydra-gui/assets/locale/he.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "מספר ניסיונות חוזרים לכל קובץ אם ההורדה נכשלה:", "OK": "אישור", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "כבוי (ברירת מחדל): הורדה נשמרת בתיקיית הקטגוריה שלה, לדוגמה הורדות/וידאו (Downloads/Video).", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "כבוי = לא מורד דבר עד שנלחץ \"התחל הורדה\", כך ששינוי שם או החלפת תיקייה מתבצעים לפני ההעברה ולא במהלכה.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "מופעל: המערכת מתעלמת מהתיקיות לעיל והורדות חדשות נשמרות ישירות בתיקיית הקטגוריה 'כללי'. הורדות שכבר קיימות ברשימה שומרות על התיקייה שלהן.", "Once": "פעם אחת", "One-time downloading": "הורדה חד-פעמית", diff --git a/crates/hydra-gui/assets/locale/hi.json b/crates/hydra-gui/assets/locale/hi.json index 23a6930b..a210dfa8 100644 --- a/crates/hydra-gui/assets/locale/hi.json +++ b/crates/hydra-gui/assets/locale/hi.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "डाउनलोड विफल होने पर प्रत्येक फ़ाइल के लिए पुनः प्रयासों की संख्या:", "OK": "ठीक है", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "बंद (डिफ़ॉल्ट): डाउनलोड को उसके श्रेणी फ़ोल्डर में फ़ाइल किया जाता है, जैसे Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "बंद = \"डाउनलोड शुरू करें\" दबाने तक कुछ भी डाउनलोड नहीं होता, इसलिए नाम बदलना या फ़ोल्डर बदलना स्थानांतरण से पहले होता है, उसके दौरान नहीं।", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "चालू: ऊपर दिए गए फ़ोल्डर अनदेखे कर दिए जाते हैं और नए डाउनलोड सीधे General श्रेणी फ़ोल्डर में सहेजे जाते हैं। सूची में पहले से मौजूद डाउनलोड अपना फ़ोल्डर बनाए रखते हैं।", "Once": "एक बार", "One-time downloading": "एक बार का डाउनलोड", diff --git a/crates/hydra-gui/assets/locale/hu.json b/crates/hydra-gui/assets/locale/hu.json index f64b27e3..c8ce2f62 100644 --- a/crates/hydra-gui/assets/locale/hu.json +++ b/crates/hydra-gui/assets/locale/hu.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Az újrapróbálkozások száma fájlonként sikertelen letöltés esetén:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Kikapcsolva (alapértelmezett): a letöltés a kategóriája szerinti mappába kerül, pl. Letöltések/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Kikapcsolva = semmi sem töltődik le, amíg meg nem nyomja a „Letöltés indítása” gombot, így az átnevezés vagy a mappaváltás az átvitel előtt történik, nem közben.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Bekapcsolva: a fenti mappák figyelmen kívül maradnak, és az új letöltések közvetlenül az Általános kategóriamappába kerülnek mentésre. A listán már szereplő letöltések megtartják mappájukat.", "Once": "Egyszer", "One-time downloading": "Egyszeri letöltés", diff --git a/crates/hydra-gui/assets/locale/id.json b/crates/hydra-gui/assets/locale/id.json index bc196102..3738c583 100644 --- a/crates/hydra-gui/assets/locale/id.json +++ b/crates/hydra-gui/assets/locale/id.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Jumlah percobaan ulang untuk setiap berkas jika pengunduhan gagal:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Nonaktif (default): unduhan disimpan di folder kategorinya, mis. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Mati = tidak ada yang diunduh sampai \"Mulai Unduhan\" ditekan, jadi mengganti nama atau folder terjadi sebelum transfer, bukan saat berlangsung.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Aktif: folder di atas diabaikan dan unduhan baru disimpan langsung di folder kategori General. Unduhan yang sudah ada di daftar tetap menggunakan folder masing-masing.", "Once": "Sekali", "One-time downloading": "Pengunduhan sekali", diff --git a/crates/hydra-gui/assets/locale/it.json b/crates/hydra-gui/assets/locale/it.json index 1016d581..fd9f740a 100644 --- a/crates/hydra-gui/assets/locale/it.json +++ b/crates/hydra-gui/assets/locale/it.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Numero di tentativi per ciascun file in caso di download non riuscito:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Disattivato (predefinito): un download viene archiviato nella sua cartella di categoria, ad es. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Disattivato = non viene scaricato nulla finché non si preme \"Avvia download\", così rinominare o cambiare cartella avviene prima del trasferimento, non durante.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Attivato: le cartelle sopra vengono ignorate e i nuovi download vengono salvati direttamente nella cartella della categoria Generale. I download già nell'elenco mantengono la propria cartella.", "Once": "Una volta", "One-time downloading": "Download singolo", diff --git a/crates/hydra-gui/assets/locale/ja.json b/crates/hydra-gui/assets/locale/ja.json index 75532107..eedabfb1 100644 --- a/crates/hydra-gui/assets/locale/ja.json +++ b/crates/hydra-gui/assets/locale/ja.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "ダウンロード失敗時の再試行回数:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "オフ(デフォルト): ダウンロードはカテゴリフォルダに保存されます(例: Downloads/Video)。", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "オフ = 「ダウンロード開始」を押すまで何も取得しません。名前やフォルダーの変更は転送中ではなく転送前に行えます。", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "オン: 上記のフォルダは無視され、新しいダウンロードは「一般」カテゴリフォルダに直接保存されます。リストに既にあるダウンロードは元のフォルダのままです。", "Once": "1回のみ", "One-time downloading": "1回限りのダウンロード", diff --git a/crates/hydra-gui/assets/locale/ko.json b/crates/hydra-gui/assets/locale/ko.json index fb4a7ffe..a64edb4c 100644 --- a/crates/hydra-gui/assets/locale/ko.json +++ b/crates/hydra-gui/assets/locale/ko.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "다운로드 실패 시 파일당 재시도 횟수:", "OK": "확인", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "끔 (기본값): 다운로드가 해당 카테고리 폴더에 저장됩니다. 예: Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "끄면 \"다운로드 시작\"을 누를 때까지 아무것도 내려받지 않으므로, 이름이나 폴더 변경이 전송 중이 아니라 전송 전에 이루어집니다.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "켬: 위의 폴더는 무시되며, 새로 다운로드한 항목은 General 카테고리 폴더에 바로 저장됩니다. 목록에 이미 있는 다운로드는 폴더를 유지합니다.", "Once": "한 번", "One-time downloading": "1회 다운로드", diff --git a/crates/hydra-gui/assets/locale/nl.json b/crates/hydra-gui/assets/locale/nl.json index 205db32b..0dc67deb 100644 --- a/crates/hydra-gui/assets/locale/nl.json +++ b/crates/hydra-gui/assets/locale/nl.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Aantal pogingen per bestand bij mislukte download:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Uit (standaard): een download wordt opgeslagen in de bijbehorende categoriemap, bijv. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Uit = er wordt niets opgehaald totdat op \"Download starten\" is geklikt, zodat hernoemen of een andere map kiezen vóór de overdracht gebeurt, niet tijdens.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Aan: de mappen hierboven worden genegeerd en nieuwe downloads worden direct opgeslagen in de categoriemap Algemeen. Downloads die al in de lijst staan, behouden hun map.", "Once": "Eenmalig", "One-time downloading": "Eenmalig downloaden", diff --git a/crates/hydra-gui/assets/locale/pl.json b/crates/hydra-gui/assets/locale/pl.json index 8d48fc3f..547405b4 100644 --- a/crates/hydra-gui/assets/locale/pl.json +++ b/crates/hydra-gui/assets/locale/pl.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Liczba ponownych prób dla każdego pliku w przypadku niepowodzenia pobierania:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Wyłączone (domyślnie): pobieranie trafia do folderu swojej kategorii, np. Pobrane/Wideo.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Wyłączone = nic nie jest pobierane, dopóki nie zostanie naciśnięte „Rozpocznij pobieranie”, więc zmiana nazwy lub folderu następuje przed transferem, a nie w jego trakcie.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Włączone: powyższe foldery są ignorowane, a nowe pobierania są zapisywane bezpośrednio w folderze kategorii Ogólne. Pobierania już znajdujące się na liście zachowują swój folder.", "Once": "Raz", "One-time downloading": "Jednorazowe pobieranie", diff --git a/crates/hydra-gui/assets/locale/pt-BR.json b/crates/hydra-gui/assets/locale/pt-BR.json index 8dda574d..78ba83e9 100644 --- a/crates/hydra-gui/assets/locale/pt-BR.json +++ b/crates/hydra-gui/assets/locale/pt-BR.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Número de tentativas para cada arquivo em caso de falha no download:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Desativado (padrão): um download é arquivado em sua pasta de categoria, por exemplo, Downloads/Vídeo.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Desativado = nada é baixado até \"Iniciar Download\" ser pressionado, então renomear ou mudar a pasta acontece antes da transferência, não durante.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Ativado: as pastas acima são ignoradas e os novos downloads são salvos diretamente na pasta da categoria Geral. Downloads já na lista mantêm sua pasta.", "Once": "Uma vez", "One-time downloading": "Download único", diff --git a/crates/hydra-gui/assets/locale/pt.json b/crates/hydra-gui/assets/locale/pt.json index 49addc12..0dac3e36 100644 --- a/crates/hydra-gui/assets/locale/pt.json +++ b/crates/hydra-gui/assets/locale/pt.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Número de tentativas para cada ficheiro caso a transferência falhe:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Desativado (predefinição): cada transferência é arquivada na sua pasta de categoria, por exemplo Transferências/Vídeo.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Desativado = nada é transferido até premir \"Iniciar Transferência\", pelo que mudar o nome ou a pasta acontece antes da transferência e não durante.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Ativado: as pastas acima são ignoradas e as novas transferências são guardadas diretamente na pasta de categoria Geral. As transferências já existentes na lista mantêm a sua pasta.", "Once": "Uma vez", "One-time downloading": "Transferência única", diff --git a/crates/hydra-gui/assets/locale/ro.json b/crates/hydra-gui/assets/locale/ro.json index 3e390bbe..a879b789 100644 --- a/crates/hydra-gui/assets/locale/ro.json +++ b/crates/hydra-gui/assets/locale/ro.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Numărul de reîncercări pentru fiecare fișier dacă descărcarea eșuează:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Dezactivat (implicit): o descărcare este plasată în folderul categoriei sale, de ex. Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Dezactivat = nu se descarcă nimic până la apăsarea „Pornește descărcarea”, astfel redenumirea sau schimbarea folderului are loc înainte de transfer, nu în timpul lui.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Activat: folderele de mai sus sunt ignorate, iar descărcările noi sunt salvate direct în folderul categoriei General. Descărcările deja din listă își păstrează folderul.", "Once": "O dată", "One-time downloading": "Descărcare unică", diff --git a/crates/hydra-gui/assets/locale/ru.json b/crates/hydra-gui/assets/locale/ru.json index 1778884d..e35bfc67 100644 --- a/crates/hydra-gui/assets/locale/ru.json +++ b/crates/hydra-gui/assets/locale/ru.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Количество повторных попыток при ошибке скачивания :", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Выкл (по умолчанию): загрузка сохраняется в папку своей категории, например Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Выключено = ничего не загружается, пока не нажата \"Начать закачку\", поэтому переименование или смена папки происходят до передачи, а не во время неё.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Вкл: папки выше игнорируются, и новые загрузки сохраняются прямо в папку категории \"Основные\". Загрузки, уже находящиеся в списке, сохраняют свою папку.", "Once": "Один раз", "One-time downloading": "Однократная загрузка", diff --git a/crates/hydra-gui/assets/locale/sv.json b/crates/hydra-gui/assets/locale/sv.json index e87f3f08..642cf886 100644 --- a/crates/hydra-gui/assets/locale/sv.json +++ b/crates/hydra-gui/assets/locale/sv.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Antal nya försök per fil om nedladdningen misslyckas:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Av (standard): en nedladdning sparas i sin kategorimapp, t.ex. Nedladdningar/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Av = inget hämtas förrän \"Starta nedladdning\" trycks, så byte av namn eller mapp sker före överföringen, inte under den.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "På: mapparna ovan ignoreras och nya nedladdningar sparas direkt i kategorimappen Allmänt. Nedladdningar som redan finns i listan behåller sin mapp.", "Once": "En gång", "One-time downloading": "Engångsnedladdning", diff --git a/crates/hydra-gui/assets/locale/th.json b/crates/hydra-gui/assets/locale/th.json index 59970dc1..19c3fb18 100644 --- a/crates/hydra-gui/assets/locale/th.json +++ b/crates/hydra-gui/assets/locale/th.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "จำนวนครั้งที่ลองใหม่สำหรับแต่ละไฟล์หากดาวน์โหลดล้มเหลว :", "OK": "ตกลง", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "ปิด (ค่าเริ่มต้น): ไฟล์ดาวน์โหลดจะถูกจัดเก็บในโฟลเดอร์ตามหมวดหมู่ เช่น Downloads/Video", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "ปิด = จะไม่ดาวน์โหลดสิ่งใดจนกว่าจะกด \"เริ่มดาวน์โหลด\" ดังนั้นการเปลี่ยนชื่อหรือเปลี่ยนโฟลเดอร์จะเกิดขึ้นก่อนการถ่ายโอน ไม่ใช่ระหว่างนั้น", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "เปิด: โฟลเดอร์ด้านบนจะถูกละเว้น และการดาวน์โหลดใหม่จะถูกบันทึกลงในโฟลเดอร์หมวดหมู่ทั่วไปโดยตรง ส่วนไฟล์ที่อยู่ในรายการอยู่แล้วจะยังคงอยู่ในโฟลเดอร์เดิม", "Once": "ครั้งเดียว", "One-time downloading": "การดาวน์โหลดครั้งเดียว", diff --git a/crates/hydra-gui/assets/locale/tr.json b/crates/hydra-gui/assets/locale/tr.json index 28cbcb85..3bf90104 100644 --- a/crates/hydra-gui/assets/locale/tr.json +++ b/crates/hydra-gui/assets/locale/tr.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "İndirme başarısız olursa her dosya için yeniden deneme sayısı:", "OK": "Tamam", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Kapalı (varsayılan): bir indirme kendi kategori klasörüne dosyalanır, ör. İndirilenler/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Kapalı = \"İndirmeyi Başlat\" düğmesine basılana kadar hiçbir şey indirilmez; böylece yeniden adlandırma veya klasör değişikliği aktarım sırasında değil öncesinde yapılır.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Açık: yukarıdaki klasörler yoksayılır ve yeni indirmeler doğrudan Genel kategori klasörüne kaydedilir. Zaten listede olan indirmeler kendi klasörlerinde kalır.", "Once": "Bir Kez", "One-time downloading": "Bir kerelik indirme", diff --git a/crates/hydra-gui/assets/locale/uk.json b/crates/hydra-gui/assets/locale/uk.json index 1e35df66..c5fec548 100644 --- a/crates/hydra-gui/assets/locale/uk.json +++ b/crates/hydra-gui/assets/locale/uk.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Кількість повторних спроб для кожного файлу в разі помилки завантаження :", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Вимкнено (типово): завантаження розміщується в папці своєї категорії, наприклад Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Вимкнено = нічого не завантажується, доки не натиснуто \"Почати завантаження\", тож перейменування чи зміна теки відбувається до передавання, а не під час нього.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Увімкнено: папки вище ігноруються, і нові завантаження зберігаються безпосередньо в папці категорії «Загальні». Завантаження, які вже є в списку, зберігають свою папку.", "Once": "Один раз", "One-time downloading": "Одноразове завантаження", diff --git a/crates/hydra-gui/assets/locale/vi.json b/crates/hydra-gui/assets/locale/vi.json index 3733d0e4..c0f5f12a 100644 --- a/crates/hydra-gui/assets/locale/vi.json +++ b/crates/hydra-gui/assets/locale/vi.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "Số lần thử lại cho mỗi tệp nếu tải xuống thất bại:", "OK": "OK", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "Tắt (mặc định): mỗi lượt tải được lưu vào thư mục danh mục của nó, ví dụ Downloads/Video.", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "Tắt = không tải gì cho đến khi nhấn \"Bắt đầu tải xuống\", nên việc đổi tên hay đổi thư mục diễn ra trước khi truyền, không phải trong lúc truyền.", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "Bật: các thư mục ở trên bị bỏ qua và các lượt tải mới được lưu trực tiếp vào thư mục danh mục Chung. Các lượt tải đã có trong danh sách vẫn giữ nguyên thư mục của chúng.", "Once": "Một lần", "One-time downloading": "Tải xuống một lần", diff --git a/crates/hydra-gui/assets/locale/zh-hant.json b/crates/hydra-gui/assets/locale/zh-hant.json index 206b1dc6..a1674bb0 100644 --- a/crates/hydra-gui/assets/locale/zh-hant.json +++ b/crates/hydra-gui/assets/locale/zh-hant.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "下載失敗時,每個檔案的重試次數:", "OK": "確定", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "關閉(預設):下載內容將歸檔到其分類檔案夾中,例如 Downloads/Video。", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "關閉 = 在按下“開始下載”之前不會下載任何內容,因此重新命名或變更資料夾是在傳輸前完成,而非傳輸過程中。", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "開啟後:以上檔案夾將被忽略,新下載的檔案將直接儲存到「一般」分類檔案夾中。清單中已有的下載檔案將保留在其原始檔案夾中。", "Once": "一次", "One-time downloading": "一次下載", diff --git a/crates/hydra-gui/assets/locale/zh.json b/crates/hydra-gui/assets/locale/zh.json index 883902fe..8fe048f0 100644 --- a/crates/hydra-gui/assets/locale/zh.json +++ b/crates/hydra-gui/assets/locale/zh.json @@ -213,6 +213,7 @@ "Number of retries for each file if downloading failed :": "下载失败时每个文件的重试次数:", "OK": "确定", "Off (default): a download is filed in its category folder, e.g. Downloads/Video.": "关闭(默认):下载会归入其分类文件夹,例如 Downloads/Video。", + "Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it.": "关闭 = 在按下“开始下载”之前不会下载任何内容,因此重命名或更改文件夹是在传输前完成,而不是传输过程中。", "On: the folders above are ignored and new downloads are saved directly in the General category folder. Downloads already on the list keep their folder.": "开启:忽略以上文件夹,新下载会直接保存到“常规”分类文件夹。列表中已有的下载保留原有文件夹。", "Once": "一次", "One-time downloading": "一次性下载", diff --git a/crates/hydra-gui/src/app.rs b/crates/hydra-gui/src/app.rs index e42365d7..fc437fda 100644 --- a/crates/hydra-gui/src/app.rs +++ b/crates/hydra-gui/src/app.rs @@ -272,6 +272,9 @@ pub struct AddUrlState { /// Filename the browser had already resolved (Content-Disposition et /// al.) — better than what the URL path implies. pub capture_name: Option, + /// Page the browser was on when it captured this file. A CDN with + /// hotlink protection answers `403` without it. + pub capture_referer: Option, /// What the address turned out to be, when it is a manifest. A stream /// has to be asked which rendition BEFORE it starts — there is no /// changing your mind halfway through a hundred segments. @@ -338,6 +341,7 @@ pub struct PendingAdd { pub auth: Option<(String, String)>, pub cookies: Option, pub name: Option, + pub referer: Option, } #[derive(Clone, Debug, Default)] @@ -1076,6 +1080,7 @@ pub enum OptField { ServerDate(bool), NoCatDirs(bool), ShowFileInfo(bool), + BgDownload(bool), StartMinimized(bool), SpeedTab(bool), CompletionTab(bool), @@ -1577,12 +1582,20 @@ impl App { } /// Attach what the browser knew that `add_item` could not: session - /// cookies and the already-resolved filename. Must run before any start - /// so the first request carries the Cookie header; renaming re-runs - /// categorization because the URL-derived name may have had no - /// extension at all. - fn apply_capture_extras(&mut self, id: DlId, cookies: Option, name: Option) { - if cookies.is_none() && name.is_none() { + /// cookies, the page the file was linked from, and the already-resolved + /// filename. Must run before any start so the first request carries the + /// `Cookie` and `Referer` headers — a hotlink-protected CDN answers + /// `403` without the referer, whatever the cookies say. Renaming re-runs + /// categorization because the URL-derived name may have had no extension + /// at all. + fn apply_capture_extras( + &mut self, + id: DlId, + cookies: Option, + name: Option, + referer: Option, + ) { + if cookies.is_none() && name.is_none() && referer.is_none() { return; } // Resolved before the item is borrowed mutably, and through @@ -1597,6 +1610,9 @@ impl App { if let Some(c) = cookies { d.cookies = Some(c); } + if let Some(r) = referer { + d.referer = Some(r); + } if let Some(n) = name { d.file_name = n; if let Some(c) = cat { @@ -2118,6 +2134,7 @@ impl App { held: d.held.clone(), expected_size: d.size, cookies: d.cookies.clone(), + referer: d.referer.clone(), limit: None, adaptive, remote_time, @@ -2312,6 +2329,7 @@ impl App { q_order, auth, cookies: None, + referer: None, speed_limit: None, limit_paused: false, held: vec![], @@ -3995,6 +4013,11 @@ impl App { .take() .filter(|s| !s.is_empty()); let cap_name = self.add_url.capture_name.take().filter(|s| !s.is_empty()); + let cap_referer = self + .add_url + .capture_referer + .take() + .filter(|s| !s.is_empty()); // A manifest that was inspected becomes a STREAM item: the // chosen rendition and container decide the filename, which // the URL path cannot. @@ -4052,6 +4075,7 @@ impl App { auth, cookies: cap_cookies, name: cap_name, + referer: cap_referer, }); self.confirm = Some(ConfirmKind::Duplicate { existing, file }); self.add_url = AddUrlState::default(); @@ -4059,7 +4083,7 @@ impl App { return Task::batch([close, self.open_window(WinKind::Confirm)]); } let id = self.add_item(url, auth, None); - self.apply_capture_extras(id, cap_cookies, cap_name); + self.apply_capture_extras(id, cap_cookies, cap_name, cap_referer); if let Some(si) = stream { let cat = crate::model::categorize(&name, &self.cfg.categories); let dir = self @@ -4125,6 +4149,7 @@ impl App { address: dl.url, capture_cookies: dl.cookies, capture_name: dl.filename, + capture_referer: dl.referer, ..AddUrlState::default() }; self.update(Message::AddUrlOk) @@ -4434,7 +4459,8 @@ impl App { // or cookie must reach the peek before it reaches the item. let auth = (!fi.login.is_empty()).then_some((fi.login.as_str(), fi.password.as_str())); - let headers = engine::request_headers(auth, Some(&fi.cookies)); + let referer = self.item(dl).and_then(|d| d.referer.clone()); + let headers = engine::request_headers(auth, Some(&fi.cookies), referer.as_deref()); let url = fi.url.clone(); let ua = self.cfg.settings.user_agent.clone(); // The size the probe (or the transfer running behind the @@ -5074,9 +5100,10 @@ impl App { Message::BatchMetalinkProbed(url.clone(), Box::new(r.ok())) }) } else { - Task::perform(engine::probe_link(url.clone(), ua), move |meta| { - Message::BatchProbed(url.clone(), meta) - }) + Task::perform( + engine::probe_link(url.clone(), ua, vec![]), + move |meta| Message::BatchProbed(url.clone(), meta), + ) } }) .collect(); @@ -5434,7 +5461,7 @@ impl App { return close; }; let id = self.add_item(pending.url, pending.auth, None); - self.apply_capture_extras(id, pending.cookies, pending.name); + self.apply_capture_extras(id, pending.cookies, pending.name, pending.referer); // `name_1.ext`, `name_2.ext`, ... until it collides with // neither the disk nor another list entry. Locked, because // this copy exists precisely so the file already on disk is @@ -5529,8 +5556,20 @@ impl App { return self.start_download(id, false); } let ua = self.cfg.settings.user_agent.clone(); - let url = self.item(id).map(|d| d.url.clone()).unwrap_or_default(); - Task::perform(engine::probe_link(url, ua), move |meta| { + let (url, headers) = self + .item(id) + .map(|d| { + ( + d.url.clone(), + engine::request_headers( + d.auth.as_ref().map(|(u, p)| (u.as_str(), p.as_str())), + d.cookies.as_deref(), + d.referer.as_deref(), + ), + ) + }) + .unwrap_or_default(); + Task::perform(engine::probe_link(url, ua, headers), move |meta| { Message::InfoProbed(id, meta) }) } @@ -5970,6 +6009,7 @@ impl App { OptField::ServerDate(b) => s.server_file_date = b, OptField::NoCatDirs(b) => s.no_category_dirs = b, OptField::ShowFileInfo(b) => s.show_file_info_dialog = b, + OptField::BgDownload(b) => s.bg_download = b, OptField::StartMinimized(b) => s.start_minimized = b, OptField::SpeedTab(b) => s.show_speed_tab = b, OptField::CompletionTab(b) => s.show_completion_tab = b, @@ -6897,6 +6937,7 @@ mod tests { q_order: 0, auth: None, cookies: None, + referer: None, speed_limit: None, limit_paused: false, held: vec![], diff --git a/crates/hydra-gui/src/engine.rs b/crates/hydra-gui/src/engine.rs index 8f0ac85d..1a740a09 100644 --- a/crates/hydra-gui/src/engine.rs +++ b/crates/hydra-gui/src/engine.rs @@ -43,6 +43,10 @@ pub struct StartSpec { pub expected_size: Option, /// `Cookie:` header value, verbatim. pub cookies: Option, + /// `Referer:` header value, verbatim: the page the file was linked from. + /// A CDN with hotlink protection refuses the object without it, whatever + /// the cookies say. + pub referer: Option, /// Aggregate cap in bytes/sec; `None` = unlimited (can be changed live). pub limit: Option, /// Start at one connection and let the in-band ramp admit more while @@ -101,6 +105,7 @@ impl StartSpec { held: Vec::new(), expected_size: None, cookies: None, + referer: None, limit: None, adaptive: false, remote_time: false, @@ -512,14 +517,14 @@ pub struct LinkMeta { /// Bounded: a pasted 500-link batch must not open 500 concurrent /// handshakes (fd limits, origin rate limiting). A small global gate keeps /// the fan-out polite; the shared connector reuses connections per host. -pub async fn probe_link(url: String, user_agent: String) -> Option { +pub async fn probe_link(url: String, user_agent: String, headers: Vec) -> Option { static GATE: OnceLock> = OnceLock::new(); let gate = GATE .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(6))) .clone(); let _permit = gate.acquire_owned().await.ok()?; let connector = shared_connector().ok()?; - let (url, p) = resolve_link(connector.as_ref(), url, &user_agent, &[]).await?; + let (url, p) = resolve_link(connector.as_ref(), url, &user_agent, &headers).await?; if p.status >= 300 { return None; } @@ -1070,9 +1075,14 @@ fn base64(data: &[u8]) -> String { } /// The request headers a download's credentials turn into: HTTP Basic for a -/// login, `Cookie:` for a cookie string. Shared by the transfer and by the -/// probes that must see the same object it will. -pub fn request_headers(auth: Option<(&str, &str)>, cookies: Option<&str>) -> Vec { +/// login, `Cookie:` for a cookie string, `Referer:` for the page the file was +/// linked from. Shared by the transfer and by the probes that must see the +/// same object it will. +pub fn request_headers( + auth: Option<(&str, &str)>, + cookies: Option<&str>, + referer: Option<&str>, +) -> Vec { let mut headers = Vec::new(); if let Some((user, pass)) = auth { headers.push(format!( @@ -1083,6 +1093,9 @@ pub fn request_headers(auth: Option<(&str, &str)>, cookies: Option<&str>) -> Vec if let Some(c) = cookies.map(str::trim).filter(|c| !c.is_empty()) { headers.push(format!("Cookie: {c}")); } + if let Some(r) = referer.map(str::trim).filter(|r| !r.is_empty()) { + headers.push(format!("Referer: {r}")); + } headers } @@ -1090,6 +1103,7 @@ fn target_for(u: &ParsedUrl, spec: &StartSpec) -> Target { let headers = request_headers( spec.auth.as_ref().map(|(u, p)| (u.as_str(), p.as_str())), spec.cookies.as_deref(), + spec.referer.as_deref(), ); target_of(u, headers, &spec.user_agent) } @@ -3046,6 +3060,7 @@ mod tests { held: vec![], expected_size: None, cookies: None, + referer: None, limit: None, adaptive: std::env::var_os("HYDRA_AB_ADAPTIVE").is_some(), remote_time: false, @@ -3272,6 +3287,7 @@ mod tests { held: vec![], expected_size: None, cookies: None, + referer: None, limit: None, adaptive: false, // On, so the harness exercises the stamping path too: it is part @@ -6335,8 +6351,28 @@ mod peek_zip_tests { #[test] fn credentials_become_headers() { - let h = request_headers(Some(("me", "pw")), Some(" sid=1 ")); + let h = request_headers(Some(("me", "pw")), Some(" sid=1 "), None); assert_eq!(h, ["Authorization: Basic bWU6cHc=", "Cookie: sid=1"]); - assert!(request_headers(None, Some(" ")).is_empty()); + assert!(request_headers(None, Some(" "), Some(" ")).is_empty()); + } + + /// A hotlink-protected CDN answers `403` to a request without the page + /// it was linked from, however good the cookies are: the captured + /// referer has to reach the wire, not just the log. + #[test] + fn a_captured_referer_becomes_a_header() { + let spec = StartSpec { + cookies: Some("sid=1".into()), + referer: Some("https://www.example.com/watch".into()), + user_agent: "hydra-test".into(), + ..StartSpec::plain() + }; + let u = parse_url("https://cdn.example.com/a.mp4?e=1&s=2").unwrap(); + let t = format!("{:?}", target_for(&u, &spec)); + assert!( + t.contains("Referer: https://www.example.com/watch"), + "no referer on the target: {t}" + ); + assert!(t.contains("Cookie: sid=1"), "no cookie on the target: {t}"); } } diff --git a/crates/hydra-gui/src/extbus.rs b/crates/hydra-gui/src/extbus.rs index ededea8e..f56c4322 100644 --- a/crates/hydra-gui/src/extbus.rs +++ b/crates/hydra-gui/src/extbus.rs @@ -41,6 +41,8 @@ pub struct ExtDownload { /// Verbatim `Cookie:` header assembled by the extension for this URL. #[serde(default)] pub cookies: Option, + /// The page the file was linked from, replayed as `Referer:` on every + /// request: a CDN with hotlink protection refuses the object without it. #[serde(default)] pub referer: Option, #[serde(default)] @@ -453,8 +455,9 @@ fn dispatch(req: &serde_json::Value, trusted: bool) -> serde_json::Value { } Some("download") => match serde_json::from_value::(req.clone()) { Ok(dl) if !dl.url.is_empty() => { - // referer/user_agent/size arrive for the log today; the - // engine's StartSpec grows per-download headers later. + // The referer travels with the item and onto every request + // (`DownloadItem::referer`); user_agent and size are logged + // for support, the transfer using the configured agent. crate::log::info(&format!( "extbus: capture {} (mime={} size={} referer={} ua={} from={})", dl.url, diff --git a/crates/hydra-gui/src/model.rs b/crates/hydra-gui/src/model.rs index 8f555bcf..1385cc56 100644 --- a/crates/hydra-gui/src/model.rs +++ b/crates/hydra-gui/src/model.rs @@ -65,6 +65,14 @@ pub struct DownloadItem { /// (filled manually or, later, by the browser extension). #[serde(default)] pub cookies: Option, + /// `Referer:` header value for a hotlink-protected origin: the page the + /// browser was on when the extension captured this file. Sites that gate + /// their CDN on it answer `403` to a request without it, however good the + /// cookies are, so it travels with the item and is replayed on every + /// start — the same way `StreamInfo::referer` already works for a + /// manifest's segments. + #[serde(default)] + pub referer: Option, /// Per-download cap, bytes/sec, when the Speed Limiter tab enables one. pub speed_limit: Option, /// Parked by the Connection tab's download limit, not by the user. The diff --git a/crates/hydra-gui/src/windows/options.rs b/crates/hydra-gui/src/windows/options.rs index 20f3a5ca..ccf09a1b 100644 --- a/crates/hydra-gui/src/windows/options.rs +++ b/crates/hydra-gui/src/windows/options.rs @@ -153,6 +153,13 @@ fn general(app: &App) -> El<'_> { .style(theme::check), tr("Closing the main window leaves Hydra running in the tray, where queues and transfers carry on; open it again from the tray icon. Off: closing the window exits Hydra."), ), + ]; + // Straight after "Close to system tray": both decide what the app looks + // like once its window is gone. + if let Some(el) = hide_taskbar { + col = col.push(el); + } + col.extend([ hinted( checkbox(s.check_updates_on_startup).label(tr("Check for updates on startup")) .on_toggle(|b| o(OptField::CheckUpdates(b))) @@ -169,11 +176,6 @@ fn general(app: &App) -> El<'_> { .style(theme::check), tr("Update checks also offer release candidates (-rc tags) when one is ahead of the stable release; otherwise the stable release is used. Beta builds may be less stable."), ), - ]; - if let Some(el) = hide_taskbar { - col = col.push(el); - } - col.extend([ hinted( checkbox(s.power_save).label(tr("Power save mode")) .on_toggle(|b| o(OptField::PowerSave(b))) @@ -347,6 +349,14 @@ fn downloads(app: &App) -> El<'_> { .style(theme::check), tr("Adding a link first shows name/category/folder while the transfer already runs in the background; off = downloads start immediately."), ), + hinted( + checkbox(s.bg_download).label(tr("Download in background while choosing options")) + .on_toggle(|b| o(OptField::BgDownload(b))) + .size(15.0) + .text_size(theme::FONT_SIZE) + .style(theme::check), + tr("Off = nothing is fetched until \"Start Download\" is pressed, so a rename or a change of folder happens before the transfer, not during it."), + ), hinted( checkbox(s.show_speed_tab).label(tr("Show \"Speed Limiter\" tab")) .on_toggle(|b| o(OptField::SpeedTab(b))) diff --git a/extensions/chrome/background.js b/extensions/chrome/background.js index d42320f0..4b40d670 100644 --- a/extensions/chrome/background.js +++ b/extensions/chrome/background.js @@ -586,6 +586,31 @@ function titleName(title) { ); } +/// What to call a captured media file. +/// +/// Normally nothing: the URL's own basename is the better name, and Hydra +/// reads it off the URL by itself — `big_buck_bunny_1080p.mp4` beats any page +/// title, and on a page of samples every row would otherwise take the same +/// one. But an object stored as `fc1eced1-6d50-4375-a125-ef65c887d7d5.mp4` was +/// named by the CDN for the CDN, and lands as a row of gibberish the user then +/// renames by hand. There the page title is the answer, which is what IDM +/// gives them. +function mediaName(url, title, kind) { + const base = decodeURIComponent(url.split(/[?#]/)[0].split("/").pop() || ""); + const stem = base.replace(/\.[^.]+$/, ""); + const opaque = + !stem || + /^[0-9a-f]{8,}$/i.test(stem) || + /^[0-9a-f][0-9a-f-]{15,}$/i.test(stem) || + /^\d{6,}$/.test(stem); + if (!opaque) return null; + const named = titleName(title); + if (!named) return null; + // The extension the file really has, else what the server called its type. + const ext = (/\.([a-z0-9]{2,5})$/i.exec(base)?.[1] || kind || "").toLowerCase(); + return ext ? `${named}.${ext}` : named; +} + /// Stable identity for one variant, so the popup can name the row's choice /// back to the service worker. Duplicated verbatim in popup.js — the two run /// in different contexts and must agree. @@ -1162,9 +1187,24 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { case "open-hydra": sendResponse(await request({ type: "open" })); break; - case "download-url": - sendResponse(await sendToHydra(msg.url, { referer: msg.referer || null })); + case "download-url": { + // Only a file the media sniffer saw is named after the page: an + // ordinary link — a `.zip` on a downloads page — means its own name, + // not the article's. + const id = await whichTab(); + const bare = msg.url.split(/[?#]/)[0]; + const hit = (id != null ? await tabMedia(id) : []).find( + (m) => m.url.split(/[?#]/)[0] === bare + ); + const tab = id != null ? await chrome.tabs.get(id).catch(() => null) : null; + sendResponse( + await sendToHydra(msg.url, { + referer: msg.referer || null, + filename: hit ? mediaName(msg.url, tab?.title, hit.kind) : null, + }) + ); break; + } case "download-stream": { const id = await whichTab(); const entry = (id != null ? await tabStreams(id) : []).find((s) => s.key === msg.key); diff --git a/extensions/chrome/manifest.json b/extensions/chrome/manifest.json index edfafba3..7996e493 100644 --- a/extensions/chrome/manifest.json +++ b/extensions/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Hydra Download Manager Integration", - "version": "0.3.1", + "version": "0.3.2", "description": "A browser integration for Hydra: automatic download capture, right-click downloads, and media/HLS/DASH stream sniffing.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt7NVgqBOTaCTKcon/gTK0M4VxY4ZKcyh0pWTVQoWfjJrJlTvRHaWFxE4ZbIj+88zNKJBxVa6uMdQKtK2V+NaPYR1NTYyTDh75UErRI+mx5RXa5c2kiwudl2jwHPsYcUU9NPMI4FwqVFaxEetvIS9J91/3ze5l+FbsZDF0Yvc9uR1dYNnnc//nGg1R+uL0/wus4INeccd9COj10+Aka2SeBkvrVlLs63AMFMkPpN9iIBwyhgs01bulVRFbvHK9m/TizLfthMHAyMCkUdCkZr6gEZbYbwNwGwKCUIswomoiVRgZx/FBFYrh6cRlfoLQZUbS7kFTShpfjm+zEGBkWQ3yQIDAQAB", "minimum_chrome_version": "116", diff --git a/extensions/firefox/manifest.json b/extensions/firefox/manifest.json index 85ac69d9..b5601746 100644 --- a/extensions/firefox/manifest.json +++ b/extensions/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Hydra Download Manager Integration", - "version": "0.3.1", + "version": "0.3.2", "description": "A browser integration for Hydra: automatic download capture, right-click downloads, and media/HLS/DASH stream sniffing.", "browser_specific_settings": { "gecko": { diff --git a/extensions/safari/Resources/background.js b/extensions/safari/Resources/background.js index d42320f0..4b40d670 100644 --- a/extensions/safari/Resources/background.js +++ b/extensions/safari/Resources/background.js @@ -586,6 +586,31 @@ function titleName(title) { ); } +/// What to call a captured media file. +/// +/// Normally nothing: the URL's own basename is the better name, and Hydra +/// reads it off the URL by itself — `big_buck_bunny_1080p.mp4` beats any page +/// title, and on a page of samples every row would otherwise take the same +/// one. But an object stored as `fc1eced1-6d50-4375-a125-ef65c887d7d5.mp4` was +/// named by the CDN for the CDN, and lands as a row of gibberish the user then +/// renames by hand. There the page title is the answer, which is what IDM +/// gives them. +function mediaName(url, title, kind) { + const base = decodeURIComponent(url.split(/[?#]/)[0].split("/").pop() || ""); + const stem = base.replace(/\.[^.]+$/, ""); + const opaque = + !stem || + /^[0-9a-f]{8,}$/i.test(stem) || + /^[0-9a-f][0-9a-f-]{15,}$/i.test(stem) || + /^\d{6,}$/.test(stem); + if (!opaque) return null; + const named = titleName(title); + if (!named) return null; + // The extension the file really has, else what the server called its type. + const ext = (/\.([a-z0-9]{2,5})$/i.exec(base)?.[1] || kind || "").toLowerCase(); + return ext ? `${named}.${ext}` : named; +} + /// Stable identity for one variant, so the popup can name the row's choice /// back to the service worker. Duplicated verbatim in popup.js — the two run /// in different contexts and must agree. @@ -1162,9 +1187,24 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { case "open-hydra": sendResponse(await request({ type: "open" })); break; - case "download-url": - sendResponse(await sendToHydra(msg.url, { referer: msg.referer || null })); + case "download-url": { + // Only a file the media sniffer saw is named after the page: an + // ordinary link — a `.zip` on a downloads page — means its own name, + // not the article's. + const id = await whichTab(); + const bare = msg.url.split(/[?#]/)[0]; + const hit = (id != null ? await tabMedia(id) : []).find( + (m) => m.url.split(/[?#]/)[0] === bare + ); + const tab = id != null ? await chrome.tabs.get(id).catch(() => null) : null; + sendResponse( + await sendToHydra(msg.url, { + referer: msg.referer || null, + filename: hit ? mediaName(msg.url, tab?.title, hit.kind) : null, + }) + ); break; + } case "download-stream": { const id = await whichTab(); const entry = (id != null ? await tabStreams(id) : []).find((s) => s.key === msg.key); diff --git a/extensions/safari/manifest.json b/extensions/safari/manifest.json index c7439929..9ea6fb7b 100644 --- a/extensions/safari/manifest.json +++ b/extensions/safari/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Hydra Download Manager Integration", - "version": "0.3.1", + "version": "0.3.2", "description": "A browser integration for Hydra: right-click downloads, link selection, and media/HLS/DASH stream sniffing.", "browser_specific_settings": { "safari": { diff --git a/extensions/tests/capture.test.mjs b/extensions/tests/capture.test.mjs index 070a8090..39bfea44 100644 --- a/extensions/tests/capture.test.mjs +++ b/extensions/tests/capture.test.mjs @@ -59,7 +59,7 @@ function build({ hydraReply = { ok: true }, store = {} } = {}) { contextMenus: { removeAll: async () => {}, create: () => {}, onClicked: onMenu }, tabs: { query: async () => [{ id: 7, url: "https://page.example/watch" }], - get: async () => ({ id: 7, url: "https://page.example/watch" }), + get: async () => ({ id: 7, url: "https://page.example/watch", title: "Sunset Timelapse 4K" }), sendMessage: async () => ({ urls: ["https://a.example/1.zip", "https://b.example/2.zip"] }), create: () => {}, onRemoved: ev(), onUpdated: ev(), }, @@ -313,5 +313,53 @@ function build({ hydraReply = { ok: true }, store = {} } = {}) { } } +// ------------------------------------- 9. naming a captured media file +// +// A CDN that stores its objects under a uuid hands the user a row of +// gibberish to rename by hand; the page title is what IDM gives them and +// what they asked for. +{ + const uuid = "https://cdn.example/1b364999/fc1eced1-6d50-4375-a125-ef65c887d7d5.mp4"; + const h = build(); + await tick(4); + h.respond({ url: uuid, mime: "video/mp4", size: 109_566_922 }); + await tick(6); + await h.send({ type: "download-url", url: `${uuid}?e=1&s=abc`, referer: "https://page.example/watch" }); + await tick(2); + const dl = h.sent.find((m) => m.type === "download"); + check( + "naming: an opaque CDN name becomes the page title", + dl?.filename === "Sunset Timelapse 4K.mp4", + JSON.stringify(dl) + ); + check("naming: the referer travels with it", dl?.referer === "https://page.example/watch"); + + // A file that names itself keeps its name: a page of samples would + // otherwise put the same title on every one of them. + const h2 = build(); + await tick(4); + h2.respond({ url: "https://cdn.example/big_buck_bunny_1080p.mp4", mime: "video/mp4" }); + await tick(6); + await h2.send({ type: "download-url", url: "https://cdn.example/big_buck_bunny_1080p.mp4" }); + await tick(2); + check( + "naming: a file that names itself is left alone", + !h2.sent.find((m) => m.type === "download")?.filename, + JSON.stringify(h2.sent) + ); + + // An ordinary link is not media the page played, so the article's title + // has nothing to do with it. + const h3 = build(); + await tick(4); + await h3.send({ type: "download-url", url: "https://files.example/8675309.zip" }); + await tick(2); + check( + "naming: an unsniffed link keeps its own name", + !h3.sent.find((m) => m.type === "download")?.filename, + JSON.stringify(h3.sent) + ); +} + console.log(fails ? `\n${fails} FAILED` : "\nall passed"); process.exit(fails ? 1 : 0);