Merge branch 'main' into refactor/remove-unnecessary-usememo

This commit is contained in:
Chubby Granny Chaser
2025-10-21 21:11:37 +01:00
committed by GitHub
86 changed files with 4524 additions and 4589 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.0",
"version": "3.7.1",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -57,7 +57,6 @@
"crc": "^4.3.2",
"create-desktop-shortcuts": "^1.11.1",
"date-fns": "^3.6.0",
"dexie": "^4.0.10",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"embla-carousel-autoplay": "^8.6.0",

View File

@@ -357,7 +357,11 @@
"delete_review_modal_description": "This action cannot be undone.",
"delete_review_modal_delete_button": "Delete",
"delete_review_modal_cancel_button": "Cancel",
"vote_failed": "Failed to register your vote. Please try again."
"vote_failed": "Failed to register your vote. Please try again.",
"show_original": "Show original",
"show_translation": "Show translation",
"show_original_translated_from": "Show original (translated from {{language}})",
"hide_original": "Hide original"
},
"activation": {
"title": "Activate Hydra",
@@ -395,7 +399,6 @@
"stop_seeding": "Stop seeding",
"resume_seeding": "Resume seeding",
"options": "Manage",
"alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet",
"extract": "Extract files",
"extracting": "Extracting files…"
},
@@ -447,6 +450,7 @@
"found_download_option_one": "Found {{countFormatted}} download option",
"found_download_option_other": "Found {{countFormatted}} download options",
"import": "Import",
"importing": "Importing...",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
@@ -507,17 +511,6 @@
"create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet",
"create_torbox_account": "Click here if you don't have a TorBox account yet",
"real_debrid_account_linked": "Real-Debrid account linked",
"enable_all_debrid": "Enable All-Debrid",
"all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.",
"all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid",
"all_debrid_account_linked": "All-Debrid account linked successfully",
"alldebrid_missing_key": "Please provide an API key",
"alldebrid_invalid_key": "Invalid API key",
"alldebrid_blocked": "Your API key is geo-blocked or IP-blocked",
"alldebrid_banned": "This account has been banned",
"alldebrid_unknown_error": "An unknown error occurred",
"alldebrid_invalid_response": "Invalid response from All-Debrid",
"alldebrid_network_error": "Network error. Please check your connection",
"name_min_length": "Theme name must be at least 3 characters long",
"import_theme": "Import theme",
"import_theme_description": "You will import {{theme}} from the theme store",
@@ -598,6 +591,7 @@
"activity": "Recent Activity",
"library": "Library",
"pinned": "Pinned",
"sort_by": "Sort by:",
"achievements_earned": "Achievements earned",
"played_recently": "Played recently",
"playtime": "Playtime",

View File

@@ -376,6 +376,7 @@
"found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga",
"found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas",
"import": "Importar",
"importing": "Importando...",
"public": "Público",
"private": "Privado",
"friends_only": "Sólo amigos",

View File

@@ -0,0 +1,708 @@
{
"language_name": "Suomi",
"app": {
"successfully_signed_in": "Kirjautuminen onnistui"
},
"home": {
"surprise_me": "Yllätä minut",
"no_results": "Ei tuloksia",
"start_typing": "Aloitan kirjoittamisen...",
"hot": "Suosittua nyt",
"weekly": "📅 Viikon parhaat pelit",
"achievements": "🏆 Pelit saavutuksilla"
},
"sidebar": {
"catalogue": "Katalogi",
"downloads": "Lataukset",
"settings": "Asetukset",
"my_library": "Kirjasto",
"downloading_metadata": "{{title}} (Metatietojen lataus…)",
"paused": "{{title}} (Keskeytetty)",
"downloading": "{{title}} ({{percentage}} - Lataa…)",
"filter": "Hae",
"home": "Koti",
"queued": "{{title}} (Jonossa)",
"game_has_no_executable": "Pelin käynnistystiedostoa ei ole valittu",
"sign_in": "Kirjaudu sisään",
"friends": "Kaverit",
"need_help": "Tarvitsetko apua?",
"favorites": "Suosikit",
"playable_button_title": "Näytä vain asennetut pelit.",
"add_custom_game_tooltip": "Lisää mukautettu peli",
"show_playable_only_tooltip": "Näytä vain pelattavissa olevat",
"custom_game_modal": "Lisää mukautettu peli",
"custom_game_modal_description": "Lisää mukautettu peli kirjastoon valitsemalla suoritettava tiedosto",
"custom_game_modal_executable_path": "Suoritettavan tiedoston polku",
"custom_game_modal_select_executable": "Valitse suoritettava tiedosto",
"custom_game_modal_title": "Pelin nimi",
"custom_game_modal_enter_title": "Syötä pelin nimi",
"custom_game_modal_browse": "Selaa",
"custom_game_modal_cancel": "Peruuta",
"custom_game_modal_add": "Lisää peli",
"custom_game_modal_adding": "Lisätään peliä...",
"custom_game_modal_success": "Mukautettu peli lisätty onnistuneesti",
"custom_game_modal_failed": "Mukautetun pelin lisääminen epäonnistui",
"custom_game_modal_executable": "Suoritettava tiedosto",
"edit_game_modal": "Mukauta resursseja",
"edit_game_modal_description": "Mukauta pelin resursseja ja tietoja",
"edit_game_modal_title": "Nimi",
"edit_game_modal_enter_title": "Syötä nimi",
"edit_game_modal_image": "Kuva",
"edit_game_modal_select_image": "Valitse kuva",
"edit_game_modal_browse": "Selaa",
"edit_game_modal_image_preview": "Kuvan esikatselu",
"edit_game_modal_icon": "Kuvake",
"edit_game_modal_select_icon": "Valitse kuvake",
"edit_game_modal_icon_preview": "Kuvakkeen esikatselu",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Valitse logo",
"edit_game_modal_logo_preview": "Logon esikatselu",
"edit_game_modal_hero": "Pelin kansikuva",
"edit_game_modal_select_hero": "Valitse pelin kansikuva",
"edit_game_modal_hero_preview": "Kansikuvan esikatselu",
"edit_game_modal_cancel": "Peruuta",
"edit_game_modal_update": "Päivitä",
"edit_game_modal_updating": "Päivitetään...",
"edit_game_modal_fill_required": "Täytä kaikki pakolliset kentät",
"edit_game_modal_success": "Resurssit päivitetty onnistuneesti",
"edit_game_modal_failed": "Resurssien päivitys epäonnistui",
"edit_game_modal_image_filter": "Kuva",
"edit_game_modal_icon_resolution": "Suositeltu resoluutio: 256x256px",
"edit_game_modal_logo_resolution": "Suositeltu resoluutio: 640x360px",
"edit_game_modal_hero_resolution": "Suositeltu resoluutio: 1920x620px",
"edit_game_modal_assets": "Resurssit",
"edit_game_modal_drop_icon_image_here": "Pudota kuvakkeen kuva tähän",
"edit_game_modal_drop_logo_image_here": "Pudota logon kuva tähän",
"edit_game_modal_drop_hero_image_here": "Pudota kansikuvan kuva tähän",
"edit_game_modal_drop_to_replace_icon": "Pudota korvataksesi kuvake",
"edit_game_modal_drop_to_replace_logo": "Pudota korvataksesi logo",
"edit_game_modal_drop_to_replace_hero": "Pudota korvataksesi kansikuva",
"install_decky_plugin": "Asenna Decky-lisäosa",
"update_decky_plugin": "Päivitä Decky-lisäosa",
"decky_plugin_installed_version": "Decky-lisäosa (v{{version}})",
"install_decky_plugin_title": "Asenna Hydra Decky -lisäosa",
"install_decky_plugin_message": "Tämä lataa ja asentaa Hydra-lisäosan Decky Loaderiin. Saattaa vaatia korotetut oikeudet. Jatketaanko?",
"update_decky_plugin_title": "Päivitä Hydra Decky -lisäosa",
"update_decky_plugin_message": "Uusi Hydra Decky -lisäosan versio on saatavilla. Haluatko päivittää sen nyt?",
"decky_plugin_installed": "Decky-lisäosa v{{version}} asennettu onnistuneesti",
"decky_plugin_installation_failed": "Decky-lisäosan asennus epäonnistui: {{error}}",
"decky_plugin_installation_error": "Decky-lisäosan asennusvirhe: {{error}}",
"confirm": "Vahvista",
"cancel": "Peruuta"
},
"header": {
"search": "Hae",
"home": "Koti",
"catalogue": "Katalogi",
"downloads": "Lataukset",
"search_results": "Hakutulokset",
"settings": "Asetukset",
"version_available_install": "Versio {{version}} saatavilla. Asentaaksesi napsauta tästä.",
"version_available_download": "Versio {{version}} saatavilla. Ladataaksesi napsauta tästä."
},
"bottom_panel": {
"no_downloads_in_progress": "Ei meneillään olevia latauksia",
"downloading_metadata": "Ladataan metatietoja {{title}}…",
"downloading": "Ladataan {{title}}… ({{percentage}} valmis) - Lopetus {{eta}} - {{speed}}",
"calculating_eta": "Ladataan {{title}}… ({{percentage}} valmis) - Lasketaan jäljellä olevaa aikaa…",
"checking_files": "Tarkistetaan tiedostoja {{title}}… ({{percentage}} valmis)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Asennus valmis",
"installation_complete_message": "Kirjastot asennettu onnistuneesti"
},
"catalogue": {
"search": "Suodatin…",
"developers": "Kehittäjät",
"genres": "Genret",
"tags": "Tagit",
"publishers": "Julkaisijat",
"download_sources": "Latauslähteet",
"result_count": "{{resultCount}} tulosta",
"filter_count": "{{filterCount}} saatavilla",
"clear_filters": "Tyhjennä {{filterCount}} valittua"
},
"game_details": {
"open_download_options": "Avaa lähteet",
"download_options_zero": "Ei lähteitä",
"download_options_one": "{{count}} lähde",
"download_options_other": "{{count}} lähdettä",
"updated_at": "Päivitetty {{updated_at}}",
"install": "Asenna",
"resume": "Jatka",
"pause": "Keskeytä",
"cancel": "Peruuta",
"remove": "Poista",
"space_left_on_disk": "{{space}} vapaana levyltä",
"eta": "Lopetus {{eta}}",
"calculating_eta": "Lasketaan jäljellä olevaa aikaa…",
"downloading_metadata": "Ladataan metatietoja…",
"filter": "Hae repackeja",
"requirements": "Järjestelmävaatimukset",
"minimum": "Minimi",
"recommended": "Suositeltu",
"paused": "Keskeytetty",
"release_date": "Julkaistu {{date}}",
"publisher": "Julkaisija {{publisher}}",
"hours": "tuntia",
"minutes": "minuuttia",
"amount_hours": "{{amount}} tuntia",
"amount_minutes": "{{amount}} minuuttia",
"accuracy": "tarkkuus {{accuracy}}%",
"add_to_library": "Lisää kirjastoon",
"already_in_library": "Jo kirjastossa",
"remove_from_library": "Poista kirjastosta",
"no_downloads": "Ei saatavilla olevia lähteitä",
"play_time": "Pelattu {{amount}}",
"last_time_played": "Viimeksi pelattu {{period}}",
"not_played_yet": "Et ole vielä pelannut {{title}}",
"next_suggestion": "Seuraava ehdotus",
"play": "Pelaa",
"deleting": "Poistetaan asennustiedostoa…",
"close": "Sulje",
"playing_now": "Käynnissä",
"change": "Vaihda",
"repacks_modal_description": "Valitse repack ladattavaksi",
"select_folder_hint": "Vaihtaaksesi oletuslatauskansiota, avaa <0>Asetukset</0>",
"download_now": "Lataa nyt",
"no_shop_details": "Kuvausta ei saatu",
"download_options": "Lähteet",
"download_path": "Latauspolku",
"previous_screenshot": "Edellinen kuvakaappaus",
"next_screenshot": "Seuraava kuvakaappaus",
"screenshot": "Kuvakaappaus {{number}}",
"open_screenshot": "Avaa kuvakaappaus {{number}}",
"download_settings": "Latausasetukset",
"downloader": "Lataaja",
"select_executable": "Valitse",
"no_executable_selected": "Tiedostoa ei valittu",
"open_folder": "Avaa kansio",
"open_download_location": "Selaa latauskansio",
"create_shortcut": "Luo työpöydän pikakuvake",
"create_shortcut_simple": "Luo pikakuvake",
"clear": "Tyhjennä",
"remove_files": "Poista tiedostot",
"remove_from_library_title": "Oletko varma?",
"remove_from_library_description": "{{game}} poistetaan kirjastostasi.",
"options": "Asetukset",
"properties": "Ominaisuudet",
"executable_section_title": "Tiedosto",
"executable_section_description": "Polku tiedostoon, joka käynnistetään kun painat \"Pelaa\"",
"downloads_section_title": "Lataukset",
"downloads_section_description": "Tarkista päivitysten tai muiden peliversioiden saatavuus",
"danger_zone_section_title": "Vaaravyöhyke",
"danger_zone_section_description": "Voit poistaa tämän pelin kirjastostasi tai Hydrasta ladatut tiedostot",
"download_in_progress": "Lataus käynnissä",
"download_paused": "Lataus keskeytetty",
"last_downloaded_option": "Viimeisin latausvaihtoehto",
"create_steam_shortcut": "Luo Steam-pikakuvake",
"create_shortcut_success": "Pikakuvake luotu",
"you_might_need_to_restart_steam": "Saattaa olla, että sinun on käynnistettävä Steam uudelleen nähdäksesi muutokset",
"create_shortcut_error": "Pikakuvakkeen luonti epäonnistui",
"add_to_favorites": "Lisää suosikkeihin",
"remove_from_favorites": "Poista suosikeista",
"failed_update_favorites": "Suosikkien päivitys epäonnistui",
"game_removed_from_library": "Peli poistettu kirjastosta",
"failed_remove_from_library": "Poistaminen kirjastosta epäonnistui",
"files_removed_success": "Tiedostot poistettu onnistuneesti",
"failed_remove_files": "Tiedostojen poisto epäonnistui",
"nsfw_content_title": "Tämä peli sisältää sopimatonta sisältöä",
"nsfw_content_description": "{{title}} sisältää sisältöä, joka ei välttämättä sovellu kaikenikäisille. \nOletko varma, että haluat jatkaa?",
"allow_nsfw_content": "Jatka",
"refuse_nsfw_content": "Takaisin",
"stats": "Tilastot",
"download_count": "Lataukset",
"player_count": "Aktiiviset pelaajat",
"download_error": "Tämä latausvaihtoehto ei ole saatavilla",
"download": "Lataa",
"executable_path_in_use": "Suoritettavaa tiedostoa käyttää jo \"{{game}}\"",
"warning": "Varoitus:",
"hydra_needs_to_remain_open": "Tämän latauksen aikana Hydran on pysyttävä auki, kunnes se on valmis. Jos Hydra sulkeutuu ennen valmistumista, menetät edistymisen.",
"achievements": "Saavutukset",
"achievements_count": "Saavutukset {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Näytä enemmän",
"show_less": "Näytä vähemmän",
"reviews": "Arvostelut",
"leave_a_review": "Jätä arvostelu",
"write_review_placeholder": "Jaa ajatuksesi tästä pelistä...",
"sort_newest": "Uusimmat ensin",
"no_reviews_yet": "Ei vielä arvosteluja",
"be_first_to_review": "Ole ensimmäinen, joka jakaa ajatuksensa tästä pelistä!",
"sort_oldest": "Vanhimmat ensin",
"sort_highest_score": "Korkein pistemäärä",
"sort_lowest_score": "Matalin pistemäärä",
"sort_most_voted": "Eniten äänestetyt",
"rating": "Arvio",
"rating_stats": "Arvio",
"rating_very_negative": "Erittäin negatiivinen",
"rating_negative": "Negatiivinen",
"rating_neutral": "Neutraali",
"rating_positive": "Positiivinen",
"rating_very_positive": "Erittäin positiivinen",
"submit_review": "Lähetä",
"submitting": "Lähetetään...",
"review_submitted_successfully": "Arvostelu lähetetty onnistuneesti!",
"review_submission_failed": "Arvostelun lähettäminen epäonnistui. Yritä uudelleen.",
"review_cannot_be_empty": "Arvostelun tekstikenttä ei voi olla tyhjä.",
"review_deleted_successfully": "Arvostelu poistettu onnistuneesti.",
"review_deletion_failed": "Arvostelun poisto epäonnistui. Yritä uudelleen.",
"loading_reviews": "Ladataan arvosteluja...",
"loading_more_reviews": "Ladataan lisää arvosteluja...",
"load_more_reviews": "Lataa lisää arvosteluja",
"you_seemed_to_enjoy_this_game": "Näyttää siltä, että nautit tästä pelistä",
"would_you_recommend_this_game": "Haluatko jättää arvion tästä pelistä?",
"yes": "Kyllä",
"maybe_later": "Ehkä myöhemmin",
"rating_count": "Arvio",
"delete_review": "Poista arvostelu",
"remove_review": "Poista arvostelu",
"delete_review_modal_title": "Haluatko varmasti poistaa arvostelusi?",
"delete_review_modal_description": "Tätä toimintoa ei voi peruuttaa.",
"delete_review_modal_delete_button": "Poista",
"delete_review_modal_cancel_button": "Peruuta",
"show_original": "Näytä alkuperäinen",
"show_translation": "Näytä käännös",
"show_original_translated_from": "Näytä alkuperäinen (käännös kielestä {{language}})",
"hide_original": "Piilota alkuperäinen",
"cloud_save": "Pilvitallennus",
"cloud_save_description": "Tallenna edistymisesi pilveen ja jatka pelaamista millä tahansa laitteella",
"backups": "Varmuuskopiot",
"install_backup": "Asenna",
"delete_backup": "Poista",
"create_backup": "Luo uusi varmuuskopio",
"last_backup_date": "Viimeisin varmuuskopio {{date}}",
"no_backup_preview": "Tallennuksia ei löytynyt tälle otsikolle",
"restoring_backup": "Palautetaan varmuuskopiota ({{progress}} valmis)…",
"uploading_backup": "Ladataan varmuuskopiota…",
"no_backups": "Et ole vielä luonut varmuuskopioita tästä pelistä",
"backup_uploaded": "Varmuuskopio ladattu",
"backup_failed": "Varmuuskopiointi epäonnistui",
"backup_deleted": "Varmuuskopio poistettu",
"backup_restored": "Varmuuskopio palautettu",
"see_all_achievements": "Näytä kaikki saavutukset",
"sign_in_to_see_achievements": "Kirjaudu sisään nähdäksesi saavutukset",
"mapping_method_automatic": "Automaattinen",
"mapping_method_manual": "Manuaalinen",
"mapping_method_label": "Kartoitusmenetelmä",
"files_automatically_mapped": "Tiedostot kartoitetu automaattisesti",
"no_backups_created": "Tälle pelille ei ole luotu varmuuskopioita",
"manage_files": "Hallitse tiedostoja",
"loading_save_preview": "Etsitään tallennuksia…",
"wine_prefix": "Wine-etuliite",
"wine_prefix_description": "Tässä pelissä käytettävä Wine-etuliite",
"launch_options": "Käynnistysvalinnat",
"launch_options_description": "Edistyneet käyttäjät voivat tehdä muutoksia käynnistysvalintoihin",
"launch_options_placeholder": "Valintaa ei määritetty",
"no_download_option_info": "Tietoja ei saatavilla",
"backup_deletion_failed": "Varmuuskopion poisto epäonnistui",
"max_number_of_artifacts_reached": "Tämän pelin enimmäismäärä varmuuskopioita saavutettu",
"achievements_not_sync": "Saavutuksesi eivät ole synkronoidut",
"manage_files_description": "Hallitse tallennettavia ja palautettavia tiedostoja",
"select_folder": "Valitse kansio",
"backup_from": "Varmuuskopio {{date}}",
"automatic_backup_from": "Automaattinen varmuuskopio {{date}}",
"enable_automatic_cloud_sync": "Ota automaattinen pilvisynkronointi käyttöön",
"custom_backup_location_set": "Mukautettu varmuuskopiosijainti asetettu",
"no_directory_selected": "Hakemistoa ei valittu",
"no_write_permission": "Ei voi ladata tähän hakemistoon. Napsauta tästä saadaksesi lisätietoja.",
"reset_achievements": "Nollaa saavutukset",
"reset_achievements_description": "Tämä nollaa kaikki saavutukset pelille {{game}}",
"reset_achievements_title": "Oletko varma?",
"reset_achievements_success": "Saavutukset nollattu onnistuneesti",
"reset_achievements_error": "Saavutusten nollaus epäonnistui",
"download_error_gofile_quota_exceeded": "Olet ylittänyt Gofilen kuukausikiintiön. Odota, kunnes kiintiö palautuu.",
"download_error_real_debrid_account_not_authorized": "Real-Debrid -tilisi ei ole valtuutettu suorittamaan uusia latauksia. Tarkista tilin asetukset ja yritä uudelleen.",
"download_error_not_cached_on_real_debrid": "Tämä lataus ei ole saatavilla Real-Debridissä, eikä lataustilan hakeminen Real-Debridistä ole toistaiseksi mahdollista.",
"update_playtime_title": "Päivitä peliaika",
"update_playtime_description": "Päivitä pelin {{game}} peliaika manuaalisesti",
"update_playtime": "Päivitä peliaika",
"update_playtime_success": "Peliaika päivitetty onnistuneesti",
"update_playtime_error": "Peliajan päivitys epäonnistui",
"update_game_playtime": "Päivitä peliaika",
"manual_playtime_warning": "Pelituntisi merkitään manuaalisesti päivitetyiksi. Tätä toimintoa ei voi peruuttaa.",
"manual_playtime_tooltip": "Tämä peliaika on päivitetty manuaalisesti",
"download_error_not_cached_on_torbox": "Tämä lataus ei ole saatavilla TorBoxissa, eikä lataustilan hakeminen TorBoxista ole toistaiseksi mahdollista.",
"download_error_not_cached_on_hydra": "Tämä lataus ei ole saatavilla Nimbuksessa.",
"game_removed_from_favorites": "Peli poistettu suosikeista",
"game_added_to_favorites": "Peli lisätty suosikkeihin",
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"automatically_extract_downloaded_files": "Pura ladatut tiedostot automaattisesti",
"create_start_menu_shortcut": "Luo Käynnistä-valikon pikakuvake",
"invalid_wine_prefix_path": "Virheellinen Wine-etuliitteen polku",
"invalid_wine_prefix_path_description": "Wine-etuliitteen polku on virheellinen. Tarkista polku ja yritä uudelleen.",
"missing_wine_prefix": "Wine-etuliite vaaditaan varmuuskopiointiin Linuxissa",
"artifact_renamed": "Varmuuskopio nimettiin uudelleen onnistuneesti",
"rename_artifact": "Nimeä varmuuskopio uudelleen",
"rename_artifact_description": "Anna varmuuskopiolle kuvaavampi nimi.",
"artifact_name_label": "Varmuuskopion nimi",
"artifact_name_placeholder": "Syötä nimi varmuuskopiolle",
"save_changes": "Tallenna muutokset",
"required_field": "Tämä kenttä on pakollinen",
"max_length_field": "Tämän kentän on oltava alle {{length}} merkkiä",
"freeze_backup": "Kiinnitä, jotta sitä ei ylikirjoiteta automaattisilla varmuuskopioilla",
"unfreeze_backup": "Poista kiinnitys",
"backup_frozen": "Varmuuskopio kiinnitetty",
"backup_unfrozen": "Varmuuskopion kiinnitys poistettu",
"backup_freeze_failed": "Varmuuskopion kiinnitys epäonnistui",
"backup_freeze_failed_description": "Sinun on jätettävä vähintään yksi paikka vapaaksi automaattisille varmuuskopioille",
"edit_game_modal_button": "Muokkaa pelin tietoja",
"game_details": "Pelin tiedot",
"currency_symbol": "€",
"currency_country": "fi",
"prices": "Hinnat",
"no_prices_found": "Hintoja ei löytynyt",
"view_all_prices": "Napsauta nähdäksesi kaikki hinnat",
"retail_price": "Vähittäishinta",
"keyshop_price": "Keyshop-hinta",
"historical_retail": "Historialliset vähittäishinnat",
"historical_keyshop": "Historialliset keyshop-hinnat",
"language": "Kieli",
"caption": "Tekstitys",
"audio": "Ääni",
"filter_by_source": "Suodata lähteen mukaan",
"no_repacks_found": "Tämän pelin lähteitä ei löytynyt"
},
"activation": {
"title": "Aktivoi Hydra",
"installation_id": "Asennustunnus:",
"enter_activation_code": "Syötä aktivointikoodisi",
"message": "Jos et tiedä mistä sitä pyytää, sinun ei pitäisi sitä olla.",
"activate": "Aktivoi",
"loading": "Ladataan…"
},
"downloads": {
"resume": "Jatka",
"pause": "Keskeytä",
"eta": "Lopetus {{eta}}",
"paused": "Keskeytetty",
"verifying": "Tarkistetaan…",
"completed": "Valmis",
"removed": "Ei ladattu",
"cancel": "Peruuta",
"filter": "Hae ladattuja pelejä",
"remove": "Poista",
"downloading_metadata": "Ladataan metatietoja…",
"deleting": "Poistetaan asennustiedostoa…",
"delete": "Poista asennustiedosto",
"delete_modal_title": "Oletko varma?",
"delete_modal_description": "Tämä poistaa kaikki asennustiedostot tietokoneeltasi",
"install": "Asenna",
"download_in_progress": "Käynnissä",
"queued_downloads": "Jonossa olevat lataukset",
"downloads_completed": "Valmiit",
"queued": "Jonossa",
"no_downloads_title": "Täällä on niin tyhjää...",
"no_downloads_description": "Et ole vielä ladannut mitään Hydran kautta, mutta ei ole koskaan liian myöhäistä aloittaa.",
"checking_files": "Tarkistetaan tiedostoja…",
"seeding": "Jakaminen",
"stop_seeding": "Lopeta jakaminen",
"resume_seeding": "Jatka jakamista",
"options": "Hallinnoi",
"extract": "Pura tiedostot",
"extracting": "Puretaan tiedostoja…"
},
"settings": {
"downloads_path": "Latausten polku",
"change": "Vaihda",
"notifications": "Ilmoitukset",
"enable_download_notifications": "Latauksen valmistuessa",
"enable_repack_list_notifications": "Kun uusi repack lisätään",
"real_debrid_api_token_label": "Real-Debrid API-tunnus",
"quit_app_instead_hiding": "Sovellus sulkeutuu system tray -alueelle sijasta",
"launch_with_system": "Käynnistä Hydra järjestelmän mukana",
"general": "Yleiset",
"behavior": "Käyttäytyminen",
"download_sources": "Latauslähteet",
"language": "Kieli",
"api_token": "API-avain",
"enable_real_debrid": "Ota Real-Debrid käyttöön",
"real_debrid_description": "Real-Debrid on rajoittamaton lataaja, jonka avulla voit ladata nopeasti verkossa olevia tiedostoja tai striimata ne välittömästi soittimeen yksityisen verkon kautta, joka kiertää kaikki estot.",
"debrid_invalid_token": "Virheellinen API-avain",
"debrid_api_token_hint": "API-avain voidaan hankkia <0>täältä</0>",
"real_debrid_free_account_error": "Tili \"{{username}}\" - ei ole tilaus. Ota Real-Debrid-tilaus",
"debrid_linked_message": "Tili \"{{username}}\" linkitetty",
"save_changes": "Tallenna muutokset",
"changes_saved": "Muutokset tallennettu onnistuneesti",
"download_sources_description": "Hydra hakee latauslinkit näistä lähteistä. URL-osoitteen on sisällettävä suora linkki .json-tiedostoon, joka sisältää latauslinkit.",
"validate_download_source": "Vahvista",
"remove_download_source": "Poista",
"add_download_source": "Lisää lähde",
"download_count_zero": "Ei latauksia listassa",
"download_count_one": "{{countFormatted}} lataus listassa",
"download_count_other": "{{countFormatted}} latausta listassa",
"download_source_url": "Lähteen URL-osoite",
"add_download_source_description": "Liitä linkki .json-tiedostoon",
"download_source_up_to_date": "Ajan tasalla",
"download_source_errored": "Virhe",
"sync_download_sources": "Päivitä lähteet",
"removed_download_source": "Lähde poistettu",
"removed_download_sources": "Lähteet poistettu",
"cancel_button_confirmation_delete_all_sources": "Ei",
"confirm_button_confirmation_delete_all_sources": "Kyllä, poista kaikki",
"title_confirmation_delete_all_sources": "Poista kaikki lähteet",
"description_confirmation_delete_all_sources": "Poistat kaikki lähteet",
"button_delete_all_sources": "Poista kaikki lähteet",
"added_download_source": "Lähde lisätty",
"download_sources_synced": "Kaikki lähteet päivitetty",
"insert_valid_json_url": "Liitä kelvollinen JSON-tiedoston URL-osoite",
"found_download_option_zero": "Ei latausvaihtoehtoja löytynyt",
"found_download_option_one": "Löytyi {{countFormatted}} latausvaihtoehto",
"found_download_option_other": "Löytyi {{countFormatted}} latausvaihtoehtoa",
"import": "Tuo",
"importing": "Tuodaan...",
"public": "Julkinen",
"private": "Yksityinen",
"friends_only": "Vain kavereille",
"privacy": "Yksityisyys",
"profile_visibility": "Profiilin näkyvyys",
"profile_visibility_description": "Valitse, kuka voi nähdä profiilisi ja kirjastosi",
"required_field": "Tämä kenttä on pakollinen",
"source_already_exists": "Tämä lähde on jo lisätty",
"must_be_valid_url": "Lähteen on oltava kelvollinen URL-osoite",
"blocked_users": "Estetyt käyttäjät",
"user_unblocked": "Käyttäjä estäminen poistettu",
"enable_achievement_notifications": "Kun saavutus avataan",
"launch_minimized": "Käynnistä Hydra pienennettynä",
"disable_nsfw_alert": "Poista sopimattoman sisällön varoitus käytöstä",
"seed_after_download_complete": "Jaa latauksen valmistumisen jälkeen",
"show_hidden_achievement_description": "Näytä piilotettujen saavutusten kuvaukset ennen niiden ansaitsemista",
"account": "Tili",
"no_users_blocked": "Sinulla ei ole estettyjä käyttäjiä",
"subscription_active_until": "Hydra Cloud -tilisi on voimassa {{date}} asti",
"manage_subscription": "Hallinnoi tilausta",
"update_email": "Päivitä sähköposti",
"update_password": "Päivitä salasana",
"current_email": "Nykyinen sähköposti:",
"no_email_account": "Et ole vielä asettanut sähköpostiosoitetta",
"account_data_updated_successfully": "Tilitiedot päivitetty onnistuneesti",
"renew_subscription": "Uusi Hydra Cloud -tilaus",
"subscription_expired_at": "Tilauksesi vanheni {{date}}",
"no_subscription": "Nauti Hydrasta täysin rinnoin",
"become_subscriber": "Tule Hydra Cloud -tilaajaksi",
"subscription_renew_cancelled": "Automaattinen uusinta peruutettu",
"subscription_renews_on": "Tilauksesi uusiutuu {{date}}",
"bill_sent_until": "Seuraava laskusi lähetetään ennen tätä päivää",
"no_themes": "Näyttää siltä, että sinulla ei vielä ole teemoja, mutta älä huoli, napsauta tästä luodaksesi ensimmäisen mestariteoksesi",
"editor_tab_code": "Koodi",
"editor_tab_info": "Tiedot",
"editor_tab_save": "Tallenna",
"web_store": "Verkkokauppa",
"clear_themes": "Tyhjennä",
"create_theme": "Luo",
"create_theme_modal_title": "Luo mukautettu teema",
"create_theme_modal_description": "Luo uusi teema Hydran ulkoasun mukauttamiseksi",
"theme_name": "Nimi",
"insert_theme_name": "Syötä teeman nimi",
"set_theme": "Aseta teema",
"unset_theme": "Poista teema",
"delete_theme": "Poista teema",
"edit_theme": "Muokkaa teemaa",
"delete_all_themes": "Poista kaikki teemat",
"delete_all_themes_description": "Tämä poistaa kaikki mukautetut teemasi",
"delete_theme_description": "Tämä poistaa teeman {{theme}}",
"cancel": "Peruuta",
"appearance": "Ulkoasu",
"debrid": "Debrid",
"debrid_description": "Debrid-palvelut ovat premium-lataajia ilman rajoituksia, joiden avulla voit ladata tiedostoja nopeasti useista tiedostonjakopalveluista, vain internet-yhteytesi nopeuden rajoittamina.",
"enable_torbox": "Ota TorBox käyttöön",
"torbox_description": "TorBox on premium-palvelusi, joka kilpailee jopa parhaimpien markkinoiden palvelimien kanssa.",
"torbox_account_linked": "TorBox-tili linkitetty",
"create_real_debrid_account": "Napsauta tästä, jos sinulla ei vielä ole Real-Debrid-tiliä",
"create_torbox_account": "Napsauta tästä, jos sinulla ei vielä ole TorBox-tiliä",
"real_debrid_account_linked": "Real-Debrid-tili linkitetty",
"name_min_length": "Teeman nimen on oltava vähintään 3 merkkiä",
"import_theme": "Tuo teema",
"import_theme_description": "Tuot teeman {{theme}} teemakaupasta",
"error_importing_theme": "Virhe teemaa tuotaessa",
"theme_imported": "Teema tuotu onnistuneesti",
"enable_friend_request_notifications": "Kun kaveripyyntö vastaanotetaan",
"enable_auto_install": "Lataa päivitykset automaattisesti",
"common_redist": "Kirjastot",
"common_redist_description": "Joidenkin pelien käyttö vaatii kirjastoja. Ongelmien välttämiseksi on suositeltavaa asentaa ne.",
"install_common_redist": "Asenna",
"installing_common_redist": "Asennetaan…",
"show_download_speed_in_megabytes": "Näytä latausnopeus megatavuina sekunnissa",
"extract_files_by_default": "Pura tiedostot oletusarvoisesti latauksen jälkeen",
"enable_steam_achievements": "Ota Steam-saavutusten haku käyttöön",
"achievement_custom_notification_position": "Saavutusilmoitusten sijainti",
"top-left": "Vasemmalla ylhäällä",
"top-center": "Yläkeskellä",
"top-right": "Oikealla ylhäällä",
"bottom-left": "Vasemmalla alhaalla",
"bottom-center": "Alakeskellä",
"bottom-right": "Oikealla alhaalla",
"enable_achievement_custom_notifications": "Ota saavutusilmoitukset käyttöön",
"alignment": "Tasaus",
"variation": "Muunnelma",
"default": "Oletus",
"rare": "Harvinainen",
"platinum": "Platina",
"hidden": "Piilotettu",
"test_notification": "Testi-ilmoitus",
"notification_preview": "Saavutusilmoituksen esikatselu",
"enable_friend_start_game_notifications": "Kun kaveri aloittaa pelin pelaamisen"
},
"notifications": {
"download_complete": "Lataus valmis",
"game_ready_to_install": "{{title}} valmis asennettavaksi",
"repack_list_updated": "Repack-lista päivitetty",
"repack_count_one": "{{count}} repack lisätty",
"repack_count_other": "{{count}} repackia lisätty",
"new_update_available": "Uusi versio {{version}} saatavilla",
"restart_to_install_update": "Käynnistä Hydra uudelleen asentaaksesi päivityksen",
"notification_achievement_unlocked_title": "Saavutus avattu pelille {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} ja muut {{count}} avattiin",
"new_friend_request_description": "{{displayName}} lähetti sinulle kaveripyynnön",
"new_friend_request_title": "Uusi kaveripyyntö",
"extraction_complete": "Purkaminen valmis",
"game_extracted": "{{title}} purettu onnistuneesti",
"friend_started_playing_game": "{{displayName}} aloitti pelin pelaamisen",
"test_achievement_notification_title": "Tämä on testi-ilmoitus",
"test_achievement_notification_description": "Aika siistiä, eikö?"
},
"system_tray": {
"open": "Avaa Hydra",
"quit": "Lopeta"
},
"game_card": {
"available_one": "Saatavilla",
"available_other": "Saatavilla",
"no_downloads": "Ei saatavilla olevia lähteitä",
"calculating": "Lasketaan"
},
"binary_not_found_modal": {
"title": "Ohjelmia ei asennettu",
"description": "Wine tai Lutris ei löytynyt",
"instructions": "Opi oikea tapa asentaa kumpi tahansa Linux-jakelullesi, jotta peli toimii kunnolla"
},
"modal": {
"close": "Sulje"
},
"forms": {
"toggle_password_visibility": "Näytä salasana"
},
"user_profile": {
"amount_hours": "{{amount}} tuntia",
"amount_minutes": "{{amount}} minuuttia",
"amount_hours_short": "{{amount}}t",
"amount_minutes_short": "{{amount}}min",
"last_time_played": "Viimeisin peli {{period}}",
"activity": "Viimeisin toiminta",
"library": "Kirjasto",
"pinned": "Kiinnitetyt",
"achievements_earned": "Ansaittu saavutukset",
"played_recently": "Äskettäin pelatut",
"playtime": "Peliaika",
"total_play_time": "Yhteensä pelattu",
"manual_playtime_tooltip": "Peliaika on päivitetty manuaalisesti",
"no_recent_activity_title": "Hmm... Täällä ei ole mitään",
"no_recent_activity_description": "Et ole pelannut mitään vähään aikaan. On aika muuttaa se!",
"display_name": "Näyttönimi",
"saving": "Tallennetaan",
"save": "Tallenna",
"edit_profile": "Muokkaa profiilia",
"saved_successfully": "Tallennettu onnistuneesti",
"try_again": "Yritä uudelleen",
"sign_out_modal_title": "Oletko varma?",
"cancel": "Peruuta",
"successfully_signed_out": "Kirjauduttu ulos onnistuneesti",
"sign_out": "Kirjaudu ulos",
"playing_for": "Pelattu {{amount}}",
"sign_out_modal_text": "Kirjastosi on linkitetty nykyiseen tiliisi. Kirjautumalla ulos kirjastosi ei ole käytettävissä, eikä edistymistä tallenneta. Kirjaudu ulos?",
"add_friends": "Lisää kavereita",
"add": "Lisää",
"friend_code": "Kaverikoodi",
"see_profile": "Näytä profiili",
"sending": "Lähetetään",
"friend_request_sent": "Kaveripyyntö lähetetty",
"friends": "Kaverit",
"friends_list": "Kaverilista",
"user_not_found": "Käyttäjää ei löytynyt",
"block_user": "Estä käyttäjä",
"add_friend": "Lisää kaveriksi",
"request_sent": "Pyyntö lähetetty",
"request_received": "Pyyntö vastaanotettu",
"accept_request": "Hyväksy pyyntö",
"ignore_request": "Ohita pyyntö",
"cancel_request": "Peruuta pyyntö",
"undo_friendship": "Poista kaveri",
"request_accepted": "Pyyntö hyväksytty",
"user_blocked_successfully": "Käyttäjä estetty onnistuneesti",
"user_block_modal_text": "{{displayName}} estetään",
"blocked_users": "Estetyt käyttäjät",
"unblock": "Poista esto",
"no_friends_added": "Et ole vielä lisännyt yhtään kaveria",
"pending": "Odottaa",
"no_pending_invites": "Sinulla ei ole vasteita odottavia pyyntöjä",
"no_blocked_users": "Et ole estänyt yhtään käyttäjää",
"friend_code_copied": "Kaverikoodi kopioitu",
"undo_friendship_modal_text": "Tämä purkaa kaverisuhteen käyttäjän {{displayName}} kanssa.",
"privacy_hint": "Määrittääksesi kuka voi nähdä tämän, siirry <0>Asetuksiin</0>.",
"locked_profile": "Tämä profiili on yksityinen",
"image_process_failure": "Kuvan käsittely epäonnistui",
"required_field": "Tämä kenttä on pakollinen",
"displayname_min_length": "Näyttönimen on oltava vähintään 3 merkkiä.",
"displayname_max_length": "Näyttönimen on oltava enintään 50 merkkiä.",
"report_profile": "Ilmianna tämä profiili",
"report_reason": "Miksi ilmiannat tämän profiilin?",
"report_description": "Lisätietoja",
"report_description_placeholder": "Lisätietoja",
"report": "Ilmianna",
"report_reason_hate": "Vihapuhe",
"report_reason_sexual_content": "Seksuaalinen sisältö",
"report_reason_violence": "Väkivalta",
"report_reason_spam": "Roskaposti",
"report_reason_other": "Muu",
"profile_reported": "Profiili-ilmoitus lähetetty",
"your_friend_code": "Kaverikoodisi:",
"upload_banner": "Lataa banneri",
"uploading_banner": "Ladataan banneria...",
"background_image_updated": "Taustakuva päivitetty",
"stats": "Tilastot",
"achievements": "Saavutukset",
"games": "Pelit",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "Sijoitus päivitetään viikoittain",
"playing": "Pelaamassa {{game}}",
"achievements_unlocked": "Saavutukset avattu",
"earned_points": "Ansaitut pisteet:",
"show_achievements_on_profile": "Näytä saavutuksesi profiilissasi",
"show_points_on_profile": "Näytä ansaitut pisteet profiilissasi",
"error_adding_friend": "Kaveripyynnön lähettäminen epäonnistui. Tarkista kaverikoodi",
"friend_code_length_error": "Kaverikoodin on oltava 8 merkkiä",
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"karma": "Karma",
"karma_count": "karmaa",
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
},
"achievement": {
"achievement_unlocked": "Saavutus avattu",
"user_achievements": "Käyttäjän {{displayName}} saavutukset",
"your_achievements": "Sinun saavutuksesi",
"unlocked_at": "Avattu: {{date}}",
"subscription_needed": "Hydra Cloud -tilaus tarvitaan tämän sisällön katsomiseen",
"new_achievements_unlocked": "{{achievementCount}} uutta saavutusta avattu {{gameCount}} pelistä",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} saavutusta",
"achievements_unlocked_for_game": "{{achievementCount}} uutta saavutusta avattu pelille {{gameTitle}}",
"hidden_achievement_tooltip": "Tämä on piilotettu saavutus",
"achievement_earn_points": "Ansaitse {{points}} pistettä tällä saavutuksella",
"earned_points": "Ansaitut pisteet:",
"available_points": "Saatavilla olevat pisteet:",
"how_to_earn_achievements_points": "Kuinka ansaita saavutuspisteitä?"
},
"hydra_cloud": {
"subscription_tour_title": "Hydra Cloud -tilaus",
"subscribe_now": "Tilaa nyt",
"cloud_saving": "Pilvitallennus",
"cloud_achievements": "Tallenna saavutuksesi pilveen",
"animated_profile_picture": "Animaoidut profiilikuvat",
"premium_support": "Premium-tuki",
"show_and_compare_achievements": "Näytä ja vertaile saavutuksiasi muiden käyttäjien saavutuksiin",
"animated_profile_banner": "Animoitu profiilin banneri",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "Olet juuri löytänyt Hydra Cloud -toiminnon!",
"learn_more": "Lue lisää",
"debrid_description": "Lataa 4 kertaa nopeammin Nimbuksella"
}
}

View File

@@ -394,7 +394,6 @@
"stop_seeding": "Seedelés leállítása",
"resume_seeding": "Seedelés folytatása",
"options": "Kezelés",
"alldebrid_size_not_supported": "Letöltési információ az AllDebrid-hez még nem támogatott",
"extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…"
},
@@ -506,17 +505,6 @@
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod",
"create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva",
"enable_all_debrid": "All-Debrid bekapcsolása",
"all_debrid_description": "Az All-Debrid egy korlátozásmentes letöltőprogram, ami lehetővé teszi a fájlok gyors letöltését különböző forrásokból.",
"all_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel az All-Debridre",
"all_debrid_account_linked": "All-Debrid fiók összekapcsolva",
"alldebrid_missing_key": "Kérlek adj meg egy API key-t",
"alldebrid_invalid_key": "Érvénytelen API key",
"alldebrid_blocked": "Az API key-ed Földrajzilag vagy IP-alapján van blokkolva",
"alldebrid_banned": "Ez a fiók kitiltásra került",
"alldebrid_unknown_error": "Egy ismeretlen hiba történt",
"alldebrid_invalid_response": "Érvénytelen válasz az All-Debrid felől",
"alldebrid_network_error": "Hálózati hiba. Ellenőrízd az internetkapcsolatod",
"name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
"import_theme": "Téma importálása",
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}",

View File

@@ -26,6 +26,7 @@ import nb from "./nb/translation.json";
import et from "./et/translation.json";
import bg from "./bg/translation.json";
import uz from "./uz/translation.json";
import fi from "./fi/translation.json";
import sv from "./sv/translation.json";
export default {
@@ -49,6 +50,7 @@ export default {
da,
ar,
fa,
fi,
ro,
ca,
bg,

View File

@@ -345,6 +345,10 @@
"delete_review_modal_description": "Esta ação não pode ser desfeita.",
"delete_review_modal_delete_button": "Excluir",
"delete_review_modal_cancel_button": "Cancelar",
"show_original": "Mostrar original",
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
"rating_count": "Avaliação"
},
"activation": {
@@ -383,7 +387,6 @@
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Gerenciar",
"alldebrid_size_not_supported": "Informações de download para AllDebrid ainda não são suportadas",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…"
},
@@ -435,6 +438,7 @@
"found_download_option_one": "{{countFormatted}} opção de download encontrada",
"found_download_option_other": "{{countFormatted}} opções de download encontradas",
"import": "Importar",
"importing": "Importando...",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
@@ -495,17 +499,6 @@
"create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid",
"create_torbox_account": "Clique aqui se você ainda não tem uma conta do TorBox",
"real_debrid_account_linked": "Conta Real-Debrid associada",
"enable_all_debrid": "Habilitar All-Debrid",
"all_debrid_description": "All-Debrid é um downloader sem restrições que permite baixar rapidamente arquivos de várias fontes.",
"all_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine o All-Debrid",
"all_debrid_account_linked": "Conta All-Debrid vinculada com sucesso",
"alldebrid_missing_key": "Por favor, forneça uma chave de API",
"alldebrid_invalid_key": "Chave de API inválida",
"alldebrid_blocked": "Sua chave de API está bloqueada por geolocalização ou IP",
"alldebrid_banned": "Esta conta foi banida",
"alldebrid_unknown_error": "Ocorreu um erro desconhecido",
"alldebrid_invalid_response": "Resposta inválida do All-Debrid",
"alldebrid_network_error": "Erro de rede. Por favor, verifique sua conexão",
"name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
"import_theme": "Importar tema",
"import_theme_description": "Você irá importar {{theme}} da loja de temas",
@@ -591,10 +584,18 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Última sessão {{period}}",
"activity": "Atividades recentes",
"library": "Biblioteca",
"pinned": "Fixados",
"sort_by": "Ordenar por:",
"achievements_earned": "Conquistas obtidas",
"played_recently": "Jogados recentemente",
"playtime": "Tempo de jogo",
"total_play_time": "Tempo total de jogo",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"no_recent_activity_title": "Hmmm… nada por aqui",
"no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?",
"display_name": "Nome de exibição",

View File

@@ -267,6 +267,7 @@
"found_download_option_one": "{{countFormatted}} opção de transferência encontrada",
"found_download_option_other": "{{countFormatted}} opções de transferência encontradas",
"import": "Importar",
"importing": "A importar...",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
@@ -376,10 +377,18 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Última sessão {{period}}",
"activity": "Atividade recente",
"library": "Biblioteca",
"pinned": "Fixados",
"sort_by": "Ordenar por:",
"achievements_earned": "Conquistas obtidas",
"played_recently": "Jogados recentemente",
"playtime": "Tempo de jogo",
"total_play_time": "Tempo total de jogo",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"no_recent_activity_title": "Hmmm… não há nada por aqui",
"no_recent_activity_description": "Parece que não jogaste nada recentemente. Que tal começar agora?",
"display_name": "Nome de apresentação",

View File

@@ -135,11 +135,7 @@
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
"debrid_linked_message": "Contul \"{{username}}\" a fost legat",
"save_changes": "Salvează modificările",
"changes_saved": "Modificările au fost salvate cu succes",
"enable_all_debrid": "Activează All-Debrid",
"all_debrid_description": "All-Debrid este un descărcător fără restricții care îți permite să descarci fișiere din diverse surse.",
"all_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la All-Debrid",
"all_debrid_account_linked": "Contul All-Debrid a fost conectat cu succes"
"changes_saved": "Modificările au fost salvate cu succes"
},
"notifications": {
"download_complete": "Descărcare completă",

View File

@@ -259,6 +259,10 @@
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -394,7 +398,6 @@
"stop_seeding": "Остановить раздачу",
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"alldebrid_size_not_supported": "Информация о загрузке для AllDebrid пока не поддерживается",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
},
@@ -446,6 +449,7 @@
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
"import": "Импортировать",
"importing": "Импортируется...",
"public": "Публичный",
"private": "Частный",
"friends_only": "Только для друзей",
@@ -506,17 +510,6 @@
"create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid",
"create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox",
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
"enable_all_debrid": "Включить All-Debrid",
"all_debrid_description": "All-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы из различных источников.",
"all_debrid_free_account_error": "Аккаунт \"{{username}}\" является бесплатным. Пожалуйста, оформите подписку на All-Debrid",
"all_debrid_account_linked": "Аккаунт All-Debrid успешно привязан",
"alldebrid_missing_key": "Пожалуйста, предоставьте API ключ",
"alldebrid_invalid_key": "Неверный API ключ",
"alldebrid_blocked": "Ваш API ключ заблокирован по геолокации или IP",
"alldebrid_banned": "Этот аккаунт был заблокирован",
"alldebrid_unknown_error": "Произошла неизвестная ошибка",
"alldebrid_invalid_response": "Неверный ответ от All-Debrid",
"alldebrid_network_error": "Ошибка сети. Пожалуйста, проверьте соединение",
"name_min_length": "Название темы должно содержать не менее 3 символов",
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",

View File

@@ -395,8 +395,7 @@
"resume_seeding": "Продовжити сідінг",
"options": "Налаштування",
"extract": "Розархівувати файли",
"extracting": "Розархівовування файлів…",
"alldebrid_size_not_supported": "Інформація про завантаження для AllDebrid поки не підтримується"
"extracting": "Розархівовування файлів…"
},
"settings": {
"downloads_path": "Тека завантажень",
@@ -519,17 +518,6 @@
"installing_common_redist": "Встановлюється…",
"show_download_speed_in_megabytes": "Показувати швидкість завантаження в мегабайтах на секунду",
"extract_files_by_default": "Розпаковувати файли після завантаження",
"enable_all_debrid": "Увімкнути All-Debrid",
"all_debrid_description": "All-Debrid - це необмежений завантажувач, який дозволяє швидко завантажувати файли з різних джерел.",
"all_debrid_free_account_error": "Обліковий запис \"{{username}}\" є безкоштовним. Будь ласка, оформіть підписку на All-Debrid",
"all_debrid_account_linked": "Обліковий запис All-Debrid успішно прив'язано",
"alldebrid_missing_key": "Будь ласка, надайте API-ключ",
"alldebrid_invalid_key": "Невірний API-ключ",
"alldebrid_blocked": "Ваш API-ключ заблоковано за геолокацією або IP",
"alldebrid_banned": "Цей обліковий запис було заблоковано",
"alldebrid_unknown_error": "Сталася невідома помилка",
"alldebrid_invalid_response": "Невірна відповідь від All-Debrid",
"alldebrid_network_error": "Помилка мережі. Будь ласка, перевірте з'єднання",
"enable_steam_achievements": "Увімкнути пошук досягнень Steam",
"achievement_custom_notification_position": "Позиція сповіщень про досягнення",
"top-left": "Верхній лівий кут",

View File

@@ -0,0 +1,76 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal } from "./helpers";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const result = await importDownloadSourceToLocal(url, true);
if (!result) {
throw new Error("Failed to import download source");
}
// Verify that repacks were actually written to the database (read-after-write)
// This ensures all async operations are complete before proceeding
let repackCount = 0;
for await (const [, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === result.id) {
repackCount++;
}
}
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: result.objectIds,
},
{ needsAuth: false }
);
// Update the source with fingerprint
const updatedSource = await downloadSourcesSublevel.get(`${result.id}`);
if (updatedSource) {
await downloadSourcesSublevel.put(`${result.id}`, {
...updatedSource,
fingerprint,
updatedAt: new Date(),
});
}
// Final verification: ensure the source with fingerprint is persisted
const finalSource = await downloadSourcesSublevel.get(`${result.id}`);
if (!finalSource || !finalSource.fingerprint) {
throw new Error("Failed to persist download source with fingerprint");
}
// Verify repacks still exist after fingerprint update
let finalRepackCount = 0;
for await (const [, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === result.id) {
finalRepackCount++;
}
}
if (finalRepackCount !== repackCount) {
logger.warn(
`Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
);
} else {
logger.info(
`Final verification passed: ${finalRepackCount} repacks confirmed`
);
}
return {
...result,
fingerprint,
};
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@@ -0,0 +1,17 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
const checkDownloadSourceExists = async (
_event: Electron.IpcMainInvokeEvent,
url: string
): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (source.url === url) {
return true;
}
}
return false;
};
registerEvent("checkDownloadSourceExists", checkDownloadSourceExists);

View File

@@ -1,13 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const createDownloadSources = async (
_event: Electron.IpcMainInvokeEvent,
urls: string[]
) => {
await HydraApi.post("/profile/download-sources", {
urls,
});
};
registerEvent("createDownloadSources", createDownloadSources);

View File

@@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateIdCaches } from "./helpers";
const deleteAllDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
) => {
await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
invalidateIdCaches();
};
registerEvent("deleteAllDownloadSources", deleteAllDownloadSources);

View File

@@ -0,0 +1,28 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateIdCaches } from "./helpers";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
const repacksToDelete: string[] = [];
for await (const [key, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === id) {
repacksToDelete.push(key);
}
}
const batch = repacksSublevel.batch();
for (const key of repacksToDelete) {
batch.del(key);
}
await batch.write();
await downloadSourcesSublevel.del(`${id}`);
invalidateIdCaches();
};
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@@ -0,0 +1,19 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, DownloadSource } from "@main/level";
const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => {
const sources: DownloadSource[] = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
sources.push(source);
}
// Sort by createdAt descending
sources.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return sources;
};
registerEvent("getDownloadSourcesList", getDownloadSourcesList);

View File

@@ -0,0 +1,367 @@
import axios from "axios";
import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import crypto from "node:crypto";
import { logger, ResourceCache } from "@main/services";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
export type TitleHashMapping = Record<string, number[]>;
let titleHashMappingCache: TitleHashMapping | null = null;
export const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
if (titleHashMappingCache) {
return titleHashMappingCache;
}
try {
const cached =
ResourceCache.getCachedData<TitleHashMapping>("sources-manifest");
if (cached) {
titleHashMappingCache = cached;
return cached;
}
const fetched = await ResourceCache.fetchAndCache<TitleHashMapping>(
"sources-manifest",
"https://cdn.losbroxas.org/sources-manifest.json",
10000
);
titleHashMappingCache = fetched;
return fetched;
} catch (error) {
logger.error("Failed to fetch title hash mapping:", error);
return {} as TitleHashMapping;
}
};
export const hashTitle = (title: string): string => {
return crypto.createHash("sha256").update(title).digest("hex");
};
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
export const formatName = (name: string) => {
return name
.normalize("NFD")
.replaceAll(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replaceAll(/[^a-z0-9]/g, "");
};
export const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", ""));
};
interface DownloadSource {
id: number;
url: string;
name: string;
etag: string | null;
status: number;
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}
const getDownloadSourcesMap = async (): Promise<
Map<string, DownloadSource>
> => {
const map = new Map();
for await (const [key, source] of downloadSourcesSublevel.iterator()) {
map.set(key, source);
}
return map;
};
export const checkUrlExists = async (url: string): Promise<boolean> => {
const sources = await getDownloadSourcesMap();
for (const source of sources.values()) {
if (source.url === url) {
return true;
}
}
return false;
};
let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null;
export const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
if (steamGamesFormattedCache) {
return steamGamesFormattedCache;
}
let steamGames: SteamGamesByLetter;
const cached = ResourceCache.getCachedData<SteamGamesByLetter>(
"steam-games-by-letter"
);
if (cached) {
steamGames = cached;
} else {
steamGames = await ResourceCache.fetchAndCache<SteamGamesByLetter>(
"steam-games-by-letter",
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
}
const formattedData: FormattedSteamGamesByLetter = {};
for (const [letter, games] of Object.entries(steamGames)) {
formattedData[letter] = games.map((game) => ({
...game,
formattedName: formatName(game.name),
}));
}
steamGamesFormattedCache = formattedData;
return formattedData;
};
export type SublevelIterator = AsyncIterable<[string, { id: number }]>;
export interface SublevelWithId {
iterator: () => SublevelIterator;
}
let maxRepackId: number | null = null;
let maxDownloadSourceId: number | null = null;
export const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
const isRepackSublevel = sublevel === repacksSublevel;
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
if (isRepackSublevel && maxRepackId !== null) {
return ++maxRepackId;
}
if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
return ++maxDownloadSourceId;
}
let maxId = 0;
for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) {
maxId = value.id;
}
}
if (isRepackSublevel) {
maxRepackId = maxId;
} else if (isDownloadSourceSublevel) {
maxDownloadSourceId = maxId;
}
return maxId + 1;
};
export const invalidateIdCaches = () => {
maxRepackId = null;
maxDownloadSourceId = null;
};
export const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: FormattedSteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel);
const batch = repacksSublevel.batch();
const titleHashMapping = await getTitleHashMapping();
let hashMatchCount = 0;
let fuzzyMatchCount = 0;
let noMatchCount = 0;
for (const download of downloads) {
let objectIds: string[] = [];
let usedHashMatch = false;
const titleHash = hashTitle(download.title);
const steamIdsFromHash = titleHashMapping[titleHash];
if (steamIdsFromHash && steamIdsFromHash.length > 0) {
hashMatchCount++;
usedHashMatch = true;
objectIds = steamIdsFromHash.map(String);
}
if (!usedHashMatch) {
let gamesInSteam: FormattedSteamGame[] = [];
const formattedTitle = formatRepackName(download.title);
if (formattedTitle && formattedTitle.length > 0) {
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.formattedName)
);
if (gamesInSteam.length === 0) {
gamesInSteam = games.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
}
if (gamesInSteam.length === 0) {
for (const letter of Object.keys(steamGames)) {
const letterGames = steamGames[letter] || [];
const matches = letterGames.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
if (matches.length > 0) {
gamesInSteam = matches;
break;
}
}
}
if (gamesInSteam.length > 0) {
fuzzyMatchCount++;
objectIds = gamesInSteam.map((game) => String(game.id));
} else {
noMatchCount++;
}
} else {
noMatchCount++;
}
}
for (const id of objectIds) {
objectIdsOnSource.add(id);
}
const repack = {
id: nextRepackId++,
objectIds: objectIds,
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
};
batch.put(`${repack.id}`, repack);
}
await batch.write();
logger.info(
`Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
);
const existingSource = await downloadSourcesSublevel.get(
`${downloadSource.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...existingSource,
objectIds: Array.from(objectIdsOnSource),
});
}
return Array.from(objectIdsOnSource);
};
export const importDownloadSourceToLocal = async (
url: string,
throwOnDuplicate = false
) => {
const urlExists = await checkUrlExists(url);
if (urlExists) {
if (throwOnDuplicate) {
throw new Error("Download source with this URL already exists");
}
return null;
}
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const steamGames = await getSteamGames();
const now = new Date();
const nextId = await getNextId(downloadSourcesSublevel);
const downloadSource = {
id: nextId,
url,
name: response.data.name,
etag: response.headers["etag"] || null,
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
objectIds: [],
createdAt: now,
updatedAt: now,
};
await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
const objectIds = await addNewDownloads(
downloadSource,
response.data.downloads,
steamGames
);
// Invalidate ID caches after creating new repacks to prevent ID collisions
invalidateIdCaches();
return {
...downloadSource,
objectIds,
};
};
export const updateDownloadSourcePreservingTimestamp = async (
existingSource: DownloadSource,
url: string
) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const updatedSource = {
...existingSource,
name: response.data.name,
etag: response.headers["etag"] || null,
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
updatedAt: new Date(),
// Preserve the original createdAt timestamp
};
await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource);
return updatedSource;
};

View File

@@ -1,17 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const putDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
objectIds: string[]
) => {
return HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds,
},
{ needsAuth: false }
);
};
registerEvent("putDownloadSource", putDownloadSource);

View File

@@ -0,0 +1,19 @@
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
export const syncDownloadSourcesFromApi = async () => {
try {
const apiSources = await HydraApi.get<
{ url: string; createdAt: string; updatedAt: string }[]
>("/profile/download-sources");
for (const apiSource of apiSources) {
const exists = await checkUrlExists(apiSource.url);
if (!exists) {
await importDownloadSourceToLocal(apiSource.url, false);
}
}
} catch (error) {
logger.error("Failed to sync download sources from API:", error);
}
};

View File

@@ -0,0 +1,115 @@
import { registerEvent } from "../register-event";
import axios, { AxiosError } from "axios";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import {
invalidateIdCaches,
downloadSourceSchema,
getSteamGames,
addNewDownloads,
} from "./helpers";
const syncDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
let newRepacksCount = 0;
try {
const downloadSources: Array<{
id: number;
url: string;
name: string;
etag: string | null;
status: number;
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}> = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
downloadSources.push(source);
}
const existingRepacks: Array<{
id: number;
title: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
downloadSourceId: number;
createdAt: Date;
updatedAt: Date;
}> = [];
for await (const [, repack] of repacksSublevel.iterator()) {
existingRepacks.push(repack);
}
// Handle sources with missing fingerprints individually, don't delete all sources
const sourcesWithFingerprints = downloadSources.filter(
(source) => source.fingerprint
);
const sourcesWithoutFingerprints = downloadSources.filter(
(source) => !source.fingerprint
);
// For sources without fingerprints, just continue with normal sync
// They will get fingerprints updated later by updateMissingFingerprints
const allSourcesToSync = [
...sourcesWithFingerprints,
...sourcesWithoutFingerprints,
];
for (const downloadSource of allSourcesToSync) {
const headers: Record<string, string> = {};
if (downloadSource.etag) {
headers["If-None-Match"] = downloadSource.etag;
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
const steamGames = await getSteamGames();
const repacks = source.downloads.filter(
(download) =>
!existingRepacks.some((repack) => repack.title === download.title)
);
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...downloadSource,
etag: response.headers["etag"] || null,
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
await addNewDownloads(downloadSource, repacks, steamGames);
newRepacksCount += repacks.length;
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...downloadSource,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
invalidateIdCaches();
return newRepacksCount;
} catch (err) {
return -1;
}
};
registerEvent("syncDownloadSources", syncDownloadSources);

View File

@@ -0,0 +1,67 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi, logger } from "@main/services";
const updateMissingFingerprints = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
const sourcesNeedingFingerprints: Array<{
id: number;
objectIds: string[];
}> = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (
!source.fingerprint &&
source.objectIds &&
source.objectIds.length > 0
) {
sourcesNeedingFingerprints.push({
id: source.id,
objectIds: source.objectIds,
});
}
}
if (sourcesNeedingFingerprints.length === 0) {
return 0;
}
logger.info(
`Updating fingerprints for ${sourcesNeedingFingerprints.length} sources`
);
await Promise.all(
sourcesNeedingFingerprints.map(async (source) => {
try {
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: source.objectIds,
},
{ needsAuth: false }
);
const existingSource = await downloadSourcesSublevel.get(
`${source.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${source.id}`, {
...existingSource,
fingerprint,
updatedAt: new Date(),
});
}
} catch (error) {
logger.error(
`Failed to update fingerprint for source ${source.id}:`,
error
);
}
})
);
return sourcesNeedingFingerprints.length;
};
registerEvent("updateMissingFingerprints", updateMissingFingerprints);

View File

@@ -0,0 +1,32 @@
import { registerEvent } from "../register-event";
import axios from "axios";
import { z } from "zod";
const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const { name } = downloadSourceSchema.parse(response.data);
return {
name,
etag: response.headers["etag"] || null,
downloadCount: response.data.downloads.length,
};
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@@ -61,9 +61,16 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-all-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/put-download-source";
import "./download-sources/add-download-source";
import "./download-sources/update-missing-fingerprints";
import "./download-sources/delete-download-source";
import "./download-sources/delete-all-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/sync-download-sources";
import "./download-sources/get-download-sources-list";
import "./download-sources/check-download-source-exists";
import "./repacks/get-all-repacks";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
@@ -91,7 +98,6 @@ import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import "./download-sources/create-download-sources";
import "./download-sources/remove-download-source";
import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers";

View File

@@ -1,6 +1,6 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
import type { GameShop } from "@types";
const addCustomGameToLibrary = async (

View File

@@ -19,7 +19,6 @@ const getAllCustomGameAssets = async (): Promise<string[]> => {
};
const getUsedAssetPaths = async (): Promise<Set<string>> => {
// Get all custom games from the level database
const { gamesSublevel } = await import("@main/level");
const allGames = await gamesSublevel.iterator().all();
@@ -30,7 +29,6 @@ const getUsedAssetPaths = async (): Promise<Set<string>> => {
const usedPaths = new Set<string>();
customGames.forEach((game) => {
// Extract file paths from local URLs
if (game.iconUrl?.startsWith("local:")) {
usedPaths.add(game.iconUrl.replace("local:", ""));
}

View File

@@ -1,7 +1,7 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
import { ASSETS_PATH } from "@main/constants";
const copyCustomGameAsset = async (
@@ -13,29 +13,23 @@ const copyCustomGameAsset = async (
throw new Error("Source file does not exist");
}
// Ensure assets directory exists
if (!fs.existsSync(ASSETS_PATH)) {
fs.mkdirSync(ASSETS_PATH, { recursive: true });
}
// Create custom games assets subdirectory
const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games");
if (!fs.existsSync(customGamesAssetsPath)) {
fs.mkdirSync(customGamesAssetsPath, { recursive: true });
}
// Get file extension
const fileExtension = path.extname(sourcePath);
// Generate unique filename
const uniqueId = randomUUID();
const fileName = `${assetType}-${uniqueId}${fileExtension}`;
const destinationPath = path.join(customGamesAssetsPath, fileName);
// Copy the file
await fs.promises.copyFile(sourcePath, destinationPath);
// Return the local URL format
return `local:${destinationPath}`;
};

View File

@@ -0,0 +1,16 @@
import { registerEvent } from "../register-event";
import { repacksSublevel, GameRepack } from "@main/level";
const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => {
const repacks: GameRepack[] = [];
for await (const [, repack] of repacksSublevel.iterator()) {
if (Array.isArray(repack.objectIds)) {
repacks.push(repack);
}
}
return repacks;
};
registerEvent("getAllRepacks", getAllRepacks);

View File

@@ -1,17 +0,0 @@
import { AllDebridClient } from "@main/services/download/all-debrid";
import { registerEvent } from "../register-event";
const authenticateAllDebrid = async (
_event: Electron.IpcMainInvokeEvent,
apiKey: string
) => {
AllDebridClient.authorize(apiKey);
const result = await AllDebridClient.getUser();
if ("error_code" in result) {
return { error_code: result.error_code };
}
return result.user;
};
registerEvent("authenticateAllDebrid", authenticateAllDebrid);

View File

@@ -0,0 +1,22 @@
import { db } from "../level";
import { levelKeys } from "./keys";
export interface DownloadSource {
id: number;
name: string;
url: string;
status: number;
objectIds: string[];
downloadCount: number;
fingerprint?: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
}
export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>(
levelKeys.downloadSources,
{
valueEncoding: "json",
}
);

View File

@@ -6,3 +6,5 @@ export * from "./game-stats-cache";
export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./repacks";

View File

@@ -17,4 +17,6 @@ export const levelKeys = {
language: "language",
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
repacks: "repacks",
};

View File

@@ -0,0 +1,22 @@
import { db } from "../level";
import { levelKeys } from "./keys";
export interface GameRepack {
id: number;
title: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
downloadSourceId: number;
createdAt: Date;
updatedAt: Date;
}
export const repacksSublevel = db.sublevel<string, GameRepack>(
levelKeys.repacks,
{
valueEncoding: "json",
}
);

View File

@@ -8,7 +8,6 @@ import {
CommonRedistManager,
TorBoxClient,
RealDebridClient,
AllDebridClient,
Aria2,
DownloadManager,
HydraApi,
@@ -17,11 +16,15 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
ResourceCache,
} from "@main/services";
export const loadState = async () => {
await Lock.acquireLock();
ResourceCache.initialize();
await ResourceCache.updateResourcesOnStartup();
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
@@ -39,10 +42,6 @@ export const loadState = async () => {
RealDebridClient.authorize(userPreferences.realDebridApiToken);
}
if (userPreferences?.allDebridApiKey) {
AllDebridClient.authorize(userPreferences.allDebridApiKey);
}
if (userPreferences?.torBoxApiToken) {
TorBoxClient.authorize(userPreferences.torBoxApiToken);
}

View File

@@ -5,15 +5,18 @@ import { logger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { AxiosError } from "axios";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60; // 1 hour
const getModifiedSinceHeader = (
cachedAchievements: GameAchievement | undefined
cachedAchievements: GameAchievement | undefined,
userLanguage: string
): Date | undefined => {
if (!cachedAchievements) {
return undefined;
}
if (userLanguage != cachedAchievements.language) {
return undefined;
}
return cachedAchievements.updatedAt
? new Date(cachedAchievements.updatedAt)
: undefined;
@@ -28,13 +31,7 @@ export const getGameAchievementData = async (
const cachedAchievements = await gameAchievementsSublevel.get(gameKey);
if (cachedAchievements?.achievements && useCachedData)
return cachedAchievements.achievements;
if (
cachedAchievements?.achievements &&
Date.now() < (cachedAchievements.updatedAt ?? 0) + LOCAL_CACHE_EXPIRATION
) {
if (cachedAchievements?.achievements && useCachedData) {
return cachedAchievements.achievements;
}
@@ -50,14 +47,15 @@ export const getGameAchievementData = async (
language,
},
{
ifModifiedSince: getModifiedSinceHeader(cachedAchievements),
ifModifiedSince: getModifiedSinceHeader(cachedAchievements, language),
}
)
.then(async (achievements) => {
await gameAchievementsSublevel.put(gameKey, {
unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [],
achievements,
updatedAt: Date.now() + LOCAL_CACHE_EXPIRATION,
updatedAt: Date.now(),
language,
});
return achievements;

View File

@@ -37,6 +37,7 @@ const saveAchievementsOnLocal = async (
achievements: gameAchievement?.achievements ?? [],
unlockedAchievements: unlockedAchievements,
updatedAt: gameAchievement?.updatedAt,
language: gameAchievement?.language,
});
if (!sendUpdateEvent) return;

View File

@@ -1,315 +0,0 @@
import axios, { AxiosInstance } from "axios";
import type { AllDebridUser } from "@types";
import { logger } from "@main/services";
interface AllDebridMagnetStatus {
id: number;
filename: string;
size: number;
status: string;
statusCode: number;
downloaded: number;
uploaded: number;
seeders: number;
downloadSpeed: number;
uploadSpeed: number;
uploadDate: number;
completionDate: number;
links: Array<{
link: string;
filename: string;
size: number;
}>;
}
interface AllDebridError {
code: string;
message: string;
}
interface AllDebridDownloadUrl {
link: string;
size?: number;
filename?: string;
}
export class AllDebridClient {
private static instance: AxiosInstance;
private static readonly baseURL = "https://api.alldebrid.com/v4";
static authorize(apiKey: string) {
logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty");
this.instance = axios.create({
baseURL: this.baseURL,
params: {
agent: "hydra",
apikey: apiKey,
},
});
}
static async getUser() {
try {
const response = await this.instance.get<{
status: string;
data?: { user: AllDebridUser };
error?: AllDebridError;
}>("/user");
logger.info("[AllDebrid] API Response:", response.data);
if (response.data.status === "error") {
const error = response.data.error;
logger.error("[AllDebrid] API Error:", error);
if (error?.code === "AUTH_MISSING_APIKEY") {
return { error_code: "alldebrid_missing_key" };
}
if (error?.code === "AUTH_BAD_APIKEY") {
return { error_code: "alldebrid_invalid_key" };
}
if (error?.code === "AUTH_BLOCKED") {
return { error_code: "alldebrid_blocked" };
}
if (error?.code === "AUTH_USER_BANNED") {
return { error_code: "alldebrid_banned" };
}
return { error_code: "alldebrid_unknown_error" };
}
if (!response.data.data?.user) {
logger.error("[AllDebrid] No user data in response");
return { error_code: "alldebrid_invalid_response" };
}
logger.info(
"[AllDebrid] Successfully got user:",
response.data.data.user.username
);
return { user: response.data.data.user };
} catch (error: any) {
logger.error("[AllDebrid] Request Error:", error);
if (error.response?.data?.error) {
return { error_code: "alldebrid_invalid_key" };
}
return { error_code: "alldebrid_network_error" };
}
}
private static async uploadMagnet(magnet: string) {
try {
logger.info("[AllDebrid] Uploading magnet with params:", { magnet });
const response = await this.instance.get("/magnet/upload", {
params: {
magnets: [magnet],
},
});
logger.info(
"[AllDebrid] Upload Magnet Raw Response:",
JSON.stringify(response.data, null, 2)
);
if (response.data.status === "error") {
throw new Error(response.data.error?.message || "Unknown error");
}
const magnetInfo = response.data.data.magnets[0];
logger.info(
"[AllDebrid] Magnet Info:",
JSON.stringify(magnetInfo, null, 2)
);
if (magnetInfo.error) {
throw new Error(magnetInfo.error.message);
}
return magnetInfo.id;
} catch (error: any) {
logger.error("[AllDebrid] Upload Magnet Error:", error);
throw error;
}
}
private static async checkMagnetStatus(
magnetId: number
): Promise<AllDebridMagnetStatus> {
try {
logger.info("[AllDebrid] Checking magnet status for ID:", magnetId);
const response = await this.instance.get(`/magnet/status`, {
params: {
id: magnetId,
},
});
logger.info(
"[AllDebrid] Check Magnet Status Raw Response:",
JSON.stringify(response.data, null, 2)
);
if (!response.data) {
throw new Error("No response data received");
}
if (response.data.status === "error") {
throw new Error(response.data.error?.message || "Unknown error");
}
// Verificăm noua structură a răspunsului
const magnetData = response.data.data?.magnets;
if (!magnetData || typeof magnetData !== "object") {
logger.error(
"[AllDebrid] Invalid response structure:",
JSON.stringify(response.data, null, 2)
);
throw new Error("Invalid magnet status response format");
}
// Convertim răspunsul în formatul așteptat
const magnetStatus: AllDebridMagnetStatus = {
id: magnetData.id,
filename: magnetData.filename,
size: magnetData.size,
status: magnetData.status,
statusCode: magnetData.statusCode,
downloaded: magnetData.downloaded,
uploaded: magnetData.uploaded,
seeders: magnetData.seeders,
downloadSpeed: magnetData.downloadSpeed,
uploadSpeed: magnetData.uploadSpeed,
uploadDate: magnetData.uploadDate,
completionDate: magnetData.completionDate,
links: magnetData.links.map((link) => ({
link: link.link,
filename: link.filename,
size: link.size,
})),
};
logger.info(
"[AllDebrid] Magnet Status:",
JSON.stringify(magnetStatus, null, 2)
);
return magnetStatus;
} catch (error: any) {
logger.error("[AllDebrid] Check Magnet Status Error:", error);
throw error;
}
}
private static async unlockLink(link: string) {
try {
const response = await this.instance.get<{
status: string;
data?: { link: string };
error?: AllDebridError;
}>("/link/unlock", {
params: {
link,
},
});
if (response.data.status === "error") {
throw new Error(response.data.error?.message || "Unknown error");
}
const unlockedLink = response.data.data?.link;
if (!unlockedLink) {
throw new Error("No download link received from AllDebrid");
}
return unlockedLink;
} catch (error: any) {
logger.error("[AllDebrid] Unlock Link Error:", error);
throw error;
}
}
public static async getDownloadUrls(
uri: string
): Promise<AllDebridDownloadUrl[]> {
try {
logger.info("[AllDebrid] Getting download URLs for URI:", uri);
if (uri.startsWith("magnet:")) {
logger.info("[AllDebrid] Detected magnet link, uploading...");
// 1. Upload magnet
const magnetId = await this.uploadMagnet(uri);
logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId);
// 2. Verificăm statusul până când avem link-uri
let retries = 0;
let magnetStatus: AllDebridMagnetStatus;
do {
magnetStatus = await this.checkMagnetStatus(magnetId);
logger.info(
"[AllDebrid] Magnet status:",
magnetStatus.status,
"statusCode:",
magnetStatus.statusCode
);
if (magnetStatus.statusCode === 4) {
// Ready
// Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează
const unlockedLinks = await Promise.all(
magnetStatus.links.map(async (link) => {
try {
const unlockedLink = await this.unlockLink(link.link);
logger.info(
"[AllDebrid] Successfully unlocked link:",
unlockedLink
);
return {
link: unlockedLink,
size: link.size,
filename: link.filename,
};
} catch (error) {
logger.error(
"[AllDebrid] Failed to unlock link:",
link.link,
error
);
throw new Error("Failed to unlock all links");
}
})
);
logger.info(
"[AllDebrid] Got unlocked download links:",
unlockedLinks
);
console.log("[AllDebrid] FINAL LINKS →", unlockedLinks);
return unlockedLinks;
}
if (retries++ > 30) {
// Maximum 30 de încercări
throw new Error("Timeout waiting for magnet to be ready");
}
await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări
} while (magnetStatus.statusCode !== 4);
} else {
logger.info("[AllDebrid] Regular link, unlocking...");
// Pentru link-uri normale, doar debridam link-ul
const downloadUrl = await this.unlockLink(uri);
logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl);
return [
{
link: downloadUrl,
},
];
}
} catch (error: any) {
logger.error("[AllDebrid] Get Download URLs Error:", error);
throw error;
}
return []; // Add default return for TypeScript
}
}

View File

@@ -17,7 +17,6 @@ import {
} from "./types";
import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid";
import { AllDebridClient } from "./all-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
@@ -379,27 +378,6 @@ export class DownloadManager {
allow_multiple_connections: true,
};
}
case Downloader.AllDebrid: {
const downloadUrls = await AllDebridClient.getDownloadUrls(
download.uri
);
if (!downloadUrls.length)
throw new Error(DownloadError.NotCachedInAllDebrid);
const totalSize = downloadUrls.reduce(
(total, url) => total + (url.size || 0),
0
);
return {
action: "start",
game_id: downloadId,
url: downloadUrls.map((d) => d.link),
save_path: download.downloadPath,
total_size: totalSize,
};
}
case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);

View File

@@ -1,4 +1,3 @@
export * from "./download-manager";
export * from "./real-debrid";
export * from "./all-debrid";
export * from "./torbox";

View File

@@ -102,8 +102,14 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds();
uploadGamesBatch();
// WSClient.close();
// WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
syncDownloadSourcesFromApi();
}
}

View File

@@ -18,3 +18,4 @@ export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./resource-cache";

View File

@@ -0,0 +1,157 @@
import { app } from "electron";
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
import { logger } from "./logger";
interface CachedResource<T = unknown> {
data: T;
etag: string | null;
}
export class ResourceCache {
private static cacheDir: string;
static initialize() {
this.cacheDir = path.join(app.getPath("userData"), "resource-cache");
if (!fs.existsSync(this.cacheDir)) {
fs.mkdirSync(this.cacheDir, { recursive: true });
}
}
private static getCacheFilePath(resourceName: string): string {
return path.join(this.cacheDir, `${resourceName}.json`);
}
private static getEtagFilePath(resourceName: string): string {
return path.join(this.cacheDir, `${resourceName}.etag`);
}
private static readCachedResource<T = unknown>(
resourceName: string
): CachedResource<T> | null {
const dataPath = this.getCacheFilePath(resourceName);
const etagPath = this.getEtagFilePath(resourceName);
if (!fs.existsSync(dataPath)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T;
const etag = fs.existsSync(etagPath)
? fs.readFileSync(etagPath, "utf-8")
: null;
return { data, etag };
} catch (error) {
logger.error(`Failed to read cached resource ${resourceName}:`, error);
return null;
}
}
private static writeCachedResource<T = unknown>(
resourceName: string,
data: T,
etag: string | null
): void {
const dataPath = this.getCacheFilePath(resourceName);
const etagPath = this.getEtagFilePath(resourceName);
try {
fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8");
if (etag) {
fs.writeFileSync(etagPath, etag, "utf-8");
}
logger.info(
`Cached resource ${resourceName} with etag: ${etag || "none"}`
);
} catch (error) {
logger.error(`Failed to write cached resource ${resourceName}:`, error);
}
}
static async fetchAndCache<T = unknown>(
resourceName: string,
url: string,
timeout: number = 10000
): Promise<T> {
const cached = this.readCachedResource<T>(resourceName);
const headers: Record<string, string> = {};
if (cached?.etag) {
headers["If-None-Match"] = cached.etag;
}
try {
const response = await axios.get<T>(url, {
headers,
timeout,
});
const newEtag = response.headers["etag"] || null;
this.writeCachedResource(resourceName, response.data, newEtag);
return response.data;
} catch (error: unknown) {
const axiosError = error as {
response?: { status?: number };
message?: string;
};
if (axiosError.response?.status === 304 && cached) {
logger.info(`Resource ${resourceName} not modified, using cache`);
return cached.data;
}
if (cached) {
logger.warn(
`Failed to fetch ${resourceName}, using cached version:`,
axiosError.message || "Unknown error"
);
return cached.data;
}
logger.error(
`Failed to fetch ${resourceName} and no cache available:`,
error
);
throw error;
}
}
static getCachedData<T = unknown>(resourceName: string): T | null {
const cached = this.readCachedResource<T>(resourceName);
return cached?.data || null;
}
static async updateResourcesOnStartup(): Promise<void> {
logger.info("Starting background resource cache update...");
const resources = [
{
name: "steam-games-by-letter",
url: `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`,
},
{
name: "sources-manifest",
url: "https://cdn.losbroxas.org/sources-manifest.json",
},
];
await Promise.allSettled(
resources.map(async (resource) => {
try {
await this.fetchAndCache(resource.name, resource.url);
} catch (error) {
logger.error(`Failed to update ${resource.name} on startup:`, error);
}
})
);
logger.info("Resource cache update complete");
}
}

View File

@@ -93,19 +93,28 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("autoLaunch", autoLaunchProps),
authenticateRealDebrid: (apiToken: string) =>
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
authenticateAllDebrid: (apiKey: string) =>
ipcRenderer.invoke("authenticateAllDebrid", apiKey),
authenticateTorBox: (apiToken: string) =>
ipcRenderer.invoke("authenticateTorBox", apiToken),
/* Download sources */
putDownloadSource: (objectIds: string[]) =>
ipcRenderer.invoke("putDownloadSource", objectIds),
createDownloadSources: (urls: string[]) =>
ipcRenderer.invoke("createDownloadSources", urls),
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
updateMissingFingerprints: () =>
ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
deleteAllDownloadSources: () =>
ipcRenderer.invoke("deleteAllDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"),
checkDownloadSourceExists: (url: string) =>
ipcRenderer.invoke("checkDownloadSourceExists", url),
getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"),
/* Library */
toggleAutomaticCloudSync: (

View File

@@ -20,14 +20,12 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { generateUUID } from "./helpers";
import { injectCustomCss, removeCustomCss } from "./helpers";
import "./app.scss";
@@ -137,15 +135,6 @@ export function App() {
}, [fetchUserDetails, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => {
window.electron.getDownloadSources().then((sources) => {
sources.forEach((source) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
source.url,
]);
});
});
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
@@ -211,41 +200,34 @@ export function App() {
}, [dispatch, draggingDisabled]);
useEffect(() => {
updateRepacks();
(async () => {
dispatch(setIsImportingSources(true));
const id = generateUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
try {
// Initial repacks load
await updateRepacks();
channel.onmessage = async (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
updateRepacks();
// Sync all local sources (check for updates)
const newRepacksCount = await window.electron.syncDownloadSources();
const downloadSources = await downloadSourcesTable.toArray();
if (newRepacksCount > 0) {
window.electron.publishNewRepacksNotification(newRepacksCount);
}
await Promise.all(
downloadSources
.filter((source) => !source.fingerprint)
.map(async (downloadSource) => {
const { fingerprint } = await window.electron.putDownloadSource(
downloadSource.objectIds
);
// Update fingerprints for sources that don't have them
await window.electron.updateMissingFingerprints();
return downloadSourcesTable.update(downloadSource.id, {
fingerprint,
});
})
);
channel.close();
};
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
return () => {
channel.close();
};
}, [updateRepacks]);
// Update repacks AFTER all syncing and fingerprint updates are complete
await updateRepacks();
} catch (error) {
console.error("Error syncing download sources:", error);
// Still update repacks even if sync fails
await updateRepacks();
} finally {
dispatch(setIsImportingSources(false));
}
})();
}, [updateRepacks, dispatch]);
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();

View File

@@ -302,7 +302,8 @@ $margin-bottom: 28px;
}
&--rare &__trophy-overlay {
background: linear-gradient(
background:
linear-gradient(
118deg,
#e8ad15 18.96%,
#d5900f 26.41%,

View File

@@ -109,12 +109,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
</span>
</div>
<div className="game-card__specifics-item">
<StarRating
rating={stats?.averageScore || null}
size={14}
showCalculating={!!(stats && stats.averageScore === null)}
calculatingText={t("calculating")}
/>
<StarRating rating={stats?.averageScore || null} size={14} />
</div>
</div>
</div>

View File

@@ -5,10 +5,7 @@
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 500px;
margin: 0 auto;
text-align: center;
min-width: 500px;
}
&__form {

View File

@@ -1,76 +1,31 @@
import { StarIcon, StarFillIcon } from "@primer/octicons-react";
import { StarFillIcon } from "@primer/octicons-react";
import "./star-rating.scss";
export interface StarRatingProps {
rating: number | null;
maxStars?: number;
size?: number;
showCalculating?: boolean;
calculatingText?: string;
hideIcon?: boolean;
}
export function StarRating({
rating,
maxStars = 5,
size = 12,
showCalculating = false,
calculatingText = "Calculating",
hideIcon = false,
}: Readonly<StarRatingProps>) {
if (rating === null && showCalculating) {
return (
<div className="star-rating star-rating--calculating">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__calculating-text">{calculatingText}</span>
</div>
);
}
export function StarRating({ rating, size = 12 }: Readonly<StarRatingProps>) {
if (rating === null || rating === undefined) {
return (
<div className="star-rating star-rating--no-rating">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__no-rating-text"></span>
</div>
);
}
const filledStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
return (
<div className="star-rating">
{Array.from({ length: filledStars }, (_, index) => (
<div className="star-rating star-rating--single">
<StarFillIcon
key={`filled-${index}`}
size={size}
className="star-rating__star star-rating__star--filled"
/>
))}
{hasHalfStar && (
<div className="star-rating__half-star" key="half-star">
<StarIcon
size={size}
className="star-rating__star star-rating__star--empty"
/>
<StarFillIcon
size={size}
className="star-rating__star star-rating__star--half"
/>
</div>
)}
{Array.from({ length: emptyStars }, (_, index) => (
<StarIcon
key={`empty-${index}`}
size={size}
className="star-rating__star star-rating__star--empty"
/>
))}
<span className="star-rating__value"></span>
</div>
);
}
// Always use single star mode with numeric score
return (
<div className="star-rating star-rating--single">
<StarFillIcon
size={size}
className="star-rating__star star-rating__star--filled"
/>
<span className="star-rating__value">{rating.toFixed(1)}</span>
</div>
);

View File

@@ -11,7 +11,6 @@ export const DOWNLOADER_NAME = {
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.TorBox]: "TorBox",
[Downloader.AllDebrid]: "All-Debrid",
[Downloader.Hydra]: "Nimbus",
};

View File

@@ -66,10 +66,7 @@ export function UserProfileContextProvider({
const isMe = userDetails?.id === userProfile?.id;
const getHeroBackgroundFromImageUrl = async (imageUrl: string) => {
const output = await average(imageUrl, {
amount: 1,
format: "hex",
});
const output = await average(imageUrl, { amount: 1, format: "hex" });
return `linear-gradient(135deg, ${darkenColor(output as string, 0.5)}, ${darkenColor(output as string, 0.6, 0.5)})`;
};
@@ -135,28 +132,25 @@ export function UserProfileContextProvider({
getUserLibraryGames();
return window.electron.hydraApi
.get<UserProfile | null>(`/users/${userId}`)
.get<UserProfile>(`/users/${userId}`)
.then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
setUserProfile(userProfile);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
})
.catch(() => {
showErrorToast(t("user_not_found"));
navigate(-1);
});
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const language = i18n.language.split("-")[0];
const params = new URLSearchParams({
locale: language,
});
const params = new URLSearchParams({ locale: language });
const badges = await window.electron.hydraApi.get<Badge[]>(
`/badges?${params.toString()}`,

View File

@@ -8,7 +8,6 @@ import type {
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
AllDebridUser,
UserProfile,
FriendRequestAction,
UpdateProfileRequest,
@@ -31,6 +30,9 @@ import type {
AchievementNotificationInfo,
Game,
DiskUsage,
DownloadSource,
DownloadSourceValidationResult,
GameRepack,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -190,9 +192,6 @@ declare global {
) => Promise<void>;
/* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateAllDebrid: (
apiKey: string
) => Promise<AllDebridUser | { error_code: string }>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (
@@ -210,14 +209,21 @@ declare global {
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
/* Download sources */
putDownloadSource: (
objectIds: string[]
) => Promise<{ fingerprint: string }>;
createDownloadSources: (urls: string[]) => Promise<void>;
addDownloadSource: (url: string) => Promise<DownloadSource>;
updateMissingFingerprints: () => Promise<number>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
>;
deleteDownloadSource: (id: number) => Promise<void>;
deleteAllDownloadSources: () => Promise<void>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: () => Promise<number>;
getDownloadSourcesList: () => Promise<DownloadSource[]>;
checkDownloadSourceExists: (url: string) => Promise<boolean>;
getAllRepacks: () => Promise<GameRepack[]>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -1,27 +0,0 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import { Dexie } from "dexie";
export interface HowLongToBeatEntry {
id?: number;
objectId: string;
categories: HowLongToBeatCategory[];
shop: GameShop;
createdAt: Date;
updatedAt: Date;
}
export const db = new Dexie("Hydra");
db.version(9).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
downloadSources: `++id, &url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
export const repacksTable = db.table("repacks");
export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
"howLongToBeatEntries"
);
db.open();

View File

@@ -0,0 +1,21 @@
import { createSlice } from "@reduxjs/toolkit";
export interface DownloadSourcesState {
isImporting: boolean;
}
const initialState: DownloadSourcesState = {
isImporting: false,
};
export const downloadSourcesSlice = createSlice({
name: "downloadSources",
initialState,
reducers: {
setIsImportingSources: (state, action) => {
state.isImporting = action.payload;
},
},
});
export const { setIsImportingSources } = downloadSourcesSlice.actions;

View File

@@ -7,4 +7,5 @@ export * from "./user-details-slice";
export * from "./game-running.slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./download-sources-slice";
export * from "./catalogue-search";

View File

@@ -1,4 +1,3 @@
import { repacksTable } from "@renderer/dexie";
import { setRepacks } from "@renderer/features";
import { useCallback } from "react";
import { RootState } from "@renderer/store";
@@ -16,18 +15,11 @@ export function useRepacks() {
[repacks]
);
const updateRepacks = useCallback(() => {
repacksTable.toArray().then((repacks) => {
dispatch(
setRepacks(
JSON.parse(
JSON.stringify(
repacks.filter((repack) => Array.isArray(repack.objectIds))
)
)
)
);
});
const updateRepacks = useCallback(async () => {
const repacks = await window.electron.getAllRepacks();
dispatch(
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
);
}, [dispatch]);
return { getRepacksForObjectId, updateRepacks };

View File

@@ -1,16 +1,10 @@
import type { CatalogueSearchResult, DownloadSource } from "@types";
import {
useAppDispatch,
useAppSelector,
useFormat,
useRepacks,
} from "@renderer/hooks";
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import "./catalogue.scss";
import { downloadSourcesTable } from "@renderer/dexie";
import { FilterSection } from "./filter-section";
import { setFilters, setPage } from "@renderer/features";
import { useTranslation } from "react-i18next";
@@ -56,8 +50,6 @@ export default function Catalogue() {
const { t, i18n } = useTranslation("catalogue");
const { getRepacksForObjectId } = useRepacks();
const debouncedSearch = useRef(
debounce(async (filters, pageSize, offset) => {
const abortController = new AbortController();
@@ -95,10 +87,10 @@ export default function Catalogue() {
}, [filters, page, debouncedSearch]);
useEffect(() => {
downloadSourcesTable.toArray().then((sources) => {
window.electron.getDownloadSourcesList().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint));
});
}, [getRepacksForObjectId]);
}, []);
const language = i18n.language.split("-")[0];
@@ -192,13 +184,15 @@ export default function Catalogue() {
},
{
title: t("download_sources"),
items: downloadSources.map((source) => ({
label: source.name,
value: source.fingerprint,
checked: filters.downloadSourceFingerprints.includes(
source.fingerprint
),
})),
items: downloadSources
.filter((source) => source.fingerprint)
.map((source) => ({
label: source.name,
value: source.fingerprint!,
checked: filters.downloadSourceFingerprints.includes(
source.fingerprint!
),
})),
key: "downloadSourceFingerprints",
},
{

View File

@@ -114,15 +114,6 @@ export function DownloadGroup({
return <p>{t("deleting")}</p>;
}
if (download.downloader === Downloader.AllDebrid) {
return (
<>
<p>{progress}</p>
<p>{t("alldebrid_size_not_supported")}</p>
</>
);
}
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
@@ -190,15 +181,6 @@ export function DownloadGroup({
}
if (download.status === "active") {
if ((download.downloader as unknown as string) === "alldebrid") {
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
<p>{t("alldebrid_size_not_supported")}</p>
</>
);
}
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
@@ -293,9 +275,7 @@ export function DownloadGroup({
(download?.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(download?.downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken) ||
(download?.downloader === Downloader.AllDebrid &&
!userPreferences?.allDebridApiKey);
!userPreferences?.torBoxApiToken);
return [
{

View File

@@ -25,11 +25,6 @@
overflow: hidden;
border-radius: 8px;
@media (min-width: 1024px) {
width: 80%;
max-height: 400px;
}
@media (min-width: 1280px) {
width: 60%;
max-height: 500px;
@@ -72,10 +67,6 @@
overflow-y: hidden;
gap: calc(globals.$spacing-unit / 2);
@media (min-width: 1024px) {
width: 80%;
}
@media (min-width: 1280px) {
width: 60%;
}

View File

@@ -224,6 +224,12 @@ $hero-height: 300px;
margin-top: calc(globals.$spacing-unit * 3);
}
&__reviews-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 4);
}
&__reviews-separator {
height: 1px;
background: rgba(255, 255, 255, 0.1);
@@ -264,16 +270,6 @@ $hero-height: 300px;
}
&__review-item {
background: linear-gradient(
to right,
globals.$dark-background-color 0%,
globals.$dark-background-color 30%,
globals.$background-color 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
overflow: hidden;
word-wrap: break-word;
}

View File

@@ -39,7 +39,7 @@ export function GameReviews({
hasUserReviewed,
onUserReviewedChange,
}: Readonly<GameReviewsProps>) {
const { t } = useTranslation("game_details");
const { t, i18n } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast();
const [reviews, setReviews] = useState<GameReview[]>([]);
@@ -129,9 +129,7 @@ export function GameReviews({
const twoHoursInMilliseconds = 2 * 60 * 60 * 1000;
const hasEnoughPlaytime =
game &&
game.playTimeInMilliseconds >= twoHoursInMilliseconds &&
!game.hasManuallyUpdatedPlaytime;
game && game.playTimeInMilliseconds >= twoHoursInMilliseconds;
if (
!hasReviewed &&
@@ -146,6 +144,8 @@ export function GameReviews({
}
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
console.log("reviews", reviews);
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
@@ -164,6 +164,7 @@ export function GameReviews({
take: "20",
skip: skip.toString(),
sortBy: reviewsSortBy,
language: i18n.language,
});
const response = await window.electron.hydraApi.get(
@@ -200,7 +201,7 @@ export function GameReviews({
}
}
},
[objectId, shop, reviewsPage, reviewsSortBy]
[objectId, shop, reviewsPage, reviewsSortBy, i18n.language]
);
const handleVoteReview = async (
@@ -439,6 +440,8 @@ export function GameReviews({
});
}, [reviews]);
console.log("reviews", reviews);
return (
<div className="game-details__reviews-section">
{showReviewPrompt &&
@@ -501,6 +504,7 @@ export function GameReviews({
)}
<div
className="game-details__reviews-container"
style={{
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
transition: "opacity 0.2s ease",

View File

@@ -117,8 +117,6 @@ export function DownloadSettingsModal({
return userPreferences?.realDebridApiToken;
if (downloader === Downloader.TorBox)
return userPreferences?.torBoxApiToken;
if (downloader === Downloader.AllDebrid)
return userPreferences?.allDebridApiKey;
if (downloader === Downloader.Hydra)
return isFeatureEnabled(Feature.Nimbus);
return true;
@@ -133,7 +131,6 @@ export function DownloadSettingsModal({
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
userPreferences?.allDebridApiKey,
]);
const handleChooseDownloadsPath = async () => {
@@ -194,8 +191,6 @@ export function DownloadSettingsModal({
const shouldDisableButton =
(downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(downloader === Downloader.AllDebrid &&
!userPreferences?.allDebridApiKey) ||
(downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken) ||
(downloader === Downloader.Hydra &&

View File

@@ -55,11 +55,8 @@
border: 1px solid var(--color-border);
border-radius: 8px;
background-color: var(--color-background-secondary);
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.1) 25%,
transparent 25%
),
background-image:
linear-gradient(45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%);

View File

@@ -19,6 +19,68 @@ export interface EditGameModalProps {
type AssetType = "icon" | "logo" | "hero";
interface ElectronFile extends File {
path?: string;
}
interface GameWithOriginalAssets extends Game {
originalIconPath?: string;
originalLogoPath?: string;
originalHeroPath?: string;
}
interface LibraryGameWithCustomOriginalAssets extends LibraryGame {
customOriginalIconPath?: string;
customOriginalLogoPath?: string;
customOriginalHeroPath?: string;
}
interface AssetPaths {
icon: string;
logo: string;
hero: string;
}
interface AssetUrls {
icon: string | null;
logo: string | null;
hero: string | null;
}
interface RemovedAssets {
icon: boolean;
logo: boolean;
hero: boolean;
}
const VALID_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
] as const;
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] as const;
const INITIAL_ASSET_PATHS: AssetPaths = {
icon: "",
logo: "",
hero: "",
};
const INITIAL_REMOVED_ASSETS: RemovedAssets = {
icon: false,
logo: false,
hero: false,
};
const INITIAL_ASSET_URLS: AssetUrls = {
icon: null,
logo: null,
hero: null,
};
export function EditGameModal({
visible,
onClose,
@@ -30,33 +92,18 @@ export function EditGameModal({
const { showSuccessToast, showErrorToast } = useToast();
const [gameName, setGameName] = useState("");
const [assetPaths, setAssetPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [assetDisplayPaths, setAssetDisplayPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [originalAssetPaths, setOriginalAssetPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [removedAssets, setRemovedAssets] = useState({
icon: false,
logo: false,
hero: false,
});
const [defaultUrls, setDefaultUrls] = useState({
icon: null as string | null,
logo: null as string | null,
hero: null as string | null,
});
const [assetPaths, setAssetPaths] = useState<AssetPaths>(INITIAL_ASSET_PATHS);
const [assetDisplayPaths, setAssetDisplayPaths] =
useState<AssetPaths>(INITIAL_ASSET_PATHS);
const [originalAssetPaths, setOriginalAssetPaths] =
useState<AssetPaths>(INITIAL_ASSET_PATHS);
const [removedAssets, setRemovedAssets] = useState<RemovedAssets>(
INITIAL_REMOVED_ASSETS
);
const [defaultUrls, setDefaultUrls] = useState<AssetUrls>(INITIAL_ASSET_URLS);
const [isUpdating, setIsUpdating] = useState(false);
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
const isCustomGame = (game: LibraryGame | Game): boolean => {
return game.shop === "custom";
@@ -66,12 +113,18 @@ export function EditGameModal({
return url?.startsWith("local:") ? url.replace("local:", "") : "";
};
const capitalizeAssetType = (assetType: AssetType): string => {
return assetType.charAt(0).toUpperCase() + assetType.slice(1);
};
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
// Check if assets were removed (URLs are null but original paths exist)
const iconRemoved = !game.iconUrl && (game as any).originalIconPath;
const logoRemoved = !game.logoImageUrl && (game as any).originalLogoPath;
const gameWithAssets = game as GameWithOriginalAssets;
const iconRemoved =
!game.iconUrl && Boolean(gameWithAssets.originalIconPath);
const logoRemoved =
!game.logoImageUrl && Boolean(gameWithAssets.originalLogoPath);
const heroRemoved =
!game.libraryHeroImageUrl && (game as any).originalHeroPath;
!game.libraryHeroImageUrl && Boolean(gameWithAssets.originalHeroPath);
setAssetPaths({
icon: extractLocalPath(game.iconUrl),
@@ -84,15 +137,14 @@ export function EditGameModal({
hero: extractLocalPath(game.libraryHeroImageUrl),
});
setOriginalAssetPaths({
icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl),
icon: gameWithAssets.originalIconPath || extractLocalPath(game.iconUrl),
logo:
(game as any).originalLogoPath || extractLocalPath(game.logoImageUrl),
gameWithAssets.originalLogoPath || extractLocalPath(game.logoImageUrl),
hero:
(game as any).originalHeroPath ||
gameWithAssets.originalHeroPath ||
extractLocalPath(game.libraryHeroImageUrl),
});
// Set removed assets state based on whether assets were explicitly removed
setRemovedAssets({
icon: iconRemoved,
logo: logoRemoved,
@@ -102,13 +154,15 @@ export function EditGameModal({
const setNonCustomGameAssets = useCallback(
(game: LibraryGame) => {
// Check if assets were removed (custom URLs are null but original paths exist)
const gameWithAssets = game as LibraryGameWithCustomOriginalAssets;
const iconRemoved =
!game.customIconUrl && (game as any).customOriginalIconPath;
!game.customIconUrl && Boolean(gameWithAssets.customOriginalIconPath);
const logoRemoved =
!game.customLogoImageUrl && (game as any).customOriginalLogoPath;
!game.customLogoImageUrl &&
Boolean(gameWithAssets.customOriginalLogoPath);
const heroRemoved =
!game.customHeroImageUrl && (game as any).customOriginalHeroPath;
!game.customHeroImageUrl &&
Boolean(gameWithAssets.customOriginalHeroPath);
setAssetPaths({
icon: extractLocalPath(game.customIconUrl),
@@ -122,17 +176,16 @@ export function EditGameModal({
});
setOriginalAssetPaths({
icon:
(game as any).customOriginalIconPath ||
gameWithAssets.customOriginalIconPath ||
extractLocalPath(game.customIconUrl),
logo:
(game as any).customOriginalLogoPath ||
gameWithAssets.customOriginalLogoPath ||
extractLocalPath(game.customLogoImageUrl),
hero:
(game as any).customOriginalHeroPath ||
gameWithAssets.customOriginalHeroPath ||
extractLocalPath(game.customHeroImageUrl),
});
// Set removed assets state based on whether assets were explicitly removed
setRemovedAssets({
icon: iconRemoved,
logo: logoRemoved,
@@ -171,29 +224,22 @@ export function EditGameModal({
setSelectedAssetType(assetType);
};
const getAssetPath = (assetType: AssetType): string => {
return assetPaths[assetType];
};
const getAssetDisplayPath = (assetType: AssetType): string => {
// If asset was removed, don't show any path
if (removedAssets[assetType]) {
return "";
}
// Use display path first, then fall back to original path
return assetDisplayPaths[assetType] || originalAssetPaths[assetType];
};
const setAssetPath = (assetType: AssetType, path: string): void => {
const updateAssetPaths = (
assetType: AssetType,
path: string,
displayPath: string
): void => {
setAssetPaths((prev) => ({ ...prev, [assetType]: path }));
};
const setAssetDisplayPath = (assetType: AssetType, path: string): void => {
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path }));
};
const getDefaultUrl = (assetType: AssetType): string | null => {
return defaultUrls[assetType];
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: displayPath }));
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: displayPath }));
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
};
const getOriginalAssetUrl = (assetType: AssetType): string | null => {
@@ -217,7 +263,7 @@ export function EditGameModal({
filters: [
{
name: t("edit_game_modal_image_filter"),
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
extensions: [...IMAGE_EXTENSIONS],
},
],
});
@@ -229,41 +275,26 @@ export function EditGameModal({
originalPath,
assetType
);
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
setAssetDisplayPath(assetType, originalPath);
// Store the original path for display purposes
setOriginalAssetPaths((prev) => ({
...prev,
[assetType]: originalPath,
}));
// Clear the removed flag when a new asset is selected
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
updateAssetPaths(
assetType,
copiedAssetUrl.replace("local:", ""),
originalPath
);
} catch (error) {
console.error(`Failed to copy ${assetType} asset:`, error);
setAssetPath(assetType, originalPath);
setAssetDisplayPath(assetType, originalPath);
setOriginalAssetPaths((prev) => ({
...prev,
[assetType]: originalPath,
}));
// Clear the removed flag when a new asset is selected
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
updateAssetPaths(assetType, originalPath, originalPath);
}
}
};
const handleRestoreDefault = (assetType: AssetType) => {
// Mark asset as removed and clear paths (for both custom and non-custom games)
setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
setAssetPath(assetType, "");
setAssetDisplayPath(assetType, "");
// Don't clear originalAssetPaths - keep them for reference but don't use them for display
setAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: "" }));
};
const getOriginalTitle = (): string => {
if (!game) return "";
// For non-custom games, the original title is from shopDetails assets
return shopDetails?.assets?.title || game.title || "";
};
@@ -274,12 +305,10 @@ export function EditGameModal({
const isTitleChanged = useMemo((): boolean => {
if (!game || isCustomGame(game)) return false;
const originalTitle = getOriginalTitle();
const originalTitle = shopDetails?.assets?.title || game.title || "";
return gameName.trim() !== originalTitle.trim();
}, [game, gameName, shopDetails]);
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -300,14 +329,9 @@ export function EditGameModal({
};
const validateImageFile = (file: File): boolean => {
const validTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
return validTypes.includes(file.type);
return VALID_IMAGE_TYPES.includes(
file.type as (typeof VALID_IMAGE_TYPES)[number]
);
};
const processDroppedFile = async (file: File, assetType: AssetType) => {
@@ -321,10 +345,6 @@ export function EditGameModal({
try {
let filePath: string;
interface ElectronFile extends File {
path?: string;
}
if ("path" in file && typeof (file as ElectronFile).path === "string") {
filePath = (file as ElectronFile).path!;
} else {
@@ -351,12 +371,13 @@ export function EditGameModal({
assetType
);
const assetPath = copiedAssetUrl.replace("local:", "");
setAssetPath(assetType, assetPath);
setAssetDisplayPath(assetType, filePath);
updateAssetPaths(
assetType,
copiedAssetUrl.replace("local:", ""),
filePath
);
showSuccessToast(
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
`${capitalizeAssetType(assetType)} updated successfully!`
);
if (!("path" in file) && filePath) {
@@ -387,63 +408,45 @@ export function EditGameModal({
}
};
// Helper function to prepare custom game assets
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
// For custom games, check if asset was explicitly removed
let iconUrl;
if (removedAssets.icon) {
iconUrl = null;
} else if (assetPaths.icon) {
iconUrl = `local:${assetPaths.icon}`;
} else {
iconUrl = game.iconUrl;
}
const iconUrl = removedAssets.icon
? null
: assetPaths.icon
? `local:${assetPaths.icon}`
: game.iconUrl;
let logoImageUrl;
if (removedAssets.logo) {
logoImageUrl = null;
} else if (assetPaths.logo) {
logoImageUrl = `local:${assetPaths.logo}`;
} else {
logoImageUrl = game.logoImageUrl;
}
const logoImageUrl = removedAssets.logo
? null
: assetPaths.logo
? `local:${assetPaths.logo}`
: game.logoImageUrl;
// For hero image, if removed, restore to the original gradient or keep the original
let libraryHeroImageUrl;
if (removedAssets.hero) {
// If the original hero was a gradient (data URL), keep it, otherwise generate a new one
const originalHero = game.libraryHeroImageUrl;
libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml")
? originalHero
: generateRandomGradient();
} else {
libraryHeroImageUrl = assetPaths.hero
const libraryHeroImageUrl = removedAssets.hero
? game.libraryHeroImageUrl?.startsWith("data:image/svg+xml")
? game.libraryHeroImageUrl
: generateRandomGradient()
: assetPaths.hero
? `local:${assetPaths.hero}`
: game.libraryHeroImageUrl;
}
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
};
// Helper function to prepare non-custom game assets
const prepareNonCustomGameAssets = () => {
const hasIconPath = assetPaths.icon;
let customIconUrl: string | null = null;
if (!removedAssets.icon && hasIconPath) {
customIconUrl = `local:${assetPaths.icon}`;
}
const customIconUrl =
!removedAssets.icon && assetPaths.icon
? `local:${assetPaths.icon}`
: null;
const hasLogoPath = assetPaths.logo;
let customLogoImageUrl: string | null = null;
if (!removedAssets.logo && hasLogoPath) {
customLogoImageUrl = `local:${assetPaths.logo}`;
}
const customLogoImageUrl =
!removedAssets.logo && assetPaths.logo
? `local:${assetPaths.logo}`
: null;
const hasHeroPath = assetPaths.hero;
let customHeroImageUrl: string | null = null;
if (!removedAssets.hero && hasHeroPath) {
customHeroImageUrl = `local:${assetPaths.hero}`;
}
const customHeroImageUrl =
!removedAssets.hero && assetPaths.hero
? `local:${assetPaths.hero}`
: null;
return {
customIconUrl,
@@ -452,7 +455,6 @@ export function EditGameModal({
};
};
// Helper function to update custom game
const updateCustomGame = async (game: LibraryGame | Game) => {
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
prepareCustomGameAssets(game);
@@ -470,7 +472,6 @@ export function EditGameModal({
});
};
// Helper function to update non-custom game
const updateNonCustomGame = async (game: LibraryGame) => {
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
prepareNonCustomGameAssets();
@@ -521,43 +522,17 @@ export function EditGameModal({
}
};
// Helper function to reset form to initial state
const resetFormToInitialState = useCallback(
(game: LibraryGame | Game) => {
setGameName(game.title || "");
// Reset removed assets state
setRemovedAssets({
icon: false,
logo: false,
hero: false,
});
// Clear all asset paths to ensure clean state
setAssetPaths({
icon: "",
logo: "",
hero: "",
});
setAssetDisplayPaths({
icon: "",
logo: "",
hero: "",
});
setOriginalAssetPaths({
icon: "",
logo: "",
hero: "",
});
setRemovedAssets(INITIAL_REMOVED_ASSETS);
setAssetPaths(INITIAL_ASSET_PATHS);
setAssetDisplayPaths(INITIAL_ASSET_PATHS);
setOriginalAssetPaths(INITIAL_ASSET_PATHS);
if (isCustomGame(game)) {
setCustomGameAssets(game);
// Clear default URLs for custom games
setDefaultUrls({
icon: null,
logo: null,
hero: null,
});
setDefaultUrls(INITIAL_ASSET_URLS);
} else {
setNonCustomGameAssets(game as LibraryGame);
}
@@ -575,8 +550,8 @@ export function EditGameModal({
const isFormValid = gameName.trim();
const getPreviewUrl = (assetType: AssetType): string | undefined => {
const assetPath = getAssetPath(assetType);
const defaultUrl = getDefaultUrl(assetType);
const assetPath = assetPaths[assetType];
const defaultUrl = defaultUrls[assetType];
if (game && !isCustomGame(game)) {
return assetPath ? `local:${assetPath}` : defaultUrl || undefined;
@@ -585,9 +560,9 @@ export function EditGameModal({
};
const renderImageSection = (assetType: AssetType) => {
const assetPath = getAssetPath(assetType);
const assetPath = assetPaths[assetType];
const assetDisplayPath = getAssetDisplayPath(assetType);
const defaultUrl = getDefaultUrl(assetType);
const defaultUrl = defaultUrls[assetType];
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
const isDragOver = dragOverTarget === assetType;

View File

@@ -15,7 +15,6 @@ import {
TextField,
CheckboxField,
} from "@renderer/components";
import { downloadSourcesTable } from "@renderer/dexie";
import type { DownloadSource } from "@types";
import type { GameRepack } from "@types";
@@ -105,7 +104,7 @@ export function RepacksModal({
}, [repacks, hashesInDebrid]);
useEffect(() => {
downloadSourcesTable.toArray().then((sources) => {
window.electron.getDownloadSourcesList().then((sources) => {
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
const filteredSources = sources.filter(
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
@@ -129,6 +128,7 @@ export function RepacksModal({
return downloadSources.some(
(src) =>
src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) &&
src.name === repack.repacker
);
@@ -210,25 +210,32 @@ export function RepacksModal({
className={`repacks-modal__download-sources ${isFilterDrawerOpen ? "repacks-modal__download-sources--open" : ""}`}
>
<div className="repacks-modal__source-grid">
{downloadSources.map((source) => {
const label = source.name || source.url;
const truncatedLabel =
label.length > 16 ? label.substring(0, 16) + "..." : label;
return (
<div
key={source.fingerprint}
className="repacks-modal__source-item"
>
<CheckboxField
label={truncatedLabel}
checked={selectedFingerprints.includes(
source.fingerprint
)}
onChange={() => toggleFingerprint(source.fingerprint)}
/>
</div>
);
})}
{downloadSources
.filter(
(
source
): source is DownloadSource & { fingerprint: string } =>
source.fingerprint !== undefined
)
.map((source) => {
const label = source.name || source.url;
const truncatedLabel =
label.length > 16 ? label.substring(0, 16) + "..." : label;
return (
<div
key={source.fingerprint}
className="repacks-modal__source-item"
>
<CheckboxField
label={truncatedLabel}
checked={selectedFingerprints.includes(
source.fingerprint
)}
onChange={() => toggleFingerprint(source.fingerprint)}
/>
</div>
);
})}
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
@use "../../scss/globals.scss";
.game-details {
&__review-translation-toggle {
display: inline-flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
margin-top: calc(globals.$spacing-unit * 1.5);
padding: 0;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 0.875rem;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
color: rgba(255, 255, 255, 0.9);
}
}
}

View File

@@ -1,8 +1,9 @@
import { TrashIcon, ClockIcon } from "@primer/octicons-react";
import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
import { ThumbsUp, ThumbsDown, Star, Languages } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared";
@@ -10,6 +11,8 @@ import { useDate } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
import "./review-item.scss";
interface ReviewItemProps {
review: GameReview;
userDetailsId?: string;
@@ -63,9 +66,45 @@ export function ReviewItem({
onAnimationComplete,
}: Readonly<ReviewItemProps>) {
const navigate = useNavigate();
const { t } = useTranslation("game_details");
const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate();
const [showOriginal, setShowOriginal] = useState(false);
// Check if this is the user's own review
const isOwnReview = userDetailsId === review.user.id;
// Helper to get base language code (e.g., "pt" from "pt-BR")
const getBaseLanguage = (lang: string) => lang.split("-")[0];
// Check if the review is in a different language (comparing base language codes)
const isDifferentLanguage =
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
// Check if translation is available and needed (but not for own reviews)
const needsTranslation =
!isOwnReview &&
isDifferentLanguage &&
review.translations &&
review.translations[i18n.language];
// Get the full language name using Intl.DisplayNames
const getLanguageName = (languageCode: string) => {
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
});
return displayNames.of(languageCode) || languageCode.toUpperCase();
} catch {
return languageCode.toUpperCase();
}
};
// Determine which content to show - always show original for own reviews
const displayContent = needsTranslation
? review.translations[i18n.language]
: review.reviewHtml;
if (isBlocked && !isVisible) {
return (
<div className="game-details__review-item">
@@ -135,12 +174,41 @@ export function ReviewItem({
))}
</div>
</div>
<div
className="game-details__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
<div>
<div
className="game-details__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(displayContent),
}}
/>
{needsTranslation && (
<>
<button
className="game-details__review-translation-toggle"
onClick={() => setShowOriginal(!showOriginal)}
>
<Languages size={13} />
{showOriginal
? t("hide_original")
: t("show_original_translated_from", {
language: getLanguageName(review.detectedLanguage),
})}
</button>
{showOriginal && (
<div
className="game-details__review-content"
style={{
opacity: 0.6,
marginTop: "12px",
}}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
)}
</>
)}
</div>
<div className="game-details__review-actions">
<div className="game-details__review-votes">
<motion.button

View File

@@ -25,7 +25,7 @@ export function HowLongToBeatSection({
return `${value} ${t(durationTranslation[unit])}`;
};
if (!howLongToBeatData || !isLoading) return null;
if (!howLongToBeatData && !isLoading) return null;
return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">

View File

@@ -17,7 +17,6 @@ import {
StarIcon,
} from "@primer/octicons-react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers";
import { useSubscription } from "@renderer/hooks/use-subscription";
@@ -80,41 +79,22 @@ export function Sidebar() {
if (objectId) {
setHowLongToBeat({ isLoading: true, data: null });
howLongToBeatEntriesTable
.where({ shop, objectId })
.first()
.then(async (cachedHowLongToBeat) => {
if (cachedHowLongToBeat) {
setHowLongToBeat({
isLoading: false,
data: cachedHowLongToBeat.categories,
});
} else {
try {
const howLongToBeat = await window.electron.hydraApi.get<
HowLongToBeatCategory[] | null
>(`/games/${shop}/${objectId}/how-long-to-beat`, {
needsAuth: false,
});
if (howLongToBeat) {
howLongToBeatEntriesTable.add({
objectId,
shop: "steam",
createdAt: new Date(),
updatedAt: new Date(),
categories: howLongToBeat,
});
}
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
} catch (err) {
setHowLongToBeat({ isLoading: false, data: null });
}
// Directly fetch from API without checking cache
window.electron.hydraApi
.get<HowLongToBeatCategory[] | null>(
`/games/${shop}/${objectId}/how-long-to-beat`,
{
needsAuth: false,
}
)
.then((howLongToBeatData) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeatData });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
});
}
}, [objectId, shop, gameTitle]);
}, [objectId, shop]);
return (
<aside className="content-sidebar">
@@ -240,14 +220,6 @@ export function Sidebar() {
: (stats?.averageScore ?? null)
}
size={16}
showCalculating={
!!(
stats &&
(stats.averageScore === null || stats.averageScore === 0)
)
}
calculatingText={t("calculating", { ns: "game_card" })}
hideIcon={true}
/>
</div>
</div>

View File

@@ -5,7 +5,7 @@ export const sectionVariants = {
height: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
ease: [0.25, 0.1, 0.25, 1] as const,
opacity: { duration: 0.1 },
y: { duration: 0.1 },
height: { duration: 0.2 },
@@ -17,13 +17,13 @@ export const sectionVariants = {
height: "auto",
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
ease: [0.25, 0.1, 0.25, 1] as const,
opacity: { duration: 0.2, delay: 0.1 },
y: { duration: 0.3 },
height: { duration: 0.3 },
},
},
};
} as const;
export const gameCardVariants = {
hidden: {
@@ -37,7 +37,7 @@ export const gameCardVariants = {
scale: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1],
ease: [0.25, 0.1, 0.25, 1] as const,
},
},
exit: {
@@ -46,10 +46,10 @@ export const gameCardVariants = {
scale: 0.95,
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
ease: [0.25, 0.1, 0.25, 1] as const,
},
},
};
} as const;
export const gameGridVariants = {
hidden: {
@@ -76,16 +76,16 @@ export const chevronVariants = {
rotate: 0,
transition: {
duration: 0.2,
ease: "easeInOut",
ease: "easeInOut" as const,
},
},
expanded: {
rotate: 90,
transition: {
duration: 0.2,
ease: "easeInOut",
ease: "easeInOut" as const,
},
},
};
} as const;
export const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;

View File

@@ -14,7 +14,7 @@ export function SortOptions({ sortBy, onSortChange }: SortOptionsProps) {
return (
<div className="sort-options__container">
<span className="sort-options__label">Sort by:</span>
<span className="sort-options__label">{t("sort_by")}</span>
<div className="sort-options__options">
<button
className={`sort-options__option ${sortBy === "achievementCount" ? "active" : ""}`}

View File

@@ -1,5 +1,14 @@
@use "../../scss/globals.scss";
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.add-download-source-modal {
&__container {
display: flex;
@@ -24,4 +33,9 @@
&__validate-button {
align-self: flex-end;
}
&__spinner {
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
}
}

View File

@@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form";
import { useAppDispatch } from "@renderer/hooks";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { downloadSourcesTable } from "@renderer/dexie";
import type { DownloadSourceValidationResult } from "@types";
import { downloadSourcesWorker } from "@renderer/workers";
import { setIsImportingSources } from "@renderer/features";
import { SyncIcon } from "@primer/octicons-react";
import "./add-download-source-modal.scss";
interface AddDownloadSourceModalProps {
@@ -52,13 +53,15 @@ export function AddDownloadSourceModal({
const { sourceUrl } = useContext(settingsContext);
const dispatch = useAppDispatch();
const onSubmit = useCallback(
async (values: FormValues) => {
const existingDownloadSource = await downloadSourcesTable
.where({ url: values.url })
.first();
const exists = await window.electron.checkDownloadSourceExists(
values.url
);
if (existingDownloadSource) {
if (exists) {
setError("url", {
type: "server",
message: t("source_already_exists"),
@@ -67,22 +70,11 @@ export function AddDownloadSourceModal({
return;
}
downloadSourcesWorker.postMessage([
"VALIDATE_DOWNLOAD_SOURCE",
values.url,
]);
const channel = new BroadcastChannel(
`download_sources:validate:${values.url}`
const validationResult = await window.electron.validateDownloadSource(
values.url
);
channel.onmessage = (
event: MessageEvent<DownloadSourceValidationResult>
) => {
setValidationResult(event.data);
channel.close();
};
setValidationResult(validationResult);
setUrl(values.url);
},
[setError, t]
@@ -100,44 +92,44 @@ export function AddDownloadSourceModal({
}
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const putDownloadSource = async () => {
const downloadSource = await downloadSourcesTable.where({ url }).first();
if (!downloadSource) return;
window.electron
.putDownloadSource(downloadSource.objectIds)
.then(({ fingerprint }) => {
downloadSourcesTable.update(downloadSource.id, { fingerprint });
});
};
const handleAddDownloadSource = async () => {
if (validationResult) {
setIsLoading(true);
dispatch(setIsImportingSources(true));
const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = () => {
window.electron.createDownloadSources([url]);
setIsLoading(false);
putDownloadSource();
try {
// Single call that handles: import → API sync → fingerprint
await window.electron.addDownloadSource(url);
// Close modal and update UI
onClose();
onAddDownloadSource();
channel.close();
};
} catch (error) {
console.error("Failed to add download source:", error);
setError("url", {
type: "server",
message: "Failed to import source. Please try again.",
});
} finally {
setIsLoading(false);
dispatch(setIsImportingSources(false));
}
}
};
const handleClose = () => {
// Prevent closing while importing
if (isLoading) return;
onClose();
};
return (
<Modal
visible={visible}
title={t("add_download_source")}
description={t("add_download_source_description")}
onClose={onClose}
onClose={handleClose}
clickOutsideToClose={!isLoading}
>
<div className="add-download-source-modal__container">
<TextField
@@ -176,7 +168,10 @@ export function AddDownloadSourceModal({
onClick={handleAddDownloadSource}
disabled={isLoading}
>
{t("import")}
{isLoading && (
<SyncIcon className="add-download-source-modal__spinner" />
)}
{isLoading ? t("importing") : t("import")}
</Button>
</div>
)}

View File

@@ -1,12 +0,0 @@
.settings-all-debrid {
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__description {
margin: 0;
color: var(--text-secondary);
}
}

View File

@@ -1,129 +0,0 @@
import { useContext, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
import "./settings-all-debrid.scss";
import { useAppSelector, useToast } from "@renderer/hooks";
import { settingsContext } from "@renderer/context";
const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys";
export function SettingsAllDebrid() {
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { updateUserPreferences } = useContext(settingsContext);
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
useAllDebrid: false,
allDebridApiKey: null as string | null,
});
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation("settings");
useEffect(() => {
if (userPreferences) {
setForm({
useAllDebrid: Boolean(userPreferences.allDebridApiKey),
allDebridApiKey: userPreferences.allDebridApiKey ?? null,
});
}
}, [userPreferences]);
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
setIsLoading(true);
event.preventDefault();
try {
if (form.useAllDebrid) {
if (!form.allDebridApiKey) {
showErrorToast(t("alldebrid_missing_key"));
return;
}
const result = await window.electron.authenticateAllDebrid(
form.allDebridApiKey
);
if ("error_code" in result) {
showErrorToast(t(result.error_code));
return;
}
if (!result.isPremium) {
showErrorToast(
t("all_debrid_free_account_error", { username: result.username })
);
return;
}
showSuccessToast(
t("all_debrid_account_linked"),
t("debrid_linked_message", { username: result.username })
);
} else {
showSuccessToast(t("changes_saved"));
}
updateUserPreferences({
allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null,
});
} catch (err: any) {
showErrorToast(t("alldebrid_unknown_error"));
} finally {
setIsLoading(false);
}
};
const isButtonDisabled =
(form.useAllDebrid && !form.allDebridApiKey) || isLoading;
return (
<form className="settings-all-debrid__form" onSubmit={handleFormSubmit}>
<p className="settings-all-debrid__description">
{t("all_debrid_description")}
</p>
<CheckboxField
label={t("enable_all_debrid")}
checked={form.useAllDebrid}
onChange={() =>
setForm((prev) => ({
...prev,
useAllDebrid: !form.useAllDebrid,
}))
}
/>
{form.useAllDebrid && (
<TextField
label={t("api_token")}
value={form.allDebridApiKey ?? ""}
type="password"
onChange={(event) =>
setForm({ ...form, allDebridApiKey: event.target.value })
}
rightContent={
<Button type="submit" disabled={isButtonDisabled}>
{t("save_changes")}
</Button>
}
placeholder="API Key"
hint={
<Trans i18nKey="debrid_api_token_hint" ns="settings">
<Link to={ALL_DEBRID_API_TOKEN_URL} />
</Trans>
}
/>
)}
</form>
);
}

View File

@@ -2,7 +2,6 @@ import { useState, useCallback, useMemo } from "react";
import { useFeature, useAppSelector } from "@renderer/hooks";
import { SettingsTorBox } from "./settings-torbox";
import { SettingsRealDebrid } from "./settings-real-debrid";
import { SettingsAllDebrid } from "./settings-all-debrid";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRightIcon, CheckCircleFillIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
@@ -11,7 +10,6 @@ import "./settings-debrid.scss";
interface CollapseState {
torbox: boolean;
realDebrid: boolean;
allDebrid: boolean;
}
const sectionVariants = {
@@ -21,7 +19,7 @@ const sectionVariants = {
height: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
ease: [0.25, 0.1, 0.25, 1] as const,
opacity: { duration: 0.1 },
y: { duration: 0.1 },
height: { duration: 0.2 },
@@ -33,30 +31,30 @@ const sectionVariants = {
height: "auto",
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
ease: [0.25, 0.1, 0.25, 1] as const,
opacity: { duration: 0.2, delay: 0.1 },
y: { duration: 0.3 },
height: { duration: 0.3 },
},
},
};
} as const;
const chevronVariants = {
collapsed: {
rotate: 0,
transition: {
duration: 0.2,
ease: "easeInOut",
ease: "easeInOut" as const,
},
},
expanded: {
rotate: 90,
transition: {
duration: 0.2,
ease: "easeInOut",
ease: "easeInOut" as const,
},
},
};
} as const;
export function SettingsDebrid() {
const { t } = useTranslation("settings");
@@ -71,7 +69,6 @@ export function SettingsDebrid() {
return {
torbox: !userPreferences?.torBoxApiToken,
realDebrid: !userPreferences?.realDebridApiToken,
allDebrid: !userPreferences?.allDebridApiKey,
};
}, [userPreferences]);
@@ -178,51 +175,6 @@ export function SettingsDebrid() {
</AnimatePresence>
</div>
)}
<div className="settings-debrid__section">
<div className="settings-debrid__section-header">
<button
type="button"
className="settings-debrid__collapse-button"
onClick={() => toggleSection("allDebrid")}
aria-label={
collapseState.allDebrid
? "Expand All-Debrid section"
: "Collapse All-Debrid section"
}
>
<motion.div
variants={chevronVariants}
animate={collapseState.allDebrid ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h3 className="settings-debrid__section-title">All-Debrid</h3>
<span className="settings-debrid__beta-badge">BETA</span>
{userPreferences?.allDebridApiKey && (
<CheckCircleFillIcon
size={16}
className="settings-debrid__check-icon"
/>
)}
</div>
<AnimatePresence initial={true} mode="wait">
{!collapseState.allDebrid && (
<motion.div
key="alldebrid-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<SettingsAllDebrid />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -19,11 +19,8 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
import { useNavigate } from "react-router-dom";
import { setFilters, clearFilters } from "@renderer/features";
import { generateUUID } from "@renderer/helpers";
import "./settings-download-sources.scss";
export function SettingsDownloadSources() {
@@ -52,11 +49,10 @@ export function SettingsDownloadSources() {
const { updateRepacks } = useRepacks();
const getDownloadSources = async () => {
await downloadSourcesTable
.toCollection()
.sortBy("createdAt")
await window.electron
.getDownloadSourcesList()
.then((sources) => {
setDownloadSources(sources.reverse());
setDownloadSources(sources);
})
.finally(() => {
setIsFetchingSources(false);
@@ -71,68 +67,67 @@ export function SettingsDownloadSources() {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
const handleRemoveSource = (downloadSource: DownloadSource) => {
const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
const channel = new BroadcastChannel(
`download_sources:delete:${downloadSource.id}`
);
downloadSourcesWorker.postMessage([
"DELETE_DOWNLOAD_SOURCE",
downloadSource.id,
]);
try {
await window.electron.deleteDownloadSource(downloadSource.id);
await window.electron.removeDownloadSource(downloadSource.url);
channel.onmessage = () => {
showSuccessToast(t("removed_download_source"));
window.electron.removeDownloadSource(downloadSource.url);
getDownloadSources();
setIsRemovingDownloadSource(false);
channel.close();
await getDownloadSources();
updateRepacks();
};
} finally {
setIsRemovingDownloadSource(false);
}
};
const handleRemoveAllDownloadSources = () => {
const handleRemoveAllDownloadSources = async () => {
setIsRemovingDownloadSource(true);
const id = generateUUID();
const channel = new BroadcastChannel(`download_sources:delete_all:${id}`);
try {
await window.electron.deleteAllDownloadSources();
await window.electron.removeDownloadSource("", true);
downloadSourcesWorker.postMessage(["DELETE_ALL_DOWNLOAD_SOURCES", id]);
channel.onmessage = () => {
showSuccessToast(t("removed_download_sources"));
window.electron.removeDownloadSource("", true);
getDownloadSources();
setIsRemovingDownloadSource(false);
await getDownloadSources();
setShowConfirmationDeleteAllSourcesModal(false);
channel.close();
updateRepacks();
};
} finally {
setIsRemovingDownloadSource(false);
}
};
const handleAddDownloadSource = async () => {
// Refresh sources list and repacks after import completes
await getDownloadSources();
// Force repacks update to ensure UI reflects new data
await updateRepacks();
showSuccessToast(t("added_download_source"));
updateRepacks();
};
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
const id = generateUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
try {
// Sync local sources (check for updates)
await window.electron.syncDownloadSources();
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
// Refresh sources and repacks AFTER sync completes
await getDownloadSources();
await updateRepacks();
channel.onmessage = () => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
} catch (error) {
console.error("Error syncing download sources:", error);
// Still refresh the UI even if sync fails
await getDownloadSources();
await updateRepacks();
} finally {
setIsSyncingDownloadSources(false);
channel.close();
updateRepacks();
};
}
};
const statusTitle = {
@@ -145,7 +140,12 @@ export function SettingsDownloadSources() {
setShowAddDownloadSourceModal(false);
};
const navigateToCatalogue = (fingerprint: string) => {
const navigateToCatalogue = (fingerprint?: string) => {
if (!fingerprint) {
console.error("Cannot navigate: fingerprint is undefined");
return;
}
dispatch(clearFilters());
dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] }));
@@ -222,54 +222,58 @@ export function SettingsDownloadSources() {
</div>
<ul className="settings-download-sources__list">
{downloadSources.map((downloadSource) => (
<li
key={downloadSource.id}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
>
<div className="settings-download-sources__item-header">
<h2>{downloadSource.name}</h2>
{downloadSources.map((downloadSource) => {
return (
<li
key={downloadSource.id}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
>
<div className="settings-download-sources__item-header">
<h2>{downloadSource.name}</h2>
<div style={{ display: "flex" }}>
<Badge>{statusTitle[downloadSource.status]}</Badge>
<div style={{ display: "flex" }}>
<Badge>{statusTitle[downloadSource.status]}</Badge>
</div>
<button
type="button"
className="settings-download-sources__navigate-button"
disabled={!downloadSource.fingerprint}
onClick={() =>
navigateToCatalogue(downloadSource.fingerprint)
}
>
<small>
{t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
</small>
</button>
</div>
<button
type="button"
className="settings-download-sources__navigate-button"
disabled={!downloadSource.fingerprint}
onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
>
<small>
{t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
</small>
</button>
</div>
<TextField
label={t("download_source_url")}
value={downloadSource.url}
readOnly
theme="dark"
disabled
rightContent={
<Button
type="button"
theme="outline"
onClick={() => handleRemoveSource(downloadSource)}
disabled={isRemovingDownloadSource}
>
<NoEntryIcon />
{t("remove_download_source")}
</Button>
}
/>
</li>
))}
<TextField
label={t("download_source_url")}
value={downloadSource.url}
readOnly
theme="dark"
disabled
rightContent={
<Button
type="button"
theme="outline"
onClick={() => handleRemoveSource(downloadSource)}
disabled={isRemovingDownloadSource}
>
<NoEntryIcon />
{t("remove_download_source")}
</Button>
}
/>
</li>
);
})}
</ul>
</>
);

View File

@@ -9,6 +9,7 @@ import {
gameRunningSlice,
subscriptionSlice,
repacksSlice,
downloadSourcesSlice,
catalogueSearchSlice,
} from "@renderer/features";
@@ -23,6 +24,7 @@ export const store = configureStore({
gameRunning: gameRunningSlice.reducer,
subscription: subscriptionSlice.reducer,
repacks: repacksSlice.reducer,
downloadSources: downloadSourcesSlice.reducer,
catalogueSearch: catalogueSearchSlice.reducer,
},
});

View File

@@ -1,238 +0,0 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus, formatName, pipe } from "@shared";
import { GameRepack } from "@types";
const formatRepackName = pipe((name) => name.replace("[DL]", ""), formatName);
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
type Payload =
| ["IMPORT_DOWNLOAD_SOURCE", string]
| ["DELETE_DOWNLOAD_SOURCE", number]
| ["VALIDATE_DOWNLOAD_SOURCE", string]
| ["SYNC_DOWNLOAD_SOURCES", string]
| ["DELETE_ALL_DOWNLOAD_SOURCES", string];
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: SteamGamesByLetter
) => {
const now = new Date();
const results = [] as (Omit<GameRepack, "id"> & {
downloadSourceId: number;
})[];
const objectIdsOnSource = new Set<string>();
for (const download of downloads) {
const formattedTitle = formatRepackName(download.title);
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
const gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.name)
);
if (gamesInSteam.length === 0) continue;
for (const game of gamesInSteam) {
objectIdsOnSource.add(String(game.id));
}
results.push({
objectIds: gamesInSteam.map((game) => String(game.id)),
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
});
}
await repacksTable.bulkAdd(results);
await downloadSourcesTable.update(downloadSource.id, {
objectIds: Array.from(objectIdsOnSource),
});
};
const getSteamGames = async () => {
const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
return response.data;
};
const importDownloadSource = async (url: string) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const steamGames = await getSteamGames();
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date();
const id = await downloadSourcesTable.add({
url,
name: response.data.name,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
createdAt: now,
updatedAt: now,
});
const downloadSource = await downloadSourcesTable.get(id);
await addNewDownloads(downloadSource, response.data.downloads, steamGames);
});
};
const deleteDownloadSource = async (id: number) => {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: id }).delete();
await downloadSourcesTable.where({ id }).delete();
});
};
const deleteAllDowloadSources = async () => {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.clear();
await downloadSourcesTable.clear();
});
};
self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data;
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
const { name } = downloadSourceSchema.parse(response.data);
const channel = new BroadcastChannel(`download_sources:validate:${data}`);
channel.postMessage({
name,
etag: response.headers["etag"],
downloadCount: response.data.downloads.length,
});
}
if (type === "DELETE_ALL_DOWNLOAD_SOURCES") {
await deleteAllDowloadSources();
const channel = new BroadcastChannel(`download_sources:delete_all:${data}`);
channel.postMessage(true);
}
if (type === "DELETE_DOWNLOAD_SOURCE") {
await deleteDownloadSource(data);
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
channel.postMessage(true);
}
if (type === "IMPORT_DOWNLOAD_SOURCE") {
await importDownloadSource(data);
const channel = new BroadcastChannel(`download_sources:import:${data}`);
channel.postMessage(true);
}
if (type === "SYNC_DOWNLOAD_SOURCES") {
const channel = new BroadcastChannel(`download_sources:sync:${data}`);
let newRepacksCount = 0;
try {
const downloadSources = await downloadSourcesTable.toArray();
const existingRepacks = await repacksTable.toArray();
if (downloadSources.some((source) => !source.fingerprint)) {
await Promise.all(
downloadSources.map(async (source) => {
await deleteDownloadSource(source.id);
await importDownloadSource(source.url);
})
);
} else {
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
const steamGames = await getSteamGames();
await db.transaction(
"rw",
repacksTable,
downloadSourcesTable,
async () => {
await downloadSourcesTable.update(downloadSource.id, {
etag: response.headers["etag"],
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
const repacks = source.downloads.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
);
await addNewDownloads(downloadSource, repacks, steamGames);
newRepacksCount += repacks.length;
}
);
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesTable.update(downloadSource.id, {
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
}
channel.postMessage(newRepacksCount);
} catch (err) {
channel.postMessage(-1);
}
}
};

View File

@@ -1,3 +0,0 @@
import DownloadSourcesWorker from "./download-sources.worker?worker";
export const downloadSourcesWorker = new DownloadSourcesWorker();

View File

@@ -1,6 +1,5 @@
export enum Downloader {
RealDebrid,
AllDebrid,
Torrent,
Gofile,
PixelDrain,
@@ -56,7 +55,6 @@ export enum AuthPage {
export enum DownloadError {
NotCachedOnRealDebrid = "download_error_not_cached_on_real_debrid",
NotCachedInAllDebrid = "download_error_not_cached_in_alldebrid",
NotCachedOnTorBox = "download_error_not_cached_on_torbox",
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",

View File

@@ -39,6 +39,12 @@ export function sanitizeHtml(html: string): string {
}
}
// Remove code and pre tags but keep their text content
for (const el of tempDiv.querySelectorAll("code, pre")) {
const textNode = document.createTextNode(el.textContent || "");
el.replaceWith(textNode);
}
for (const el of tempDiv.querySelectorAll("*")) {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();

View File

@@ -124,7 +124,6 @@ export const getDownloadersForUri = (uri: string) => {
Downloader.Hydra,
Downloader.TorBox,
Downloader.RealDebrid,
Downloader.AllDebrid,
];
}
@@ -142,19 +141,6 @@ export const getDownloadersForUris = (uris: string[]) => {
return Array.from(downloadersSet);
};
export const steamUrlBuilder = {
library: (objectId: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/header.jpg`,
libraryHero: (objectId: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/library_hero.jpg`,
logo: (objectId: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/logo.png`,
cover: (objectId: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/library_600x900.jpg`,
icon: (objectId: string, clientIcon: string) =>
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`,
};
export const getDateLocale = (language: string) => {
if (language.startsWith("pt")) return ptBR;
if (language.startsWith("es")) return es;

View File

@@ -175,11 +175,3 @@ export interface SeedingStatus {
status: DownloadStatus;
uploadSpeed: number;
}
/* All-Debrid */
export interface AllDebridUser {
username: string;
email: string;
isPremium: boolean;
premiumUntil: string;
}

View File

@@ -35,7 +35,7 @@ export interface DownloadSource {
status: DownloadSourceStatus;
objectIds: string[];
downloadCount: number;
fingerprint: string;
fingerprint?: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
@@ -260,6 +260,10 @@ export interface GameReview {
displayName: string;
profileImageUrl: string | null;
};
translations: {
[key: string]: string;
};
detectedLanguage: string;
}
export interface TrendingGame extends ShopAssets {

View File

@@ -83,6 +83,7 @@ export interface GameAchievement {
achievements: SteamAchievement[];
unlockedAchievements: UnlockedAchievement[];
updatedAt: number | undefined;
language: string | undefined;
}
export type AchievementCustomNotificationPosition =
@@ -99,7 +100,6 @@ export interface UserPreferences {
language?: string;
realDebridApiToken?: string | null;
torBoxApiToken?: string | null;
allDebridApiKey?: string | null;
preferQuitInsteadOfHiding?: boolean;
runAtStartup?: boolean;
startMinimized?: boolean;

5146
yarn.lock

File diff suppressed because it is too large Load Diff